web based infinite canvas
at main 410 lines 12 kB view raw
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}