web based infinite canvas
at main 254 lines 6.4 kB view raw
1import { liveQuery } from "dexie"; 2import type { BrushConfig, DocPatch, PersistenceSink, PersistenceSinkOptions, PersistentDocRepo } from "inkfinite-core"; 3import { createPersistenceSink } from "inkfinite-core"; 4import type { InkfiniteDB, PersistenceStatus } from "inkfinite-core"; 5 6type StatusListener = (status: PersistenceStatus) => void; 7 8export type StatusStore = { 9 get(): PersistenceStatus; 10 subscribe(listener: StatusListener): () => void; 11 update(updater: (status: PersistenceStatus) => PersistenceStatus): void; 12}; 13 14type LiveQueryFactory = typeof liveQuery; 15 16export type PersistenceManagerOptions = { sink?: PersistenceSinkOptions; liveQueryFn?: LiveQueryFactory }; 17 18export type SnapSettings = { snapEnabled: boolean; gridEnabled: boolean; gridSize: number }; 19 20export type SnapStore = { 21 get(): SnapSettings; 22 subscribe(listener: (snap: SnapSettings) => void): () => void; 23 update(updater: (snap: SnapSettings) => SnapSettings): void; 24 set(next: SnapSettings): void; 25}; 26 27export type BrushSettings = BrushConfig & { color: string }; 28 29export type BrushStore = { 30 get(): BrushSettings; 31 subscribe(listener: (brush: BrushSettings) => void): () => void; 32 update(updater: (brush: BrushSettings) => BrushSettings): void; 33 set(next: BrushSettings): void; 34}; 35 36export type PersistenceManager = { 37 sink: PersistenceSink; 38 status: StatusStore; 39 setActiveBoard(boardId: string | null): void; 40 dispose(): void; 41}; 42 43export function createPersistenceManager( 44 db: InkfiniteDB, 45 repo: PersistentDocRepo, 46 options?: PersistenceManagerOptions, 47): PersistenceManager { 48 const sink = createPersistenceSink(repo, options?.sink); 49 const status = createStatusStore({ backend: "indexeddb", state: "saved", pendingWrites: 0 }); 50 51 let activeBoardId: string | null = null; 52 let subscription: { unsubscribe(): void } | null = null; 53 const liveQueryFactory = options?.liveQueryFn ?? liveQuery; 54 55 function incrementPending() { 56 status.update((current) => ({ 57 ...current, 58 pendingWrites: (current.pendingWrites ?? 0) + 1, 59 state: "saving", 60 lastError: undefined, 61 })); 62 } 63 64 function markSaved(timestamp?: number) { 65 status.update((current) => ({ 66 ...current, 67 pendingWrites: 0, 68 state: "saved", 69 lastSavedAt: timestamp ?? current.lastSavedAt, 70 errorMsg: undefined, 71 })); 72 } 73 74 function markError(error: unknown) { 75 status.update((current) => ({ 76 ...current, 77 state: "error", 78 errorMsg: error instanceof Error ? error.message : String(error), 79 })); 80 } 81 82 function setActiveBoard(boardId: string | null) { 83 if (activeBoardId === boardId) { 84 return; 85 } 86 87 subscription?.unsubscribe(); 88 subscription = null; 89 activeBoardId = boardId; 90 91 if (!boardId) { 92 return; 93 } 94 95 const observable = liveQueryFactory(() => db.boards.get(boardId)); 96 subscription = observable.subscribe({ 97 next(board) { 98 if (board?.updatedAt !== undefined) { 99 markSaved(board.updatedAt); 100 } 101 }, 102 error(err) { 103 markError(err); 104 }, 105 }); 106 } 107 108 const trackedSink: PersistenceSink = { 109 enqueueDocPatch(boardId, patch) { 110 if (hasPatchChanges(patch)) { 111 incrementPending(); 112 } 113 sink.enqueueDocPatch(boardId, patch); 114 }, 115 async flush() { 116 try { 117 await sink.flush(); 118 } catch (error) { 119 markError(error); 120 throw error; 121 } 122 }, 123 }; 124 125 return { 126 sink: trackedSink, 127 status, 128 setActiveBoard, 129 dispose() { 130 subscription?.unsubscribe(); 131 subscription = null; 132 }, 133 }; 134} 135 136export function createStatusStore(initial: PersistenceStatus): StatusStore { 137 let value = initial; 138 const listeners = new Set<StatusListener>(); 139 140 return { 141 get() { 142 return value; 143 }, 144 subscribe(listener: StatusListener) { 145 listeners.add(listener); 146 listener(value); 147 return () => { 148 listeners.delete(listener); 149 }; 150 }, 151 update(updater) { 152 value = updater(value); 153 for (const listener of listeners) { 154 listener(value); 155 } 156 }, 157 }; 158} 159 160function hasPatchChanges(patch: DocPatch): boolean { 161 const upserts = patch.upserts; 162 if (upserts?.pages?.length || upserts?.shapes?.length || upserts?.bindings?.length) { 163 return true; 164 } 165 166 const deletes = patch.deletes; 167 if (deletes?.pageIds?.length || deletes?.shapeIds?.length || deletes?.bindingIds?.length) { 168 return true; 169 } 170 171 if (patch.order) { 172 if (patch.order.pageIds?.length) { 173 return true; 174 } 175 if (patch.order.shapeOrder && Object.keys(patch.order.shapeOrder).length > 0) { 176 return true; 177 } 178 } 179 180 return false; 181} 182 183/** 184 * IMPORTANT: Default gridSize must match DEFAULT_GRID_SIZE renderer 185 * to ensure grid lines and snapping positions align correctly 186 */ 187export function createSnapStore(initial?: Partial<SnapSettings>): SnapStore { 188 const defaults: SnapSettings = { snapEnabled: false, gridEnabled: true, gridSize: 25 }; 189 let value: SnapSettings = { ...defaults, ...initial }; 190 const listeners = new Set<(snap: SnapSettings) => void>(); 191 192 return { 193 get() { 194 return value; 195 }, 196 subscribe(listener) { 197 listeners.add(listener); 198 listener(value); 199 return () => { 200 listeners.delete(listener); 201 }; 202 }, 203 update(updater) { 204 value = updater(value); 205 for (const listener of listeners) { 206 listener(value); 207 } 208 }, 209 set(next) { 210 value = next; 211 for (const listener of listeners) { 212 listener(value); 213 } 214 }, 215 }; 216} 217 218export function createBrushStore(initial?: Partial<BrushSettings>): BrushStore { 219 const defaults: BrushSettings = { 220 size: 16, 221 thinning: 0.5, 222 smoothing: 0.5, 223 streamline: 0.5, 224 simulatePressure: true, 225 color: "#88c0d0", 226 }; 227 let value: BrushSettings = { ...defaults, ...initial }; 228 const listeners = new Set<(brush: BrushSettings) => void>(); 229 230 return { 231 get() { 232 return value; 233 }, 234 subscribe(listener) { 235 listeners.add(listener); 236 listener(value); 237 return () => { 238 listeners.delete(listener); 239 }; 240 }, 241 update(updater) { 242 value = updater(value); 243 for (const listener of listeners) { 244 listener(value); 245 } 246 }, 247 set(next) { 248 value = next; 249 for (const listener of listeners) { 250 listener(value); 251 } 252 }, 253 }; 254}