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:
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-D6iqmLrm.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BRF-Y_tu.css">
|
||||
<script type="module" crossorigin src="/assets/index-DxdHmf_Q.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BZPHLDSC.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
mod pty;
|
||||
mod storage;
|
||||
|
||||
use pty::{pty_kill, pty_resize, pty_spawn, pty_write, PtyState};
|
||||
use storage::{board_load, board_save};
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
@@ -33,6 +35,8 @@ pub fn run() {
|
||||
pty_write,
|
||||
pty_resize,
|
||||
pty_kill,
|
||||
board_save,
|
||||
board_load,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
36
src-tauri/src/storage.rs
Normal file
36
src-tauri/src/storage.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde_json::Value;
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
fn board_path(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
let dir = 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"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn board_save(app: AppHandle, state: Value) -> Result<(), String> {
|
||||
let path = board_path(&app)?;
|
||||
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}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn board_load(app: AppHandle) -> Result<Option<Value>, String> {
|
||||
let path = board_path(&app)?;
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let body = fs::read_to_string(&path).map_err(|e| format!("read: {e}"))?;
|
||||
let v = serde_json::from_str(&body).map_err(|e| format!("parse: {e}"))?;
|
||||
Ok(Some(v))
|
||||
}
|
||||
74
src/App.tsx
74
src/App.tsx
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
})}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
33
src/canvas/cards/CardHeader.tsx
Normal file
33
src/canvas/cards/CardHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user