MULTI-BOARD !
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
4
dist/index.html
vendored
4
dist/index.html
vendored
@@ -4,8 +4,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Infinite</title>
|
||||
<script type="module" crossorigin src="/assets/index-DxdHmf_Q.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BZPHLDSC.css">
|
||||
<script type="module" crossorigin src="/assets/index-DaPgpxZP.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DJpJgVAz.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -2,7 +2,10 @@ mod pty;
|
||||
mod storage;
|
||||
|
||||
use pty::{pty_kill, pty_resize, pty_spawn, pty_write, PtyState};
|
||||
use storage::{board_load, board_save};
|
||||
use storage::{
|
||||
board_create, board_delete, board_load, board_rename, board_save, boards_load_index,
|
||||
boards_set_current,
|
||||
};
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
@@ -35,8 +38,13 @@ pub fn run() {
|
||||
pty_write,
|
||||
pty_resize,
|
||||
pty_kill,
|
||||
boards_load_index,
|
||||
board_save,
|
||||
board_load,
|
||||
board_create,
|
||||
board_rename,
|
||||
board_delete,
|
||||
boards_set_current,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -1,32 +1,119 @@
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use tauri::{AppHandle, Manager};
|
||||
use uuid::Uuid;
|
||||
|
||||
fn board_path(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
let dir = app
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BoardMeta {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BoardIndex {
|
||||
pub version: u32,
|
||||
pub current_board_id: String,
|
||||
pub boards: Vec<BoardMeta>,
|
||||
}
|
||||
|
||||
fn data_dir(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
let d = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| format!("app_data_dir: {e}"))?;
|
||||
fs::create_dir_all(&dir).map_err(|e| format!("create_dir_all: {e}"))?;
|
||||
Ok(dir.join("board.json"))
|
||||
fs::create_dir_all(&d).map_err(|e| format!("create_dir_all: {e}"))?;
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn board_save(app: AppHandle, state: Value) -> Result<(), String> {
|
||||
let path = board_path(&app)?;
|
||||
fn boards_dir(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
let d = data_dir(app)?.join("boards");
|
||||
fs::create_dir_all(&d).map_err(|e| format!("create boards dir: {e}"))?;
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
fn index_path(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
Ok(data_dir(app)?.join("boards.json"))
|
||||
}
|
||||
|
||||
fn board_file(app: &AppHandle, id: &str) -> Result<PathBuf, String> {
|
||||
if id.is_empty() || id.contains('/') || id.contains('\\') || id.contains("..") {
|
||||
return Err(format!("invalid board id: {id:?}"));
|
||||
}
|
||||
Ok(boards_dir(app)?.join(format!("{id}.json")))
|
||||
}
|
||||
|
||||
fn atomic_write(path: &Path, body: &str) -> Result<(), String> {
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
let body = serde_json::to_string_pretty(&state).map_err(|e| e.to_string())?;
|
||||
fs::write(&tmp, body).map_err(|e| format!("write tmp: {e}"))?;
|
||||
// Atomic replace so a crash mid-write can't corrupt the existing file.
|
||||
fs::rename(&tmp, &path).map_err(|e| format!("rename: {e}"))?;
|
||||
fs::rename(&tmp, path).map_err(|e| format!("rename: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn empty_board_json() -> String {
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"version": 1,
|
||||
"cards": [],
|
||||
"viewport": { "x": 0, "y": 0, "scale": 1 },
|
||||
"maxZ": 0,
|
||||
}))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn write_index(app: &AppHandle, idx: &BoardIndex) -> Result<(), String> {
|
||||
let body = serde_json::to_string_pretty(idx).map_err(|e| format!("serialize index: {e}"))?;
|
||||
atomic_write(&index_path(app)?, &body)
|
||||
}
|
||||
|
||||
fn read_index(app: &AppHandle) -> Result<BoardIndex, String> {
|
||||
let path = index_path(app)?;
|
||||
if !path.exists() {
|
||||
// First run, or upgrading from the pre-multi-board single-file layout.
|
||||
// Migrate the old board.json (if any) into boards/default.json.
|
||||
let legacy = data_dir(app)?.join("board.json");
|
||||
let default_id = "default".to_string();
|
||||
let default_file = board_file(app, &default_id)?;
|
||||
if legacy.exists() && !default_file.exists() {
|
||||
let body = fs::read_to_string(&legacy).map_err(|e| format!("read legacy: {e}"))?;
|
||||
atomic_write(&default_file, &body)?;
|
||||
let _ = fs::remove_file(&legacy);
|
||||
} else if !default_file.exists() {
|
||||
atomic_write(&default_file, &empty_board_json())?;
|
||||
}
|
||||
let idx = BoardIndex {
|
||||
version: 1,
|
||||
current_board_id: default_id.clone(),
|
||||
boards: vec![BoardMeta {
|
||||
id: default_id,
|
||||
name: "Default".to_string(),
|
||||
}],
|
||||
};
|
||||
write_index(app, &idx)?;
|
||||
return Ok(idx);
|
||||
}
|
||||
let body = fs::read_to_string(&path).map_err(|e| format!("read index: {e}"))?;
|
||||
serde_json::from_str(&body).map_err(|e| format!("parse index: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn board_load(app: AppHandle) -> Result<Option<Value>, String> {
|
||||
let path = board_path(&app)?;
|
||||
pub fn boards_load_index(app: AppHandle) -> Result<BoardIndex, String> {
|
||||
read_index(&app)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn board_save(app: AppHandle, id: String, state: Value) -> Result<(), String> {
|
||||
let path = board_file(&app, &id)?;
|
||||
let body = serde_json::to_string_pretty(&state).map_err(|e| format!("serialize: {e}"))?;
|
||||
atomic_write(&path, &body)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn board_load(app: AppHandle, id: String) -> Result<Option<Value>, String> {
|
||||
let path = board_file(&app, &id)?;
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
@@ -34,3 +121,58 @@ pub fn board_load(app: AppHandle) -> Result<Option<Value>, String> {
|
||||
let v = serde_json::from_str(&body).map_err(|e| format!("parse: {e}"))?;
|
||||
Ok(Some(v))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn board_create(app: AppHandle, name: String) -> Result<BoardMeta, String> {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let mut idx = read_index(&app)?;
|
||||
let meta = BoardMeta {
|
||||
id: id.clone(),
|
||||
name,
|
||||
};
|
||||
idx.boards.push(meta.clone());
|
||||
write_index(&app, &idx)?;
|
||||
atomic_write(&board_file(&app, &id)?, &empty_board_json())?;
|
||||
Ok(meta)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn board_rename(app: AppHandle, id: String, name: String) -> Result<(), String> {
|
||||
let mut idx = read_index(&app)?;
|
||||
let b = idx
|
||||
.boards
|
||||
.iter_mut()
|
||||
.find(|b| b.id == id)
|
||||
.ok_or_else(|| format!("board {id} not found"))?;
|
||||
b.name = name;
|
||||
write_index(&app, &idx)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn board_delete(app: AppHandle, id: String) -> Result<BoardIndex, String> {
|
||||
let mut idx = read_index(&app)?;
|
||||
if idx.boards.len() <= 1 {
|
||||
return Err("can't delete the last board".to_string());
|
||||
}
|
||||
let before = idx.boards.len();
|
||||
idx.boards.retain(|b| b.id != id);
|
||||
if idx.boards.len() == before {
|
||||
return Err(format!("board {id} not found"));
|
||||
}
|
||||
if idx.current_board_id == id {
|
||||
idx.current_board_id = idx.boards[0].id.clone();
|
||||
}
|
||||
write_index(&app, &idx)?;
|
||||
let _ = fs::remove_file(board_file(&app, &id)?);
|
||||
Ok(idx)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn boards_set_current(app: AppHandle, id: String) -> Result<(), String> {
|
||||
let mut idx = read_index(&app)?;
|
||||
if !idx.boards.iter().any(|b| b.id == id) {
|
||||
return Err(format!("board {id} not found"));
|
||||
}
|
||||
idx.current_board_id = id;
|
||||
write_index(&app, &idx)
|
||||
}
|
||||
|
||||
178
src/App.tsx
178
src/App.tsx
@@ -1,9 +1,19 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Canvas } from "./canvas/Canvas";
|
||||
import type { BoardState, Card } from "./canvas/types";
|
||||
import { BoardSwitcher } from "./canvas/BoardSwitcher";
|
||||
import type { BoardIndex, BoardState, Card } from "./canvas/types";
|
||||
import { board } from "./ipc";
|
||||
|
||||
const DEFAULT_BOARD: BoardState = {
|
||||
const SAVE_DEBOUNCE_MS = 400;
|
||||
|
||||
const DEFAULT_STATE: BoardState = {
|
||||
version: 1,
|
||||
cards: [],
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
maxZ: 0,
|
||||
};
|
||||
|
||||
const WELCOME_NOTE = (): BoardState => ({
|
||||
version: 1,
|
||||
cards: [
|
||||
{
|
||||
@@ -23,9 +33,9 @@ const DEFAULT_BOARD: BoardState = {
|
||||
],
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
maxZ: 1,
|
||||
};
|
||||
});
|
||||
|
||||
// Live PTY processes die with the previous app session — wipe ptyIds so each
|
||||
// Live PTY processes die with the previous session — wipe ptyIds so each
|
||||
// terminal card respawns a fresh shell on mount.
|
||||
function sanitize(state: BoardState): BoardState {
|
||||
return {
|
||||
@@ -37,25 +47,149 @@ function sanitize(state: BoardState): BoardState {
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const [initial, setInitial] = useState<BoardState | null>(null);
|
||||
const [index, setIndex] = useState<BoardIndex | null>(null);
|
||||
const [initialState, setInitialState] = useState<BoardState | null>(null);
|
||||
const latestStateRef = useRef<BoardState | null>(null);
|
||||
const saveTimerRef = useRef<number | null>(null);
|
||||
const currentIdRef = useRef<string>("");
|
||||
|
||||
// Initial load.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
board
|
||||
.load()
|
||||
.then((loaded) => {
|
||||
if (cancelled) return;
|
||||
setInitial(loaded ? sanitize(loaded) : DEFAULT_BOARD);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("board_load failed", err);
|
||||
if (!cancelled) setInitial(DEFAULT_BOARD);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
(async () => {
|
||||
try {
|
||||
const idx = await board.loadIndex();
|
||||
const loaded = await board.load(idx.currentBoardId);
|
||||
currentIdRef.current = idx.currentBoardId;
|
||||
setIndex(idx);
|
||||
setInitialState(loaded ? sanitize(loaded) : WELCOME_NOTE());
|
||||
} catch (err) {
|
||||
console.error("boards init failed", err);
|
||||
currentIdRef.current = "default";
|
||||
setIndex({
|
||||
version: 1,
|
||||
currentBoardId: "default",
|
||||
boards: [{ id: "default", name: "Default" }],
|
||||
});
|
||||
setInitialState(WELCOME_NOTE());
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
if (!initial) return null;
|
||||
return <Canvas initial={initial} />;
|
||||
const flush = useCallback(async () => {
|
||||
if (saveTimerRef.current !== null) {
|
||||
window.clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = null;
|
||||
}
|
||||
const id = currentIdRef.current;
|
||||
const state = latestStateRef.current;
|
||||
if (id && state) {
|
||||
try {
|
||||
await board.save(id, state);
|
||||
} catch (err) {
|
||||
console.error("board_save flush failed", err);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onCanvasChange = useCallback((state: BoardState) => {
|
||||
latestStateRef.current = state;
|
||||
if (saveTimerRef.current !== null) window.clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = window.setTimeout(() => {
|
||||
saveTimerRef.current = null;
|
||||
const id = currentIdRef.current;
|
||||
const s = latestStateRef.current;
|
||||
if (id && s) {
|
||||
board.save(id, s).catch((err) => console.error("board_save failed", err));
|
||||
}
|
||||
}, SAVE_DEBOUNCE_MS);
|
||||
}, []);
|
||||
|
||||
const switchTo = useCallback(
|
||||
async (id: string) => {
|
||||
if (!index || id === index.currentBoardId) return;
|
||||
await flush();
|
||||
await board.setCurrent(id);
|
||||
const loaded = await board.load(id);
|
||||
latestStateRef.current = null;
|
||||
currentIdRef.current = id;
|
||||
setIndex({ ...index, currentBoardId: id });
|
||||
setInitialState(loaded ? sanitize(loaded) : DEFAULT_STATE);
|
||||
},
|
||||
[index, flush],
|
||||
);
|
||||
|
||||
const createBoard = useCallback(
|
||||
async (name: string) => {
|
||||
if (!index) return;
|
||||
await flush();
|
||||
const meta = await board.create(name);
|
||||
await board.setCurrent(meta.id);
|
||||
latestStateRef.current = null;
|
||||
currentIdRef.current = meta.id;
|
||||
setIndex({
|
||||
...index,
|
||||
currentBoardId: meta.id,
|
||||
boards: [...index.boards, meta],
|
||||
});
|
||||
setInitialState(DEFAULT_STATE);
|
||||
},
|
||||
[index, flush],
|
||||
);
|
||||
|
||||
const renameBoard = useCallback(async (id: string, name: string) => {
|
||||
await board.rename(id, name);
|
||||
setIndex((idx) =>
|
||||
idx
|
||||
? { ...idx, boards: idx.boards.map((b) => (b.id === id ? { ...b, name } : b)) }
|
||||
: idx,
|
||||
);
|
||||
}, []);
|
||||
|
||||
const deleteBoard = useCallback(
|
||||
async (id: string) => {
|
||||
if (!index) return;
|
||||
const wasCurrent = id === index.currentBoardId;
|
||||
const newIndex = await board.delete(id);
|
||||
if (wasCurrent) {
|
||||
const loaded = await board.load(newIndex.currentBoardId);
|
||||
latestStateRef.current = null;
|
||||
currentIdRef.current = newIndex.currentBoardId;
|
||||
setIndex(newIndex);
|
||||
setInitialState(loaded ? sanitize(loaded) : DEFAULT_STATE);
|
||||
} else {
|
||||
setIndex(newIndex);
|
||||
}
|
||||
},
|
||||
[index],
|
||||
);
|
||||
|
||||
// Best-effort flush on tab/window close.
|
||||
useEffect(() => {
|
||||
const onBeforeUnload = () => {
|
||||
if (saveTimerRef.current !== null) {
|
||||
window.clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = null;
|
||||
}
|
||||
const id = currentIdRef.current;
|
||||
const s = latestStateRef.current;
|
||||
if (id && s) board.save(id, s).catch(() => {});
|
||||
};
|
||||
window.addEventListener("beforeunload", onBeforeUnload);
|
||||
return () => window.removeEventListener("beforeunload", onBeforeUnload);
|
||||
}, []);
|
||||
|
||||
if (!index || !initialState) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<BoardSwitcher
|
||||
index={index}
|
||||
onSwitch={switchTo}
|
||||
onCreate={createBoard}
|
||||
onRename={renameBoard}
|
||||
onDelete={deleteBoard}
|
||||
/>
|
||||
<Canvas key={index.currentBoardId} initial={initialState} onChange={onCanvasChange} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
199
src/canvas/BoardSwitcher.tsx
Normal file
199
src/canvas/BoardSwitcher.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { BoardIndex, BoardMeta } from "./types";
|
||||
|
||||
interface Props {
|
||||
index: BoardIndex;
|
||||
onSwitch: (id: string) => void;
|
||||
onCreate: (name: string) => void;
|
||||
onRename: (id: string, name: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
export function BoardSwitcher({ index, onSwitch, onCreate, onRename, onDelete }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDocDown = (e: MouseEvent) => {
|
||||
if (!containerRef.current?.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
setRenamingId(null);
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", onDocDown);
|
||||
return () => document.removeEventListener("mousedown", onDocDown);
|
||||
}, [open]);
|
||||
|
||||
const current = index.boards.find((b) => b.id === index.currentBoardId);
|
||||
|
||||
const handleDelete = (b: BoardMeta) => {
|
||||
if (index.boards.length <= 1) return;
|
||||
if (!confirm(`Delete board "${b.name}"? This can't be undone.`)) return;
|
||||
onDelete(b.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="board-switcher" ref={containerRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="board-switcher-trigger"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<span className="board-switcher-name">{current?.name ?? "—"}</span>
|
||||
<span className="board-switcher-caret">▾</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="board-switcher-menu">
|
||||
{index.boards.map((b) => (
|
||||
<BoardRow
|
||||
key={b.id}
|
||||
board={b}
|
||||
current={b.id === index.currentBoardId}
|
||||
renaming={renamingId === b.id}
|
||||
canDelete={index.boards.length > 1}
|
||||
onSwitch={() => {
|
||||
setOpen(false);
|
||||
onSwitch(b.id);
|
||||
}}
|
||||
onStartRename={() => setRenamingId(b.id)}
|
||||
onCommitRename={(name) => {
|
||||
setRenamingId(null);
|
||||
if (name.trim() && name !== b.name) onRename(b.id, name.trim());
|
||||
}}
|
||||
onCancelRename={() => setRenamingId(null)}
|
||||
onDelete={() => handleDelete(b)}
|
||||
/>
|
||||
))}
|
||||
<div className="board-switcher-divider" />
|
||||
{creating ? (
|
||||
<NewBoardRow
|
||||
onCommit={(name) => {
|
||||
setCreating(false);
|
||||
setOpen(false);
|
||||
if (name.trim()) onCreate(name.trim());
|
||||
}}
|
||||
onCancel={() => setCreating(false)}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="board-switcher-new"
|
||||
onClick={() => setCreating(true)}
|
||||
>
|
||||
+ New board
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BoardRow(props: {
|
||||
board: BoardMeta;
|
||||
current: boolean;
|
||||
renaming: boolean;
|
||||
canDelete: boolean;
|
||||
onSwitch: () => void;
|
||||
onStartRename: () => void;
|
||||
onCommitRename: (name: string) => void;
|
||||
onCancelRename: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const { board, current, renaming, canDelete } = props;
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [draft, setDraft] = useState(board.name);
|
||||
|
||||
useEffect(() => {
|
||||
if (renaming) {
|
||||
setDraft(board.name);
|
||||
requestAnimationFrame(() => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
});
|
||||
}
|
||||
}, [renaming, board.name]);
|
||||
|
||||
if (renaming) {
|
||||
return (
|
||||
<div className="board-switcher-row">
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="board-switcher-input"
|
||||
value={draft}
|
||||
spellCheck={false}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") props.onCommitRename(draft);
|
||||
else if (e.key === "Escape") props.onCancelRename();
|
||||
}}
|
||||
onBlur={() => props.onCommitRename(draft)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`board-switcher-row ${current ? "is-current" : ""}`}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
props.onStartRename();
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="board-switcher-name-btn"
|
||||
onClick={props.onSwitch}
|
||||
>
|
||||
{board.name}
|
||||
{current && <span className="board-switcher-dot">●</span>}
|
||||
</button>
|
||||
{canDelete && (
|
||||
<button
|
||||
type="button"
|
||||
className="board-switcher-delete"
|
||||
aria-label="Delete board"
|
||||
onClick={props.onDelete}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NewBoardRow({
|
||||
onCommit,
|
||||
onCancel,
|
||||
}: {
|
||||
onCommit: (name: string) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState("");
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
useEffect(() => {
|
||||
ref.current?.focus();
|
||||
}, []);
|
||||
return (
|
||||
<div className="board-switcher-row">
|
||||
<input
|
||||
ref={ref}
|
||||
className="board-switcher-input"
|
||||
value={draft}
|
||||
placeholder="Board name"
|
||||
spellCheck={false}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onCommit(draft);
|
||||
else if (e.key === "Escape") onCancel();
|
||||
}}
|
||||
onBlur={() => onCommit(draft)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,19 +3,19 @@ import type { BoardState, Card, Viewport } from "./types";
|
||||
import { ViewportContext } from "./viewport";
|
||||
import { NoteCardView } from "./cards/NoteCardView";
|
||||
import { TerminalCardView } from "./cards/TerminalCardView";
|
||||
import { board, pty } from "../ipc";
|
||||
import { pty } from "../ipc";
|
||||
import "./canvas.css";
|
||||
|
||||
const MIN_SCALE = 0.1;
|
||||
const MAX_SCALE = 4;
|
||||
const ZOOM_SENSITIVITY = 0.0015;
|
||||
const SAVE_DEBOUNCE_MS = 400;
|
||||
|
||||
interface CanvasProps {
|
||||
initial: BoardState;
|
||||
onChange: (state: BoardState) => void;
|
||||
}
|
||||
|
||||
export function Canvas({ initial }: CanvasProps) {
|
||||
export function Canvas({ initial, onChange }: CanvasProps) {
|
||||
const [cards, setCards] = useState<Card[]>(initial.cards);
|
||||
const [vp, setVp] = useState<Viewport>(initial.viewport);
|
||||
const [spaceHeld, setSpaceHeld] = useState(false);
|
||||
@@ -27,19 +27,16 @@ export function Canvas({ initial }: CanvasProps) {
|
||||
Math.max(initial.maxZ, initial.cards.reduce((m, c) => Math.max(m, c.z), 0)),
|
||||
);
|
||||
|
||||
// Auto-save the board whenever cards or viewport change. Debounced so a
|
||||
// pan/drag/typing burst writes once after motion stops.
|
||||
// Emit board state changes upward; App owns the debounced save.
|
||||
const onChangeRef = useRef(onChange);
|
||||
onChangeRef.current = onChange;
|
||||
useEffect(() => {
|
||||
const handle = window.setTimeout(() => {
|
||||
const state: BoardState = {
|
||||
version: 1,
|
||||
cards,
|
||||
viewport: vp,
|
||||
maxZ: maxZRef.current,
|
||||
};
|
||||
board.save(state).catch((err) => console.error("board_save failed", err));
|
||||
}, SAVE_DEBOUNCE_MS);
|
||||
return () => window.clearTimeout(handle);
|
||||
onChangeRef.current({
|
||||
version: 1,
|
||||
cards,
|
||||
viewport: vp,
|
||||
maxZ: maxZRef.current,
|
||||
});
|
||||
}, [cards, vp]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -211,7 +211,7 @@
|
||||
.toolbar {
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
left: 220px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 6px;
|
||||
@@ -220,6 +220,160 @@
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.board-switcher {
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
z-index: 1001;
|
||||
}
|
||||
|
||||
.board-switcher-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 8px;
|
||||
padding: 7px 12px;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
min-width: 180px;
|
||||
max-width: 260px;
|
||||
}
|
||||
|
||||
.board-switcher-trigger:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.board-switcher-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.board-switcher-caret {
|
||||
opacity: 0.6;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.board-switcher-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
min-width: 220px;
|
||||
background: var(--card-bg, #2a2a32);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.board-switcher-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 5px;
|
||||
padding: 0 2px 0 0;
|
||||
}
|
||||
|
||||
.board-switcher-row:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.board-switcher-row.is-current {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.board-switcher-name-btn {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
padding: 7px 10px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.board-switcher-dot {
|
||||
font-size: 8px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.board-switcher-delete {
|
||||
flex-shrink: 0;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: var(--text);
|
||||
opacity: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.board-switcher-row:hover .board-switcher-delete {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.board-switcher-delete:hover {
|
||||
background: rgba(255, 80, 80, 0.25);
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.board-switcher-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: 1px solid var(--accent, #4f87ff);
|
||||
border-radius: 5px;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
padding: 6px 10px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.board-switcher-divider {
|
||||
height: 1px;
|
||||
background: var(--card-border);
|
||||
margin: 4px 2px;
|
||||
}
|
||||
|
||||
.board-switcher-new {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
padding: 7px 10px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.board-switcher-new:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.toolbar button {
|
||||
background: var(--card-bg);
|
||||
color: var(--text);
|
||||
|
||||
@@ -44,3 +44,14 @@ export interface BoardState {
|
||||
viewport: Viewport;
|
||||
maxZ: number;
|
||||
}
|
||||
|
||||
export interface BoardMeta {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface BoardIndex {
|
||||
version: number;
|
||||
currentBoardId: string;
|
||||
boards: BoardMeta[];
|
||||
}
|
||||
|
||||
13
src/ipc.ts
13
src/ipc.ts
@@ -1,6 +1,6 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||
import type { BoardState } from "./canvas/types";
|
||||
import type { BoardIndex, BoardMeta, BoardState } from "./canvas/types";
|
||||
|
||||
export interface PtySpawnOpts {
|
||||
cols: number;
|
||||
@@ -51,6 +51,13 @@ export function stringToBase64(s: string): string {
|
||||
}
|
||||
|
||||
export const board = {
|
||||
save: (state: BoardState) => invoke<void>("board_save", { state }),
|
||||
load: () => invoke<BoardState | null>("board_load"),
|
||||
loadIndex: () => invoke<BoardIndex>("boards_load_index"),
|
||||
setCurrent: (id: string) => invoke<void>("boards_set_current", { id }),
|
||||
load: (id: string) => invoke<BoardState | null>("board_load", { id }),
|
||||
save: (id: string, state: BoardState) =>
|
||||
invoke<void>("board_save", { id, state }),
|
||||
create: (name: string) => invoke<BoardMeta>("board_create", { name }),
|
||||
rename: (id: string, name: string) =>
|
||||
invoke<void>("board_rename", { id, name }),
|
||||
delete: (id: string) => invoke<BoardIndex>("board_delete", { id }),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user