What persists: every card (kind, position, size, title, note text, ptyId, z-order) + viewport pan/zoom + maxZ counter,

in a single board.json under app_data_dir() (~/.local/share/{appName}/board.json on Linux).

  Write pattern: tmp file + atomic rename, debounced 400ms. A crash mid-write can't corrupt the existing file; worst
  case you lose ~400ms of recent edits.

  Read pattern: load on app start, gate the Canvas render on the result. Missing file → default welcome board. Parse
  error → log and fall back to default (won't overwrite the bad file until the user makes changes; actually it will save
   over it after the next change — if you want to preserve a corrupt file for recovery that's a one-line tweak).

  Terminal caveat: live PTY processes die with the app session, so sanitize() in App.tsx clears every terminal card's
  ptyId on load. Each terminal card respawns a fresh shell on mount via the existing pty.spawn path. The card's
  position, size, and title are preserved.
This commit is contained in:
Haapy
2026-05-15 11:40:36 +00:00
parent 595666e94b
commit 092d27beab
13 changed files with 311 additions and 71 deletions

View File

@@ -1,23 +1,61 @@
import { useEffect, useState } from "react";
import { Canvas } from "./canvas/Canvas";
import type { Card } from "./canvas/types";
import type { BoardState, Card } from "./canvas/types";
import { board } from "./ipc";
const initialCards: Card[] = [
{
id: "welcome",
kind: "note",
x: 200,
y: 200,
width: 340,
height: 200,
z: 1,
text:
"Welcome to Infinite.\n\n" +
"Pan: middle-drag or Space + drag\n" +
"Zoom: Ctrl + scroll\n\n" +
"Use the toolbar to add notes and terminals.",
},
];
const DEFAULT_BOARD: BoardState = {
version: 1,
cards: [
{
id: "welcome",
kind: "note",
x: 200,
y: 200,
width: 340,
height: 200,
z: 1,
text:
"Welcome to Infinite.\n\n" +
"Pan: middle-drag or Space + drag\n" +
"Zoom: Ctrl + scroll\n\n" +
"Use the toolbar to add notes and terminals.",
},
],
viewport: { x: 0, y: 0, scale: 1 },
maxZ: 1,
};
// Live PTY processes die with the previous app session — wipe ptyIds so each
// terminal card respawns a fresh shell on mount.
function sanitize(state: BoardState): BoardState {
return {
...state,
cards: state.cards.map((c): Card =>
c.kind === "terminal" ? { ...c, ptyId: "" } : c,
),
};
}
export function App() {
return <Canvas initialCards={initialCards} />;
const [initial, setInitial] = useState<BoardState | null>(null);
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;
};
}, []);
if (!initial) return null;
return <Canvas initial={initial} />;
}

View File

@@ -1,30 +1,47 @@
import { useEffect, useRef, useState, useCallback } from "react";
import type { Card, Viewport } from "./types";
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 "./canvas.css";
const MIN_SCALE = 0.1;
const MAX_SCALE = 4;
const ZOOM_SENSITIVITY = 0.0015;
const SAVE_DEBOUNCE_MS = 400;
interface CanvasProps {
initialCards: Card[];
initial: BoardState;
}
export function Canvas({ initialCards }: CanvasProps) {
const [cards, setCards] = useState<Card[]>(initialCards);
const [vp, setVp] = useState<Viewport>({ x: 0, y: 0, scale: 1 });
export function Canvas({ initial }: CanvasProps) {
const [cards, setCards] = useState<Card[]>(initial.cards);
const [vp, setVp] = useState<Viewport>(initial.viewport);
const [spaceHeld, setSpaceHeld] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const panState = useRef<{ startX: number; startY: number; vpX: number; vpY: number } | null>(
null,
);
const maxZRef = useRef(
initialCards.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
// pan/drag/typing burst writes once after motion stops.
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);
}, [cards, vp]);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.code === "Space" && !e.repeat) setSpaceHeld(true);
@@ -99,6 +116,26 @@ export function Canvas({ initialCards }: CanvasProps) {
setCards((cs) => cs.map((c) => (c.id === id ? ({ ...c, ...patch } as Card) : c)));
};
const deleteCard = (id: string) => {
setCards((cs) => {
const target = cs.find((c) => c.id === id);
if (target?.kind === "terminal" && target.ptyId) {
pty.kill(target.ptyId).catch(() => {});
}
return cs.filter((c) => c.id !== id);
});
};
const bringToFront = (id: string) => {
setCards((cs) => {
const target = cs.find((c) => c.id === id);
if (!target || target.z === maxZRef.current) return cs;
maxZRef.current += 1;
const newZ = maxZRef.current;
return cs.map((c) => (c.id === id ? ({ ...c, z: newZ } as Card) : c));
});
};
const addCardAtCenter = (build: (id: string, x: number, y: number, z: number) => Card) => {
const w = window.innerWidth / 2;
const h = window.innerHeight / 2;
@@ -154,10 +191,26 @@ export function Canvas({ initialCards }: CanvasProps) {
<ViewportContext.Provider value={vp}>
{cards.map((c) => {
if (c.kind === "note") {
return <NoteCardView key={c.id} card={c} onUpdate={(p) => updateCard(c.id, p)} />;
return (
<NoteCardView
key={c.id}
card={c}
onUpdate={(p) => updateCard(c.id, p)}
onClose={() => deleteCard(c.id)}
onFocus={() => bringToFront(c.id)}
/>
);
}
if (c.kind === "terminal") {
return <TerminalCardView key={c.id} card={c} onUpdate={(p) => updateCard(c.id, p)} />;
return (
<TerminalCardView
key={c.id}
card={c}
onUpdate={(p) => updateCard(c.id, p)}
onClose={() => deleteCard(c.id)}
onFocus={() => bringToFront(c.id)}
/>
);
}
return null;
})}

View File

@@ -52,7 +52,7 @@
}
.card-header {
padding: 0.46em 0.77em;
padding: 0.31em 0.46em 0.31em 0.77em;
background: rgba(0, 0, 0, 0.2);
border-bottom: calc(1px * var(--scale, 1)) solid var(--card-border);
font-size: 0.85em;
@@ -61,6 +61,51 @@
overflow: hidden;
border-top-left-radius: inherit;
border-top-right-radius: inherit;
display: flex;
align-items: center;
gap: 0.46em;
}
.card-title {
flex: 1;
min-width: 0;
background: transparent;
border: none;
outline: none;
color: var(--text);
font-family: inherit;
font-size: inherit;
padding: 0.15em 0;
cursor: text;
}
.card-title::placeholder {
color: var(--text);
opacity: 0.35;
}
.card-close {
flex-shrink: 0;
width: 1.6em;
height: 1.6em;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
border-radius: 4px;
color: var(--text);
opacity: 0.5;
font-size: 1.2em;
line-height: 1;
cursor: pointer;
padding: 0;
font-family: inherit;
}
.card-close:hover {
background: rgba(255, 80, 80, 0.25);
opacity: 1;
}
.card-body {

View File

@@ -0,0 +1,33 @@
import React from "react";
interface Props {
title: string;
placeholder: string;
onTitleChange: (next: string) => void;
onClose: () => void;
dragProps: { onPointerDown: (e: React.PointerEvent) => void };
}
export function CardHeader({ title, placeholder, onTitleChange, onClose, dragProps }: Props) {
return (
<div className="card-header" {...dragProps}>
<input
className="card-title"
value={title}
placeholder={placeholder}
spellCheck={false}
onChange={(e) => onTitleChange(e.target.value)}
onPointerDown={(e) => e.stopPropagation()}
/>
<button
type="button"
className="card-close"
aria-label="Close"
onClick={onClose}
onPointerDown={(e) => e.stopPropagation()}
>
×
</button>
</div>
);
}

View File

@@ -3,16 +3,19 @@ import type { NoteCard } from "../types";
import { useViewport } from "../viewport";
import { useDragHandle } from "../useDragHandle";
import { ResizeHandles } from "./ResizeHandles";
import { CardHeader } from "./CardHeader";
import { useMarkdownEditor } from "./useMarkdownEditor";
interface Props {
card: NoteCard;
onUpdate: (patch: Partial<NoteCard>) => void;
onClose: () => void;
onFocus: () => void;
}
const BODY_FONT_BASE = 13;
export function NoteCardView({ card, onUpdate }: Props) {
export function NoteCardView({ card, onUpdate, onClose, onFocus }: Props) {
const vp = useViewport();
const drag = useDragHandle(card, (p) => onUpdate(p));
const editorRef = useRef<HTMLDivElement>(null);
@@ -21,6 +24,7 @@ export function NoteCardView({ card, onUpdate }: Props) {
return (
<div
className="card note-card"
onPointerDownCapture={onFocus}
style={
{
left: vp.x + card.x * vp.scale,
@@ -33,9 +37,13 @@ export function NoteCardView({ card, onUpdate }: Props) {
} as React.CSSProperties
}
>
<div className="card-header" {...drag}>
note
</div>
<CardHeader
title={card.title ?? ""}
placeholder="Note"
onTitleChange={(t) => onUpdate({ title: t })}
onClose={onClose}
dragProps={drag}
/>
<div
className="card-body note-body"
ref={editorRef}

View File

@@ -6,6 +6,7 @@ import type { TerminalCard } from "../types";
import { useViewport } from "../viewport";
import { useDragHandle } from "../useDragHandle";
import { ResizeHandles } from "./ResizeHandles";
import { CardHeader } from "./CardHeader";
import { pty, base64ToBytes, stringToBase64 } from "../../ipc";
const BASE_FONT_SIZE = 13;
@@ -13,9 +14,11 @@ const BASE_FONT_SIZE = 13;
interface Props {
card: TerminalCard;
onUpdate: (patch: Partial<TerminalCard>) => void;
onClose: () => void;
onFocus: () => void;
}
export function TerminalCardView({ card, onUpdate }: Props) {
export function TerminalCardView({ card, onUpdate, onClose, onFocus }: Props) {
const vp = useViewport();
const drag = useDragHandle(card, (p) => onUpdate(p));
const containerRef = useRef<HTMLDivElement>(null);
@@ -106,6 +109,7 @@ export function TerminalCardView({ card, onUpdate }: Props) {
return (
<div
className="card terminal-card"
onPointerDownCapture={onFocus}
style={
{
left: vp.x + card.x * vp.scale,
@@ -117,9 +121,13 @@ export function TerminalCardView({ card, onUpdate }: Props) {
} as React.CSSProperties
}
>
<div className="card-header" {...drag}>
terminal
</div>
<CardHeader
title={card.title ?? ""}
placeholder="Terminal"
onTitleChange={(t) => onUpdate({ title: t })}
onClose={onClose}
dragProps={drag}
/>
<div
className="card-body terminal-body"
ref={containerRef}

View File

@@ -15,11 +15,13 @@ export interface BaseCard {
export interface NoteCard extends BaseCard {
kind: "note";
text: string;
title?: string;
}
export interface TerminalCard extends BaseCard {
kind: "terminal";
ptyId: string;
title?: string;
}
export interface ThumbnailCard extends BaseCard {
@@ -35,3 +37,10 @@ export interface Viewport {
y: number;
scale: number;
}
export interface BoardState {
version: number;
cards: Card[];
viewport: Viewport;
maxZ: number;
}

View File

@@ -1,5 +1,6 @@
import { invoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import type { BoardState } from "./canvas/types";
export interface PtySpawnOpts {
cols: number;
@@ -48,3 +49,8 @@ export function base64ToBytes(b64: string): Uint8Array {
export function stringToBase64(s: string): string {
return bytesToBase64(new TextEncoder().encode(s));
}
export const board = {
save: (state: BoardState) => invoke<void>("board_save", { state }),
load: () => invoke<BoardState | null>("board_load"),
};