@scrawl-board/board 0.1.0-beta.1 → 0.1.0-beta.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/local.js ADDED
@@ -0,0 +1,342 @@
1
+ //#region src/persistence/shared/documentCollections.ts
2
+ var COLLECTION_FIELD = {
3
+ stroke: "strokes",
4
+ note: "notes",
5
+ text: "textBlocks",
6
+ table: "tables",
7
+ image: "images",
8
+ timer: "timers",
9
+ rectangle: "rectangles",
10
+ ellipse: "ellipses",
11
+ group: "groups",
12
+ line: "lines",
13
+ arrow: "arrows",
14
+ polygon: "polygons",
15
+ star: "stars",
16
+ heart: "hearts",
17
+ custom: "customObjects"
18
+ };
19
+ var FIELD_OBJECT_TYPE = Object.fromEntries(Object.entries(COLLECTION_FIELD).map(([objectType, field]) => [field, objectType]));
20
+ function serializeObjects(objects) {
21
+ const collections = {
22
+ strokes: [],
23
+ notes: [],
24
+ textBlocks: [],
25
+ tables: [],
26
+ images: [],
27
+ timers: [],
28
+ rectangles: [],
29
+ ellipses: [],
30
+ groups: [],
31
+ lines: [],
32
+ arrows: [],
33
+ polygons: [],
34
+ stars: [],
35
+ hearts: [],
36
+ customObjects: []
37
+ };
38
+ let objectOrder = [];
39
+ const entries = [...objects];
40
+ const pages = [];
41
+ let pageName;
42
+ let pageSectionId;
43
+ const notebooks = [];
44
+ const sections = [];
45
+ for (const object of entries) {
46
+ if (object.objectType === "notebook") {
47
+ notebooks.push(object.payload);
48
+ continue;
49
+ }
50
+ if (object.objectType === "section") {
51
+ sections.push(object.payload);
52
+ continue;
53
+ }
54
+ if (object.objectType === "page") {
55
+ const metadata = object.payload;
56
+ if (object.objectId === "default") {
57
+ pageName = metadata.name;
58
+ pageSectionId = metadata.sectionId;
59
+ } else pages.push({
60
+ id: object.objectId,
61
+ name: metadata.name,
62
+ ...metadata.sectionId ? { sectionId: metadata.sectionId } : {},
63
+ content: serializeObjects(entries.filter((entry) => entry.pageId === object.objectId).map((entry) => ({
64
+ ...entry,
65
+ pageId: void 0
66
+ })))
67
+ });
68
+ continue;
69
+ }
70
+ if (object.pageId && object.pageId !== "default") continue;
71
+ if (object.objectType === "order") {
72
+ objectOrder = object.payload?.order ?? [];
73
+ continue;
74
+ }
75
+ collections[COLLECTION_FIELD[object.objectType]].push(object.payload);
76
+ }
77
+ return {
78
+ schemaVersion: 1,
79
+ ...collections,
80
+ objectOrder,
81
+ ...pageName ? { pageName } : {},
82
+ ...pageSectionId ? { pageSectionId } : {},
83
+ ...notebooks.length ? { notebooks } : {},
84
+ ...sections.length ? { sections } : {},
85
+ ...pages.length ? { pages } : {}
86
+ };
87
+ }
88
+ /** The inverse of `serializeObjects` — one `StoredObject` per collection entry, keyed by that entry's own `id`. Malformed entries (no string `id`) are skipped. */
89
+ function objectsFromDocument(document) {
90
+ const result = [];
91
+ const record = document;
92
+ for (const [field, objectType] of Object.entries(FIELD_OBJECT_TYPE)) {
93
+ const items = record[field];
94
+ if (!Array.isArray(items)) continue;
95
+ for (const item of items) {
96
+ const objectId = item?.id;
97
+ if (typeof objectId !== "string") continue;
98
+ result.push({
99
+ objectType,
100
+ objectId,
101
+ payload: item
102
+ });
103
+ }
104
+ }
105
+ if (Array.isArray(document.objectOrder) && document.objectOrder.length) result.push({
106
+ objectType: "order",
107
+ objectId: "order",
108
+ payload: { order: [...document.objectOrder] }
109
+ });
110
+ for (const notebook of document.notebooks ?? []) result.push({
111
+ objectType: "notebook",
112
+ objectId: notebook.id,
113
+ payload: notebook
114
+ });
115
+ for (const section of document.sections ?? []) result.push({
116
+ objectType: "section",
117
+ objectId: section.id,
118
+ payload: section
119
+ });
120
+ if (document.pageName || document.pageSectionId) result.push({
121
+ objectType: "page",
122
+ objectId: "default",
123
+ payload: {
124
+ id: "default",
125
+ name: document.pageName ?? "Page 1",
126
+ ...document.pageSectionId ? { sectionId: document.pageSectionId } : {}
127
+ }
128
+ });
129
+ for (const page of document.pages ?? []) {
130
+ result.push({
131
+ objectType: "page",
132
+ objectId: page.id,
133
+ payload: {
134
+ id: page.id,
135
+ name: page.name,
136
+ ...page.sectionId ? { sectionId: page.sectionId } : {}
137
+ }
138
+ });
139
+ result.push(...objectsFromDocument(page.content).map((object) => ({
140
+ ...object,
141
+ pageId: page.id
142
+ })));
143
+ }
144
+ return result;
145
+ }
146
+ /** Collision-free identity for metadata and content on any page. */
147
+ function storedObjectKey(object) {
148
+ return [
149
+ "page",
150
+ "notebook",
151
+ "section"
152
+ ].includes(object.objectType) ? JSON.stringify([object.objectType, object.objectId]) : JSON.stringify([
153
+ "content",
154
+ object.pageId ?? "default",
155
+ object.objectId
156
+ ]);
157
+ }
158
+ //#endregion
159
+ //#region src/persistence/local/indexedDBPersistenceAdapter.ts
160
+ var OBJECT_STORE = "objects";
161
+ var DOCUMENT_STORE = "documents";
162
+ var DB_VERSION = 2;
163
+ function objectKey(documentId, objectId) {
164
+ return JSON.stringify([documentId, objectId]);
165
+ }
166
+ function requestToPromise(request) {
167
+ return new Promise((resolve, reject) => {
168
+ request.onsuccess = () => resolve(request.result);
169
+ request.onerror = () => reject(request.error);
170
+ });
171
+ }
172
+ function openDatabase(factory, databaseName) {
173
+ return new Promise((resolve, reject) => {
174
+ const request = factory.open(databaseName, DB_VERSION);
175
+ request.onupgradeneeded = (event) => {
176
+ const db = request.result;
177
+ if (!db.objectStoreNames.contains(OBJECT_STORE)) db.createObjectStore(OBJECT_STORE, { keyPath: "key" }).createIndex("documentId", "documentId");
178
+ if (!db.objectStoreNames.contains(DOCUMENT_STORE)) db.createObjectStore(DOCUMENT_STORE, { keyPath: "documentId" });
179
+ if (event.oldVersion === 1) {
180
+ const objects = request.transaction.objectStore(OBJECT_STORE);
181
+ const records = objects.getAll();
182
+ records.onsuccess = () => {
183
+ objects.clear();
184
+ for (const record of records.result) objects.put({
185
+ ...record,
186
+ key: objectKey(record.documentId, storedObjectKey(record))
187
+ });
188
+ };
189
+ const cursor = request.transaction.objectStore(DOCUMENT_STORE).openCursor();
190
+ cursor.onsuccess = () => {
191
+ if (!cursor.result) return;
192
+ const record = cursor.result.value;
193
+ cursor.result.update({
194
+ ...record,
195
+ tombstones: record.tombstones.map((id) => storedObjectKey({
196
+ objectType: "note",
197
+ objectId: id
198
+ }))
199
+ });
200
+ cursor.result.continue();
201
+ };
202
+ }
203
+ };
204
+ request.onsuccess = () => resolve(request.result);
205
+ request.onerror = () => reject(request.error);
206
+ });
207
+ }
208
+ /**
209
+ * Creates a `PersistenceAdapter` backed by the browser's IndexedDB. One
210
+ * instance can back multiple documents (keyed by
211
+ * `DocumentContext.documentId`), stored in object-level records so a
212
+ * partial save never corrupts unrelated objects.
213
+ */
214
+ function createIndexedDBPersistence(options = {}) {
215
+ const maybeFactory = options.indexedDB ?? (typeof indexedDB !== "undefined" ? indexedDB : void 0);
216
+ if (!maybeFactory) throw new Error("createIndexedDBPersistence: no IndexedDB implementation found. Pass `indexedDB` explicitly outside a browser (e.g. in tests), or use createMemoryPersistence instead.");
217
+ const factory = maybeFactory;
218
+ const databaseName = options.databaseName ?? "scrawl-board";
219
+ let dbPromise = null;
220
+ function db() {
221
+ if (!dbPromise) dbPromise = openDatabase(factory, databaseName);
222
+ return dbPromise;
223
+ }
224
+ async function readDocumentRecord(database, documentId) {
225
+ return requestToPromise(database.transaction(DOCUMENT_STORE, "readonly").objectStore(DOCUMENT_STORE).get(documentId));
226
+ }
227
+ async function readObjects(database, documentId) {
228
+ return requestToPromise(database.transaction(OBJECT_STORE, "readonly").objectStore(OBJECT_STORE).index("documentId").getAll(documentId));
229
+ }
230
+ return {
231
+ async load(context) {
232
+ const database = await db();
233
+ const record = await readDocumentRecord(database, context.documentId);
234
+ if (!record || record.version === 0) return { state: "missing" };
235
+ return {
236
+ state: "found",
237
+ document: serializeObjects(await readObjects(database, context.documentId)),
238
+ revision: String(record.version)
239
+ };
240
+ },
241
+ async applyOps(context, ops) {
242
+ const database = await db();
243
+ const existing = await readDocumentRecord(database, context.documentId) ?? {
244
+ documentId: context.documentId,
245
+ version: 0,
246
+ tombstones: []
247
+ };
248
+ const tombstones = new Set(existing.tombstones);
249
+ const rejectedOpIds = [];
250
+ let reason = null;
251
+ const tx = database.transaction([OBJECT_STORE, DOCUMENT_STORE], "readwrite");
252
+ const objectStore = tx.objectStore(OBJECT_STORE);
253
+ for (const op of ops) {
254
+ const scopedId = storedObjectKey(op);
255
+ if (op.kind === "upsert" && tombstones.has(scopedId)) {
256
+ rejectedOpIds.push(op.id);
257
+ reason = "tombstone";
258
+ continue;
259
+ }
260
+ const key = objectKey(context.documentId, scopedId);
261
+ if (op.kind === "remove") {
262
+ objectStore.delete(key);
263
+ tombstones.add(scopedId);
264
+ } else {
265
+ if (op.kind === "restore") tombstones.delete(scopedId);
266
+ const record = {
267
+ key,
268
+ documentId: context.documentId,
269
+ objectType: op.objectType,
270
+ objectId: op.objectId,
271
+ ...op.pageId ? { pageId: op.pageId } : {},
272
+ payload: op.payload
273
+ };
274
+ objectStore.put(record);
275
+ }
276
+ }
277
+ const nextVersion = ops.length ? existing.version + 1 : existing.version;
278
+ const nextRecord = {
279
+ documentId: context.documentId,
280
+ version: nextVersion,
281
+ tombstones: [...tombstones]
282
+ };
283
+ tx.objectStore(DOCUMENT_STORE).put(nextRecord);
284
+ await new Promise((resolve, reject) => {
285
+ tx.oncomplete = () => resolve();
286
+ tx.onerror = () => reject(tx.error);
287
+ tx.onabort = () => reject(tx.error);
288
+ });
289
+ if (rejectedOpIds.length && reason) return {
290
+ state: "reconcile",
291
+ revision: String(nextVersion),
292
+ rejectedOpIds,
293
+ reason
294
+ };
295
+ return {
296
+ state: "applied",
297
+ revision: String(nextVersion)
298
+ };
299
+ },
300
+ async replace(context, document, baseRevision) {
301
+ const database = await db();
302
+ const existing = await readDocumentRecord(database, context.documentId) ?? {
303
+ documentId: context.documentId,
304
+ version: 0,
305
+ tombstones: []
306
+ };
307
+ if (String(existing.version) !== baseRevision) return {
308
+ state: "conflict",
309
+ currentRevision: String(existing.version)
310
+ };
311
+ const tx = database.transaction([OBJECT_STORE, DOCUMENT_STORE], "readwrite");
312
+ const objectStore = tx.objectStore(OBJECT_STORE);
313
+ const existingKeys = await requestToPromise(objectStore.index("documentId").getAllKeys(context.documentId));
314
+ for (const key of existingKeys) objectStore.delete(key);
315
+ for (const object of objectsFromDocument(document)) {
316
+ const record = {
317
+ key: objectKey(context.documentId, storedObjectKey(object)),
318
+ documentId: context.documentId,
319
+ ...object
320
+ };
321
+ objectStore.put(record);
322
+ }
323
+ const nextVersion = existing.version + 1;
324
+ tx.objectStore(DOCUMENT_STORE).put({
325
+ documentId: context.documentId,
326
+ version: nextVersion,
327
+ tombstones: []
328
+ });
329
+ await new Promise((resolve, reject) => {
330
+ tx.oncomplete = () => resolve();
331
+ tx.onerror = () => reject(tx.error);
332
+ tx.onabort = () => reject(tx.error);
333
+ });
334
+ return {
335
+ state: "applied",
336
+ revision: String(nextVersion)
337
+ };
338
+ }
339
+ };
340
+ }
341
+ //#endregion
342
+ export { createIndexedDBPersistence };