web based infinite canvas
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}