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 charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Infinite</title>
|
<title>Infinite</title>
|
||||||
<script type="module" crossorigin src="/assets/index-DxdHmf_Q.js"></script>
|
<script type="module" crossorigin src="/assets/index-DaPgpxZP.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-BZPHLDSC.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-DJpJgVAz.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ mod pty;
|
|||||||
mod storage;
|
mod storage;
|
||||||
|
|
||||||
use pty::{pty_kill, pty_resize, pty_spawn, pty_write, PtyState};
|
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)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
@@ -35,8 +38,13 @@ pub fn run() {
|
|||||||
pty_write,
|
pty_write,
|
||||||
pty_resize,
|
pty_resize,
|
||||||
pty_kill,
|
pty_kill,
|
||||||
|
boards_load_index,
|
||||||
board_save,
|
board_save,
|
||||||
board_load,
|
board_load,
|
||||||
|
board_create,
|
||||||
|
board_rename,
|
||||||
|
board_delete,
|
||||||
|
boards_set_current,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@@ -1,32 +1,119 @@
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use tauri::{AppHandle, Manager};
|
use tauri::{AppHandle, Manager};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
fn board_path(app: &AppHandle) -> Result<PathBuf, String> {
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
let dir = app
|
#[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()
|
.path()
|
||||||
.app_data_dir()
|
.app_data_dir()
|
||||||
.map_err(|e| format!("app_data_dir: {e}"))?;
|
.map_err(|e| format!("app_data_dir: {e}"))?;
|
||||||
fs::create_dir_all(&dir).map_err(|e| format!("create_dir_all: {e}"))?;
|
fs::create_dir_all(&d).map_err(|e| format!("create_dir_all: {e}"))?;
|
||||||
Ok(dir.join("board.json"))
|
Ok(d)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
fn boards_dir(app: &AppHandle) -> Result<PathBuf, String> {
|
||||||
pub fn board_save(app: AppHandle, state: Value) -> Result<(), String> {
|
let d = data_dir(app)?.join("boards");
|
||||||
let path = board_path(&app)?;
|
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 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}"))?;
|
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(())
|
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]
|
#[tauri::command]
|
||||||
pub fn board_load(app: AppHandle) -> Result<Option<Value>, String> {
|
pub fn boards_load_index(app: AppHandle) -> Result<BoardIndex, String> {
|
||||||
let path = board_path(&app)?;
|
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() {
|
if !path.exists() {
|
||||||
return Ok(None);
|
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}"))?;
|
let v = serde_json::from_str(&body).map_err(|e| format!("parse: {e}"))?;
|
||||||
Ok(Some(v))
|
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)
|
||||||
|
}
|
||||||
|
|||||||
176
src/App.tsx
176
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 { 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";
|
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,
|
version: 1,
|
||||||
cards: [
|
cards: [
|
||||||
{
|
{
|
||||||
@@ -23,9 +33,9 @@ const DEFAULT_BOARD: BoardState = {
|
|||||||
],
|
],
|
||||||
viewport: { x: 0, y: 0, scale: 1 },
|
viewport: { x: 0, y: 0, scale: 1 },
|
||||||
maxZ: 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.
|
// terminal card respawns a fresh shell on mount.
|
||||||
function sanitize(state: BoardState): BoardState {
|
function sanitize(state: BoardState): BoardState {
|
||||||
return {
|
return {
|
||||||
@@ -37,25 +47,149 @@ function sanitize(state: BoardState): BoardState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function App() {
|
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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
(async () => {
|
||||||
board
|
try {
|
||||||
.load()
|
const idx = await board.loadIndex();
|
||||||
.then((loaded) => {
|
const loaded = await board.load(idx.currentBoardId);
|
||||||
if (cancelled) return;
|
currentIdRef.current = idx.currentBoardId;
|
||||||
setInitial(loaded ? sanitize(loaded) : DEFAULT_BOARD);
|
setIndex(idx);
|
||||||
})
|
setInitialState(loaded ? sanitize(loaded) : WELCOME_NOTE());
|
||||||
.catch((err) => {
|
} catch (err) {
|
||||||
console.error("board_load failed", err);
|
console.error("boards init failed", err);
|
||||||
if (!cancelled) setInitial(DEFAULT_BOARD);
|
currentIdRef.current = "default";
|
||||||
|
setIndex({
|
||||||
|
version: 1,
|
||||||
|
currentBoardId: "default",
|
||||||
|
boards: [{ id: "default", name: "Default" }],
|
||||||
});
|
});
|
||||||
return () => {
|
setInitialState(WELCOME_NOTE());
|
||||||
cancelled = true;
|
}
|
||||||
};
|
})();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!initial) return null;
|
const flush = useCallback(async () => {
|
||||||
return <Canvas initial={initial} />;
|
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 { ViewportContext } from "./viewport";
|
||||||
import { NoteCardView } from "./cards/NoteCardView";
|
import { NoteCardView } from "./cards/NoteCardView";
|
||||||
import { TerminalCardView } from "./cards/TerminalCardView";
|
import { TerminalCardView } from "./cards/TerminalCardView";
|
||||||
import { board, pty } from "../ipc";
|
import { pty } from "../ipc";
|
||||||
import "./canvas.css";
|
import "./canvas.css";
|
||||||
|
|
||||||
const MIN_SCALE = 0.1;
|
const MIN_SCALE = 0.1;
|
||||||
const MAX_SCALE = 4;
|
const MAX_SCALE = 4;
|
||||||
const ZOOM_SENSITIVITY = 0.0015;
|
const ZOOM_SENSITIVITY = 0.0015;
|
||||||
const SAVE_DEBOUNCE_MS = 400;
|
|
||||||
|
|
||||||
interface CanvasProps {
|
interface CanvasProps {
|
||||||
initial: BoardState;
|
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 [cards, setCards] = useState<Card[]>(initial.cards);
|
||||||
const [vp, setVp] = useState<Viewport>(initial.viewport);
|
const [vp, setVp] = useState<Viewport>(initial.viewport);
|
||||||
const [spaceHeld, setSpaceHeld] = useState(false);
|
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)),
|
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
|
// Emit board state changes upward; App owns the debounced save.
|
||||||
// pan/drag/typing burst writes once after motion stops.
|
const onChangeRef = useRef(onChange);
|
||||||
|
onChangeRef.current = onChange;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handle = window.setTimeout(() => {
|
onChangeRef.current({
|
||||||
const state: BoardState = {
|
|
||||||
version: 1,
|
version: 1,
|
||||||
cards,
|
cards,
|
||||||
viewport: vp,
|
viewport: vp,
|
||||||
maxZ: maxZRef.current,
|
maxZ: maxZRef.current,
|
||||||
};
|
});
|
||||||
board.save(state).catch((err) => console.error("board_save failed", err));
|
|
||||||
}, SAVE_DEBOUNCE_MS);
|
|
||||||
return () => window.clearTimeout(handle);
|
|
||||||
}, [cards, vp]);
|
}, [cards, vp]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -211,7 +211,7 @@
|
|||||||
.toolbar {
|
.toolbar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 12px;
|
top: 12px;
|
||||||
left: 12px;
|
left: 220px;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
@@ -220,6 +220,160 @@
|
|||||||
z-index: 1000;
|
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 {
|
.toolbar button {
|
||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
|
|||||||
@@ -44,3 +44,14 @@ export interface BoardState {
|
|||||||
viewport: Viewport;
|
viewport: Viewport;
|
||||||
maxZ: number;
|
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 { invoke } from "@tauri-apps/api/core";
|
||||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
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 {
|
export interface PtySpawnOpts {
|
||||||
cols: number;
|
cols: number;
|
||||||
@@ -51,6 +51,13 @@ export function stringToBase64(s: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const board = {
|
export const board = {
|
||||||
save: (state: BoardState) => invoke<void>("board_save", { state }),
|
loadIndex: () => invoke<BoardIndex>("boards_load_index"),
|
||||||
load: () => invoke<BoardState | null>("board_load"),
|
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