web based infinite canvas
1/**
2 * Desktop (Tauri) file-based DocRepo implementation
3 * Used when the web app is running inside Tauri
4 */
5
6import type { BoardExport, BoardMeta, DocPatch, LoadedDoc, PageRecord, PersistentDocRepo } from "inkfinite-core";
7import {
8 createFileData,
9 createId,
10 type DesktopFileData,
11 type FileHandle,
12 loadedDocFromFileData,
13 parseDesktopFile,
14 serializeDesktopFile,
15} from "inkfinite-core";
16import type { DesktopFileOps } from "../fileops";
17
18export type DesktopDocRepo = PersistentDocRepo & {
19 kind: "desktop";
20 getCurrentFile(): FileHandle | null;
21 openFromDialog(): Promise<{ boardId: string; doc: LoadedDoc }>;
22 getWorkspaceDir(): Promise<string | null>;
23 setWorkspaceDir(path: string | null): Promise<void>;
24 pickWorkspaceDir(): Promise<string | null>;
25};
26
27export function isDesktopRepo(repo: PersistentDocRepo): repo is DesktopDocRepo {
28 return (repo as DesktopDocRepo).kind === "desktop";
29}
30
31/**
32 * Create a desktop file-based DocRepo
33 * This implementation manages a single document loaded from disk
34 */
35export function createDesktopDocRepo(fileOps: DesktopFileOps): DesktopDocRepo {
36 let currentFile: FileHandle | null = null;
37 let currentBoard: BoardMeta | null = null;
38 let currentDoc: LoadedDoc | null = null;
39 const boardFiles = new Map<string, FileHandle>();
40
41 type StoredHandle = { path: string; name?: string };
42
43 function setCurrentState(file: FileHandle, board: BoardMeta, doc: LoadedDoc) {
44 currentFile = file;
45 currentBoard = board;
46 currentDoc = doc;
47 boardFiles.set(board.id, file);
48 }
49
50 async function loadFromHandle(handle: StoredHandle): Promise<LoadedDoc> {
51 const content = await fileOps.readFile(handle.path);
52 const fileData = parseDesktopFile(content);
53 const doc = loadedDocFromFileData(fileData);
54 const normalizedHandle: FileHandle = {
55 path: handle.path,
56 name: handle.name ?? handle.path.split("/").pop() ?? "Untitled",
57 };
58 setCurrentState(normalizedHandle, fileData.board, doc);
59 await fileOps.addRecentFile(normalizedHandle);
60 return doc;
61 }
62
63 async function loadFromPath(path: string): Promise<LoadedDoc> {
64 const handle: FileHandle = { path, name: path.split("/").pop() || "Untitled" };
65 return loadFromHandle(handle);
66 }
67
68 async function listBoards(): Promise<BoardMeta[]> {
69 const workspaceDir = await fileOps.getWorkspaceDir();
70 const boards: BoardMeta[] = [];
71
72 if (workspaceDir) {
73 // Workspace mode: list files from workspace directory
74 try {
75 const entries = await fileOps.readDirectory(workspaceDir, "*.inkfinite.json");
76
77 for (const entry of entries) {
78 if (entry.isDir) continue;
79
80 try {
81 const content = await fileOps.readFile(entry.path);
82 const fileData = parseDesktopFile(content);
83 boards.push(fileData.board);
84 boardFiles.set(fileData.board.id, { path: entry.path, name: entry.name });
85 } catch (error) {
86 console.warn(`Failed to load board from ${entry.path}:`, error);
87 }
88 }
89 } catch (error) {
90 console.error("Failed to read workspace directory:", error);
91 }
92 } else {
93 // Recent files mode
94 const recent = await fileOps.getRecentFiles();
95
96 for (const handle of recent) {
97 try {
98 const content = await fileOps.readFile(handle.path);
99 const fileData = parseDesktopFile(content);
100 boards.push(fileData.board);
101 boardFiles.set(fileData.board.id, handle);
102 } catch {
103 await fileOps.removeRecentFile(handle.path);
104 }
105 }
106 }
107
108 return boards.sort((a, b) => b.updatedAt - a.updatedAt);
109 }
110
111 function createDefaultPage(name: string): PageRecord {
112 return { id: createId("page"), name, shapeIds: [] };
113 }
114
115 async function createBoard(name: string): Promise<string> {
116 const boardId = createId("board");
117 const timestamp = Date.now();
118 const page = createDefaultPage("Page 1");
119
120 const board: BoardMeta = {
121 id: boardId,
122 name: name || "Untitled Board",
123 createdAt: timestamp,
124 updatedAt: timestamp,
125 };
126
127 const fileData = createFileData(board, { [page.id]: page }, {}, {}, {
128 pageIds: [page.id],
129 shapeOrder: { [page.id]: [] },
130 });
131
132 const workspaceDir = await fileOps.getWorkspaceDir();
133 let path: string | null;
134
135 if (workspaceDir) {
136 // Workspace mode: save directly in workspace directory
137 const fileName = `${name || "Untitled"}.inkfinite.json`;
138 path = `${workspaceDir}/${fileName}`;
139 } else {
140 // Recent files mode: show save dialog
141 path = await fileOps.showSaveDialog(`${name || "Untitled"}.inkfinite.json`);
142 if (!path) {
143 throw new Error("Save cancelled");
144 }
145 }
146
147 await fileOps.writeFile(path, serializeDesktopFile(fileData));
148
149 const handle = { path, name: path.split("/").pop() || name };
150 setCurrentState(handle, board, loadedDocFromFileData(fileData));
151
152 if (!workspaceDir) {
153 await fileOps.addRecentFile(handle);
154 }
155
156 return boardId;
157 }
158
159 async function renameBoard(boardId: string, name: string): Promise<void> {
160 if (!currentBoard || currentBoard.id !== boardId) {
161 await loadDoc(boardId);
162 }
163 if (!currentBoard || !currentDoc || !currentFile) {
164 throw new Error("No board loaded");
165 }
166
167 const oldPath = currentFile.path;
168 const workspaceDir = await fileOps.getWorkspaceDir();
169
170 // If we're renaming the file itself (in workspace mode)
171 if (workspaceDir) {
172 const dir = oldPath.substring(0, oldPath.lastIndexOf("/"));
173 const newFileName = `${name}.inkfinite.json`;
174 const newPath = `${dir}/${newFileName}`;
175
176 // Update board metadata
177 currentBoard = { ...currentBoard, name, updatedAt: Date.now() };
178
179 const fileData = createFileData(
180 currentBoard,
181 currentDoc.pages,
182 currentDoc.shapes,
183 currentDoc.bindings,
184 currentDoc.order,
185 );
186
187 // Write to new path and delete old file (atomic rename not always possible cross-filesystem)
188 await fileOps.writeFile(newPath, serializeDesktopFile(fileData));
189
190 if (newPath !== oldPath) {
191 try {
192 await fileOps.deleteFile(oldPath);
193 } catch (error) {
194 console.warn("Failed to delete old file:", error);
195 }
196 }
197
198 // Update current file handle
199 const newHandle = { path: newPath, name: newFileName };
200 currentFile = newHandle;
201 boardFiles.set(currentBoard.id, newHandle);
202 } else {
203 // Recent files mode: just update the content
204 currentBoard = { ...currentBoard, name, updatedAt: Date.now() };
205
206 const fileData = createFileData(
207 currentBoard,
208 currentDoc.pages,
209 currentDoc.shapes,
210 currentDoc.bindings,
211 currentDoc.order,
212 );
213
214 await fileOps.writeFile(currentFile.path, serializeDesktopFile(fileData));
215 boardFiles.set(currentBoard.id, currentFile);
216 }
217 }
218
219 async function deleteBoard(boardId: string): Promise<void> {
220 const handle = boardFiles.get(boardId);
221 const workspaceDir = await fileOps.getWorkspaceDir();
222
223 if (handle) {
224 if (workspaceDir) {
225 // Workspace mode: actually delete the file
226 try {
227 await fileOps.deleteFile(handle.path);
228 } catch (error) {
229 console.error("Failed to delete file:", error);
230 throw error;
231 }
232 } else {
233 // Recent files mode: just remove from recent list
234 await fileOps.removeRecentFile(handle.path);
235 }
236 boardFiles.delete(boardId);
237 }
238
239 if (currentBoard?.id === boardId) {
240 currentFile = null;
241 currentBoard = null;
242 currentDoc = null;
243 }
244 }
245
246 async function loadDoc(boardId: string): Promise<LoadedDoc> {
247 if (currentDoc && currentBoard?.id === boardId) {
248 return currentDoc;
249 }
250 const handle = boardFiles.get(boardId);
251 if (!handle) {
252 throw new Error(`Unknown board: ${boardId}`);
253 }
254 try {
255 return await loadFromHandle(handle);
256 } catch (error) {
257 await fileOps.removeRecentFile(handle.path);
258 boardFiles.delete(boardId);
259 throw error;
260 }
261 }
262
263 async function openBoard(boardId: string): Promise<void> {
264 await loadDoc(boardId);
265 }
266
267 async function applyDocPatch(boardId: string, patch: DocPatch): Promise<void> {
268 if (!currentBoard || !currentDoc || !currentFile) {
269 throw new Error("No board loaded");
270 }
271
272 if (patch.deletes) {
273 if (patch.deletes.pageIds) {
274 for (const id of patch.deletes.pageIds) {
275 delete currentDoc.pages[id];
276 }
277 }
278 if (patch.deletes.shapeIds) {
279 for (const id of patch.deletes.shapeIds) {
280 delete currentDoc.shapes[id];
281 }
282 }
283 if (patch.deletes.bindingIds) {
284 for (const id of patch.deletes.bindingIds) {
285 delete currentDoc.bindings[id];
286 }
287 }
288 }
289
290 if (patch.upserts) {
291 if (patch.upserts.pages) {
292 for (const page of patch.upserts.pages) {
293 currentDoc.pages[page.id] = page;
294 }
295 }
296 if (patch.upserts.shapes) {
297 for (const shape of patch.upserts.shapes) {
298 currentDoc.shapes[shape.id] = shape;
299 }
300 }
301 if (patch.upserts.bindings) {
302 for (const binding of patch.upserts.bindings) {
303 currentDoc.bindings[binding.id] = binding;
304 }
305 }
306 }
307
308 if (patch.order) {
309 if (patch.order.pageIds) {
310 currentDoc.order.pageIds = patch.order.pageIds;
311 }
312 if (patch.order.shapeOrder) {
313 currentDoc.order.shapeOrder = patch.order.shapeOrder;
314 }
315 }
316
317 currentBoard = { ...currentBoard, updatedAt: Date.now() };
318
319 const fileData = createFileData(
320 currentBoard,
321 currentDoc.pages,
322 currentDoc.shapes,
323 currentDoc.bindings,
324 currentDoc.order,
325 );
326
327 await fileOps.writeFile(currentFile.path, serializeDesktopFile(fileData));
328 boardFiles.set(currentBoard.id, currentFile);
329 }
330
331 async function exportBoard(_boardId: string): Promise<BoardExport> {
332 if (!currentBoard || !currentDoc) {
333 throw new Error("No board loaded");
334 }
335
336 return {
337 board: currentBoard,
338 doc: { pages: currentDoc.pages, shapes: currentDoc.shapes, bindings: currentDoc.bindings },
339 order: currentDoc.order,
340 };
341 }
342
343 async function importBoard(snapshot: BoardExport): Promise<string> {
344 const boardId = snapshot.board.id ?? createId("board");
345 const timestamp = Date.now();
346
347 const board: BoardMeta = {
348 id: boardId,
349 name: snapshot.board.name || "Imported Board",
350 createdAt: snapshot.board.createdAt ?? timestamp,
351 updatedAt: timestamp,
352 };
353
354 const fileData: DesktopFileData = { board, doc: snapshot.doc, order: snapshot.order };
355
356 const path = await fileOps.showSaveDialog(`${board.name}.inkfinite.json`);
357 if (!path) {
358 throw new Error("Save cancelled");
359 }
360
361 await fileOps.writeFile(path, serializeDesktopFile(fileData));
362
363 const handle = { path, name: path.split("/").pop() || board.name };
364 setCurrentState(handle, board, loadedDocFromFileData(fileData));
365
366 await fileOps.addRecentFile(handle);
367
368 return boardId;
369 }
370
371 async function openFromDialog(): Promise<{ boardId: string; doc: LoadedDoc }> {
372 const path = await fileOps.showOpenDialog();
373 if (!path) {
374 throw new Error("Open cancelled");
375 }
376 const doc = await loadFromPath(path);
377 if (!currentBoard) {
378 throw new Error("Failed to open file");
379 }
380 return { boardId: currentBoard.id, doc };
381 }
382
383 return {
384 kind: "desktop",
385 listBoards,
386 createBoard,
387 openBoard,
388 renameBoard,
389 deleteBoard,
390 loadDoc,
391 applyDocPatch,
392 exportBoard,
393 importBoard,
394 getCurrentFile: () => currentFile,
395 openFromDialog,
396 getWorkspaceDir: () => fileOps.getWorkspaceDir(),
397 setWorkspaceDir: (path: string | null) => fileOps.setWorkspaceDir(path),
398 pickWorkspaceDir: () => fileOps.pickWorkspaceDir(),
399 };
400}
401
402/**
403 * Get current file handle (for showing in title bar, etc.)
404 */
405export function getCurrentFile(repo: PersistentDocRepo): FileHandle | null {
406 if (isDesktopRepo(repo)) {
407 return repo.getCurrentFile();
408 }
409 return null;
410}