Jaimodiji commited on
Commit
67c7632
·
verified ·
1 Parent(s): f7332d7

Upload folder using huggingface_hub

Browse files
.build_counter CHANGED
@@ -1 +1 @@
1
- 25
 
1
+ 24
public/scripts/ColorRmApp.js CHANGED
@@ -1080,12 +1080,6 @@ export class ColorRmApp {
1080
  this.sotaPerf.initialize(item.history);
1081
  }
1082
 
1083
- // Recalculate infinite canvas bounds after loading
1084
- // This ensures the canvas stretches properly even after server restart or for new users
1085
- if (item.isInfinite && this.recalculateInfiniteCanvasBounds) {
1086
- this.recalculateInfiniteCanvasBounds(this.state.idx);
1087
- }
1088
-
1089
  resolve();
1090
  };
1091
  });
 
1080
  this.sotaPerf.initialize(item.history);
1081
  }
1082
 
 
 
 
 
 
 
1083
  resolve();
1084
  };
1085
  });
public/scripts/LiveSync.js CHANGED
@@ -424,11 +424,7 @@ export class LiveSyncClient {
424
  undoStack: [],
425
  redoStack: [],
426
  isInfinite: meta.isInfinite || false,
427
- vectorGrid: meta.vectorGrid || null,
428
- bounds: meta.bounds || null,
429
- origin: meta.origin || null,
430
  templateType: meta.templateType || null,
431
- templateConfig: meta.templateConfig || null,
432
  _needsBlobFetch: true // Mark for R2 fetch
433
  };
434
 
@@ -613,7 +609,6 @@ export class LiveSyncClient {
613
  pagesHistory: new LiveMap(),
614
  pagesModifications: new LiveMap(),
615
  pagesMetadata: new LiveMap(),
616
- pagesDelta: new LiveMap(),
617
  bookmarks: new LiveList([]),
618
  colors: new LiveList([])
619
  }));
@@ -639,10 +634,6 @@ export class LiveSyncClient {
639
  console.log('[LiveSync] Adding missing pagesMetadata LiveMap');
640
  remoteProject.set("pagesMetadata", new LiveMap());
641
  }
642
- if (!remoteProject.get("pagesDelta")) {
643
- console.log('[LiveSync] Adding missing pagesDelta LiveMap');
644
- remoteProject.set("pagesDelta", new LiveMap());
645
- }
646
  }
647
 
648
  await this.syncStorageToLocal();
@@ -1918,10 +1909,6 @@ export class LiveSyncClient {
1918
  this.app.ui.updateProgress(90, 'Finalizing sync...');
1919
  await this._reorderPagesToMatchStructure(remotePageIds);
1920
 
1921
- // STEP 4.5: Apply page metadata to ensure infinite canvas properties are set
1922
- // This is critical for new users or after server restart
1923
- await this._applyPageMetadata(pageMetadata);
1924
-
1925
  // STEP 5: Update UI
1926
  const pt = this.app.getElement('pageTotal');
1927
  if (pt) pt.innerText = '/ ' + this.app.state.images.length;
@@ -2154,68 +2141,6 @@ export class LiveSyncClient {
2154
  }
2155
  }
2156
 
2157
- /**
2158
- * Apply page metadata from server to local pages
2159
- * Critical for restoring infinite canvas properties after server restart or for new users
2160
- * @param {object} pageMetadata - Map of pageId -> metadata from server
2161
- */
2162
- async _applyPageMetadata(pageMetadata) {
2163
- if (!pageMetadata || Object.keys(pageMetadata).length === 0) return;
2164
-
2165
- let updated = false;
2166
-
2167
- for (const page of this.app.state.images) {
2168
- if (!page || !page.pageId) continue;
2169
-
2170
- const meta = pageMetadata[page.pageId];
2171
- if (!meta) continue;
2172
-
2173
- // Apply infinite canvas properties if missing locally
2174
- if (meta.isInfinite && !page.isInfinite) {
2175
- page.isInfinite = true;
2176
- page.vectorGrid = meta.vectorGrid;
2177
- page.bounds = meta.bounds;
2178
- page.origin = meta.origin;
2179
- updated = true;
2180
- console.log(`[_applyPageMetadata] Applied infinite canvas props to page ${page.pageId}`);
2181
- }
2182
-
2183
- // Update vectorGrid/bounds if they exist in meta but not locally
2184
- if (meta.isInfinite && page.isInfinite) {
2185
- if (meta.vectorGrid && !page.vectorGrid) {
2186
- page.vectorGrid = meta.vectorGrid;
2187
- updated = true;
2188
- }
2189
- if (meta.bounds && !page.bounds) {
2190
- page.bounds = meta.bounds;
2191
- updated = true;
2192
- }
2193
- if (meta.origin && !page.origin) {
2194
- page.origin = meta.origin;
2195
- updated = true;
2196
- }
2197
- }
2198
-
2199
- // Apply template properties if missing locally
2200
- if (meta.templateType && !page.templateType) {
2201
- page.templateType = meta.templateType;
2202
- page.templateConfig = meta.templateConfig;
2203
- updated = true;
2204
- console.log(`[_applyPageMetadata] Applied template props to page ${page.pageId}`);
2205
- }
2206
- }
2207
-
2208
- // Persist updates to IndexedDB
2209
- if (updated) {
2210
- for (const page of this.app.state.images) {
2211
- if (page) {
2212
- await this.app.dbPut('pages', page);
2213
- }
2214
- }
2215
- console.log(`[_applyPageMetadata] Persisted metadata updates to IndexedDB`);
2216
- }
2217
- }
2218
-
2219
  /**
2220
  * Fetches missing pages from backend when remote has more pages than local
2221
  * @param {number} expectedPageCount - The expected total page count from metadata
@@ -2234,58 +2159,125 @@ export class LiveSyncClient {
2234
  const project = this.getProject();
2235
  if (!project || this.isInitializing) return;
2236
 
 
2237
  let currentIdxChanged = false;
2238
  const pagesToFetchBase = [];
2239
 
2240
- // Check for versioned delta system first (new approach)
2241
- const pagesDelta = project.get("pagesDelta");
2242
- const usesVersionedDelta = pagesDelta && pagesDelta.size > 0;
2243
-
2244
  // Priority: Update current page immediately
2245
- const currentPageIdx = this.app.state.idx;
2246
- const localImg = this.app.state.images[currentPageIdx];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2247
 
2248
- if (localImg) {
2249
- if (usesVersionedDelta) {
2250
- // Use versioned delta sync
2251
- currentIdxChanged = this._syncPageWithVersionedDelta(currentPageIdx, localImg, pagesToFetchBase);
2252
- } else {
2253
- // Legacy sync (backwards compatible)
2254
- currentIdxChanged = this._syncPageLegacy(currentPageIdx, localImg, pagesToFetchBase);
 
 
2255
  }
2256
  }
2257
 
2258
  // Background sync all other pages (with null check)
2259
  this.app.state.images.forEach((img, idx) => {
2260
  if (!img) return; // Skip null/undefined pages (gaps)
2261
- if (idx === currentPageIdx) return; // Already handled
2262
-
2263
- if (usesVersionedDelta) {
2264
- this._syncPageWithVersionedDelta(idx, img, pagesToFetchBase);
2265
- } else {
2266
- this._syncPageLegacy(idx, img, pagesToFetchBase);
2267
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2268
 
2269
- // Auto-recalculate infinite canvas bounds for all infinite pages
2270
- if (img.isInfinite && this.app.recalculateInfiniteCanvasBounds) {
2271
- this.app.recalculateInfiniteCanvasBounds(idx);
 
2272
  }
2273
  });
2274
 
2275
- if (currentIdxChanged) {
2276
- if (this.app.invalidateCache) this.app.invalidateCache();
2277
- this.app.render();
2278
-
2279
- // SOTA v2: Reinitialize performance manager with updated history
2280
- if (localImg?.history?.length > 500 && this.app.sotaPerf) {
2281
- this.app.sotaPerf.initialize(localImg.history);
2282
- }
2283
-
2284
- // Auto-recalculate infinite canvas bounds from synced strokes
2285
- if (localImg?.isInfinite && this.app.recalculateInfiniteCanvasBounds) {
2286
- this.app.recalculateInfiniteCanvasBounds(currentPageIdx);
2287
- }
2288
- }
2289
 
2290
  // Fetch base history for pages that need it (async, will trigger re-sync)
2291
  if (pagesToFetchBase.length > 0) {
@@ -2293,132 +2285,6 @@ export class LiveSyncClient {
2293
  }
2294
  }
2295
 
2296
- /**
2297
- * Sync a page using versioned delta system
2298
- * R2 stores base history, Liveblocks stores only deltas
2299
- */
2300
- _syncPageWithVersionedDelta(pageIdx, localImg, pagesToFetchBase) {
2301
- const delta = this.getVersionedDelta(pageIdx);
2302
- if (!delta) return false;
2303
-
2304
- let changed = false;
2305
- const pageId = localImg.pageId;
2306
-
2307
- // Initialize local delta sync state from Liveblocks
2308
- if (this.app._initPageSyncState) {
2309
- this.app._initPageSyncState(pageId, null, delta);
2310
- }
2311
-
2312
- // Check if we need to fetch R2 base
2313
- const needsR2Base = delta.r2Version > 0 &&
2314
- (!localImg._r2Version || localImg._r2Version < delta.r2Version);
2315
-
2316
- if (needsR2Base && pageId) {
2317
- // Queue for R2 fetch
2318
- pagesToFetchBase.push({ idx: pageIdx, pageId, r2Version: delta.r2Version });
2319
-
2320
- // Meanwhile, apply deltas to existing history using CRDT merge
2321
- if (delta.deltas?.length > 0) {
2322
- const mergedHistory = this._crdtMergeHistory(
2323
- localImg.history || [],
2324
- delta.deltas,
2325
- delta.timestamp
2326
- );
2327
- localImg.history = mergedHistory;
2328
- changed = true;
2329
- }
2330
- } else {
2331
- // We have correct R2 base - reconstruct full history
2332
- // Start with whatever local history we have that was from R2
2333
- let baseHistory = localImg.history?.slice(0, delta.r2HistoryCount) || [];
2334
-
2335
- // Apply modifications
2336
- if (delta.modifications && Object.keys(delta.modifications).length > 0) {
2337
- baseHistory = baseHistory.map(item => {
2338
- const mod = delta.modifications[item.id];
2339
- if (mod) {
2340
- return { ...item, ...mod };
2341
- }
2342
- return item;
2343
- });
2344
- }
2345
-
2346
- // Combine base with deltas
2347
- const remoteHistory = [...baseHistory, ...(delta.deltas || [])];
2348
-
2349
- // CRDT merge with any local-only items
2350
- const mergedHistory = this._crdtMergeHistory(
2351
- localImg.history || [],
2352
- remoteHistory,
2353
- delta.timestamp
2354
- );
2355
-
2356
- if (JSON.stringify(mergedHistory) !== JSON.stringify(localImg.history)) {
2357
- localImg.history = mergedHistory;
2358
- changed = true;
2359
- }
2360
-
2361
- // Track R2 version locally
2362
- localImg._r2Version = delta.r2Version;
2363
- }
2364
-
2365
- return changed;
2366
- }
2367
-
2368
- /**
2369
- * Legacy sync for backwards compatibility
2370
- * Full history stored in Liveblocks pagesHistory
2371
- */
2372
- _syncPageLegacy(pageIdx, localImg, pagesToFetchBase) {
2373
- const project = this.getProject();
2374
- const pagesHistory = project.get("pagesHistory");
2375
- if (!pagesHistory) return false;
2376
-
2377
- const remote = pagesHistory.get(pageIdx.toString());
2378
- if (!remote) return false;
2379
-
2380
- const deltaHist = remote.toArray();
2381
-
2382
- // Check if we need to fetch base history first
2383
- const pageMeta = this.getPageMetadata(pageIdx);
2384
- const needsBase = (pageMeta?.hasBaseHistory || localImg.hasBaseHistory) && !localImg._baseHistory;
2385
-
2386
- if (needsBase) {
2387
- // Queue for base history fetch, use CRDT merge with deltas for now
2388
- pagesToFetchBase.push(pageIdx);
2389
- const mergedHistory = this._crdtMergeHistory(
2390
- localImg.history || [],
2391
- deltaHist,
2392
- Date.now()
2393
- );
2394
- localImg.history = mergedHistory;
2395
- return true;
2396
- } else if (localImg.hasBaseHistory && localImg._baseHistory) {
2397
- // If page has base history, merge it with deltas and apply modifications
2398
- const liveblocksModifications = this.getPageModifications(pageIdx);
2399
- const r2Modifications = localImg._r2Modifications || {};
2400
- const modifications = { ...liveblocksModifications, ...r2Modifications };
2401
-
2402
- const modifiedBase = localImg._baseHistory.map(item => {
2403
- if (modifications[item.id]) {
2404
- return { ...item, ...modifications[item.id] };
2405
- }
2406
- return item;
2407
- });
2408
- localImg.history = [...modifiedBase, ...deltaHist];
2409
- return true;
2410
- } else {
2411
- // CRDT merge: combine local and remote history
2412
- const mergedHistory = this._crdtMergeHistory(
2413
- localImg.history || [],
2414
- deltaHist,
2415
- Date.now()
2416
- );
2417
- localImg.history = mergedHistory;
2418
- return true;
2419
- }
2420
- }
2421
-
2422
  /**
2423
  * CRDT-based history merge for multi-user collaboration
2424
  * Implements a Last-Writer-Wins with ID-based merge strategy:
@@ -2516,17 +2382,11 @@ export class LiveSyncClient {
2516
  }
2517
 
2518
  // Fetches base history for multiple pages and re-syncs
2519
- async _fetchMissingBaseHistories(pageItems) {
2520
- console.log(`[LiveSync] Fetching base history for ${pageItems.length} pages...`);
2521
 
2522
- for (const pageItem of pageItems) {
2523
- // Support both old format (just index) and new format (object with pageId and r2Version)
2524
- if (typeof pageItem === 'object' && pageItem.pageId) {
2525
- await this._fetchVersionedBaseHistory(pageItem);
2526
- } else {
2527
- // Legacy: pageItem is just the page index
2528
- await this.ensureBaseHistory(pageItem);
2529
- }
2530
  }
2531
 
2532
  // Re-sync history now that base is loaded
@@ -2537,79 +2397,7 @@ export class LiveSyncClient {
2537
  }, 100);
2538
  }
2539
 
2540
- /**
2541
- * Fetch versioned base history from R2
2542
- * Called when we detect a newer R2 version in Liveblocks
2543
- */
2544
- async _fetchVersionedBaseHistory(pageInfo) {
2545
- const { idx, pageId, r2Version } = pageInfo;
2546
- const localImg = this.app.state.images[idx];
2547
- if (!localImg || !pageId) return;
2548
-
2549
- try {
2550
- const historyUrl = window.Config?.apiUrl(`/api/color_rm/history/${this.app.state.sessionId}/${pageId}`)
2551
- || `/api/color_rm/history/${this.app.state.sessionId}/${pageId}`;
2552
-
2553
- const response = await fetch(historyUrl);
2554
- if (response.ok) {
2555
- const r2Data = await response.json();
2556
-
2557
- // R2 response can be array (legacy) or object with version info
2558
- let history, version, historyCount;
2559
- if (Array.isArray(r2Data)) {
2560
- // Legacy format
2561
- history = r2Data;
2562
- version = r2Version || 1;
2563
- historyCount = history.length;
2564
- } else {
2565
- // Versioned format
2566
- history = r2Data.history || [];
2567
- version = r2Data.version || r2Version || 1;
2568
- historyCount = r2Data.historyCount || history.length;
2569
- }
2570
-
2571
- console.log(`[LiveSync] Fetched R2 base v${version}: ${history.length} items for page ${idx}`);
2572
-
2573
- // Get delta from Liveblocks
2574
- const delta = this.getVersionedDelta(idx);
2575
-
2576
- // Apply modifications from delta
2577
- if (delta?.modifications && Object.keys(delta.modifications).length > 0) {
2578
- history = history.map(item => {
2579
- const mod = delta.modifications[item.id];
2580
- if (mod) {
2581
- return { ...item, ...mod };
2582
- }
2583
- return item;
2584
- });
2585
- }
2586
-
2587
- // Combine R2 base + Liveblocks deltas + local items
2588
- const remoteHistory = [...history, ...(delta?.deltas || [])];
2589
-
2590
- // CRDT merge with local history
2591
- localImg.history = this._crdtMergeHistory(
2592
- localImg.history || [],
2593
- remoteHistory,
2594
- Date.now()
2595
- );
2596
-
2597
- // Track R2 version locally
2598
- localImg._r2Version = version;
2599
-
2600
- // Initialize delta sync state
2601
- if (this.app._initPageSyncState) {
2602
- this.app._initPageSyncState(pageId, { version, historyCount }, delta);
2603
- }
2604
-
2605
- if (this.app.invalidateCache) this.app.invalidateCache();
2606
- }
2607
- } catch (e) {
2608
- console.error(`[LiveSync] Failed to fetch R2 base for page ${idx}:`, e);
2609
- }
2610
- }
2611
-
2612
- // Fetch and cache base history for a page (called when page is loaded) - LEGACY
2613
  async ensureBaseHistory(pageIdx) {
2614
  const localImg = this.app.state.images[pageIdx];
2615
  if (!localImg) return;
@@ -3027,7 +2815,7 @@ export class LiveSyncClient {
3027
  const pageMetadata = {};
3028
  for (const page of this.app.state.images) {
3029
  if (page && page.pageId) {
3030
- const meta = {
3031
  width: page.width || this.app.state.viewW,
3032
  height: page.height || this.app.state.viewH,
3033
  bg: page.bg || '#ffffff',
@@ -3035,20 +2823,6 @@ export class LiveSyncClient {
3035
  templateType: page.templateType || null,
3036
  pageIndex: page.pageIndex
3037
  };
3038
-
3039
- // Include infinite canvas properties
3040
- if (page.isInfinite) {
3041
- meta.vectorGrid = page.vectorGrid;
3042
- meta.bounds = page.bounds;
3043
- meta.origin = page.origin;
3044
- }
3045
-
3046
- // Include template config
3047
- if (page.templateType) {
3048
- meta.templateConfig = page.templateConfig;
3049
- }
3050
-
3051
- pageMetadata[page.pageId] = meta;
3052
  }
3053
  }
3054
 
@@ -3196,248 +2970,4 @@ export class LiveSyncClient {
3196
  console.log('[handlePageStructureChange] Running reconciliation...');
3197
  await this.reconcilePageStructure();
3198
  }
3199
-
3200
- // =============================================
3201
- // VERSIONED DELTA SYNC METHODS
3202
- // =============================================
3203
- // These methods implement the robust versioned delta sync system:
3204
- // - R2 stores periodic snapshots (base history) with version tracking
3205
- // - Liveblocks only stores deltas since last R2 sync
3206
- // - Keeps Liveblocks data under 1MB even for large histories
3207
- // =============================================
3208
-
3209
- /**
3210
- * Sync versioned delta to Liveblocks
3211
- * Called by ColorRmStorage.saveCurrentImg() for incremental updates
3212
- *
3213
- * @param {number} pageIdx - Page index
3214
- * @param {object} deltaInfo - Delta information
3215
- * @param {number} deltaInfo.r2Version - Current R2 version
3216
- * @param {number} deltaInfo.r2HistoryCount - Items in R2
3217
- * @param {Array} deltaInfo.deltas - New items since R2 sync
3218
- * @param {object} deltaInfo.modifications - Changes to R2 base items
3219
- * @param {number} deltaInfo.timestamp - Sync timestamp
3220
- */
3221
- syncVersionedDelta(pageIdx, deltaInfo) {
3222
- // Beta mode: Use Yjs
3223
- if (this.useBetaSync) {
3224
- return this._syncVersionedDeltaYjs(pageIdx, deltaInfo);
3225
- }
3226
-
3227
- const project = this.getProject();
3228
- if (!project || this.isInitializing) return;
3229
-
3230
- const key = pageIdx.toString();
3231
-
3232
- // Get or create pagesDelta LiveMap
3233
- let pagesDelta = project.get("pagesDelta");
3234
- if (!pagesDelta) {
3235
- project.set("pagesDelta", new LiveMap());
3236
- pagesDelta = project.get("pagesDelta");
3237
- }
3238
-
3239
- // Store delta info as LiveObject
3240
- const deltaData = new LiveObject({
3241
- r2Version: deltaInfo.r2Version,
3242
- r2HistoryCount: deltaInfo.r2HistoryCount,
3243
- timestamp: deltaInfo.timestamp,
3244
- // Store deltas as LiveList for efficient updates
3245
- deltas: new LiveList(deltaInfo.deltas || []),
3246
- // Store modifications as nested object
3247
- modifications: deltaInfo.modifications || {}
3248
- });
3249
-
3250
- pagesDelta.set(key, deltaData);
3251
-
3252
- const deltaCount = deltaInfo.deltas?.length || 0;
3253
- const modCount = Object.keys(deltaInfo.modifications || {}).length;
3254
- console.log(`[LiveSync] Synced versioned delta page ${pageIdx}: r2v=${deltaInfo.r2Version}, ${deltaCount} deltas, ${modCount} mods`);
3255
- }
3256
-
3257
- /**
3258
- * Notify that R2 sync completed - clears delta in Liveblocks
3259
- * Called after successful R2 upload to reset delta state
3260
- *
3261
- * @param {number} pageIdx - Page index
3262
- * @param {object} r2Info - R2 sync info
3263
- */
3264
- notifyR2SyncComplete(pageIdx, r2Info) {
3265
- // Beta mode: Use Yjs
3266
- if (this.useBetaSync) {
3267
- return this._notifyR2SyncCompleteYjs(pageIdx, r2Info);
3268
- }
3269
-
3270
- const project = this.getProject();
3271
- if (!project || this.isInitializing) return;
3272
-
3273
- const key = pageIdx.toString();
3274
-
3275
- // Update pagesDelta with new R2 version and clear deltas
3276
- let pagesDelta = project.get("pagesDelta");
3277
- if (!pagesDelta) {
3278
- project.set("pagesDelta", new LiveMap());
3279
- pagesDelta = project.get("pagesDelta");
3280
- }
3281
-
3282
- // Reset delta - only store R2 version info
3283
- pagesDelta.set(key, new LiveObject({
3284
- r2Version: r2Info.r2Version,
3285
- r2HistoryCount: r2Info.r2HistoryCount,
3286
- timestamp: r2Info.timestamp,
3287
- deltas: new LiveList([]),
3288
- modifications: {}
3289
- }));
3290
-
3291
- // Also update page metadata
3292
- this.updatePageMetadata(pageIdx, {
3293
- r2Version: r2Info.r2Version,
3294
- r2HistoryCount: r2Info.r2HistoryCount,
3295
- r2SyncTimestamp: r2Info.timestamp
3296
- });
3297
-
3298
- console.log(`[LiveSync] R2 sync complete notification: page ${pageIdx}, r2v=${r2Info.r2Version}`);
3299
- }
3300
-
3301
- /**
3302
- * Get versioned delta for a page from Liveblocks
3303
- * Used when syncing to combine R2 base with Liveblocks deltas
3304
- *
3305
- * @param {number} pageIdx - Page index
3306
- * @returns {object|null} Delta info or null
3307
- */
3308
- getVersionedDelta(pageIdx) {
3309
- // Beta mode: Get from Yjs
3310
- if (this.useBetaSync) {
3311
- return this._getVersionedDeltaYjs(pageIdx);
3312
- }
3313
-
3314
- const project = this.getProject();
3315
- if (!project) return null;
3316
-
3317
- const pagesDelta = project.get("pagesDelta");
3318
- if (!pagesDelta) return null;
3319
-
3320
- const key = pageIdx.toString();
3321
- const delta = pagesDelta.get(key);
3322
- if (!delta) return null;
3323
-
3324
- // Convert LiveObject to plain object
3325
- const deltaObj = delta.toObject ? delta.toObject() : delta;
3326
- return {
3327
- r2Version: deltaObj.r2Version || 0,
3328
- r2HistoryCount: deltaObj.r2HistoryCount || 0,
3329
- timestamp: deltaObj.timestamp || 0,
3330
- deltas: deltaObj.deltas?.toArray ? deltaObj.deltas.toArray() : (deltaObj.deltas || []),
3331
- modifications: deltaObj.modifications || {}
3332
- };
3333
- }
3334
-
3335
- /**
3336
- * Fetch combined history for a page: R2 base + Liveblocks deltas
3337
- * Used when loading a page or for new users joining
3338
- *
3339
- * @param {number} pageIdx - Page index
3340
- * @param {string} pageId - Page ID for R2 fetch
3341
- * @returns {Promise<{history: Array, r2Version: number, r2HistoryCount: number}>}
3342
- */
3343
- async fetchCombinedHistory(pageIdx, pageId) {
3344
- // Get delta info from Liveblocks
3345
- const delta = this.getVersionedDelta(pageIdx);
3346
- const r2Version = delta?.r2Version || 0;
3347
-
3348
- // Fetch R2 base history if version > 0
3349
- let baseHistory = [];
3350
- if (r2Version > 0 && pageId) {
3351
- try {
3352
- const historyUrl = window.Config?.apiUrl(`/api/color_rm/history/${this.app.state.sessionId}/${pageId}`)
3353
- || `/api/color_rm/history/${this.app.state.sessionId}/${pageId}`;
3354
-
3355
- const response = await fetch(historyUrl);
3356
- if (response.ok) {
3357
- const r2Data = await response.json();
3358
- // R2 response could be array or object with history field
3359
- baseHistory = Array.isArray(r2Data) ? r2Data : (r2Data.history || []);
3360
- console.log(`[LiveSync] Fetched ${baseHistory.length} R2 base items for page ${pageIdx}`);
3361
- }
3362
- } catch (e) {
3363
- console.error('[LiveSync] Failed to fetch R2 base history:', e);
3364
- }
3365
- }
3366
-
3367
- // Apply modifications to base
3368
- if (delta?.modifications && Object.keys(delta.modifications).length > 0) {
3369
- baseHistory = baseHistory.map(item => {
3370
- const mod = delta.modifications[item.id];
3371
- if (mod) {
3372
- return { ...item, ...mod };
3373
- }
3374
- return item;
3375
- });
3376
- }
3377
-
3378
- // Combine: base + deltas
3379
- const combined = [...baseHistory, ...(delta?.deltas || [])];
3380
-
3381
- return {
3382
- history: combined,
3383
- r2Version: r2Version,
3384
- r2HistoryCount: delta?.r2HistoryCount || 0
3385
- };
3386
- }
3387
-
3388
- // =============================================
3389
- // YJS VERSIONS OF VERSIONED DELTA SYNC
3390
- // =============================================
3391
-
3392
- _syncVersionedDeltaYjs(pageIdx, deltaInfo) {
3393
- if (!this.yjsRoot) return;
3394
-
3395
- const key = pageIdx.toString();
3396
-
3397
- this.yjsDoc.transact(() => {
3398
- // Ensure pagesDelta map exists
3399
- if (!this.yjsRoot.pagesDelta) {
3400
- this.yjsRoot.pagesDelta = new Y.Map();
3401
- }
3402
-
3403
- this.yjsRoot.pagesDelta.set(key, {
3404
- r2Version: deltaInfo.r2Version,
3405
- r2HistoryCount: deltaInfo.r2HistoryCount,
3406
- timestamp: deltaInfo.timestamp,
3407
- deltas: deltaInfo.deltas || [],
3408
- modifications: deltaInfo.modifications || {}
3409
- });
3410
- });
3411
-
3412
- console.log(`[Yjs] Synced versioned delta page ${pageIdx}`);
3413
- }
3414
-
3415
- _notifyR2SyncCompleteYjs(pageIdx, r2Info) {
3416
- if (!this.yjsRoot) return;
3417
-
3418
- const key = pageIdx.toString();
3419
-
3420
- this.yjsDoc.transact(() => {
3421
- if (!this.yjsRoot.pagesDelta) {
3422
- this.yjsRoot.pagesDelta = new Y.Map();
3423
- }
3424
-
3425
- this.yjsRoot.pagesDelta.set(key, {
3426
- r2Version: r2Info.r2Version,
3427
- r2HistoryCount: r2Info.r2HistoryCount,
3428
- timestamp: r2Info.timestamp,
3429
- deltas: [],
3430
- modifications: {}
3431
- });
3432
- });
3433
-
3434
- console.log(`[Yjs] R2 sync complete: page ${pageIdx}, r2v=${r2Info.r2Version}`);
3435
- }
3436
-
3437
- _getVersionedDeltaYjs(pageIdx) {
3438
- if (!this.yjsRoot || !this.yjsRoot.pagesDelta) return null;
3439
-
3440
- const key = pageIdx.toString();
3441
- return this.yjsRoot.pagesDelta.get(key) || null;
3442
- }
3443
  }
 
424
  undoStack: [],
425
  redoStack: [],
426
  isInfinite: meta.isInfinite || false,
 
 
 
427
  templateType: meta.templateType || null,
 
428
  _needsBlobFetch: true // Mark for R2 fetch
429
  };
430
 
 
609
  pagesHistory: new LiveMap(),
610
  pagesModifications: new LiveMap(),
611
  pagesMetadata: new LiveMap(),
 
612
  bookmarks: new LiveList([]),
613
  colors: new LiveList([])
614
  }));
 
634
  console.log('[LiveSync] Adding missing pagesMetadata LiveMap');
635
  remoteProject.set("pagesMetadata", new LiveMap());
636
  }
 
 
 
 
637
  }
638
 
639
  await this.syncStorageToLocal();
 
1909
  this.app.ui.updateProgress(90, 'Finalizing sync...');
1910
  await this._reorderPagesToMatchStructure(remotePageIds);
1911
 
 
 
 
 
1912
  // STEP 5: Update UI
1913
  const pt = this.app.getElement('pageTotal');
1914
  if (pt) pt.innerText = '/ ' + this.app.state.images.length;
 
2141
  }
2142
  }
2143
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2144
  /**
2145
  * Fetches missing pages from backend when remote has more pages than local
2146
  * @param {number} expectedPageCount - The expected total page count from metadata
 
2159
  const project = this.getProject();
2160
  if (!project || this.isInitializing) return;
2161
 
2162
+ const pagesHistory = project.get("pagesHistory");
2163
  let currentIdxChanged = false;
2164
  const pagesToFetchBase = [];
2165
 
 
 
 
 
2166
  // Priority: Update current page immediately
2167
+ const currentRemote = pagesHistory.get(this.app.state.idx.toString());
2168
+ if (currentRemote) {
2169
+ const deltaHist = currentRemote.toArray();
2170
+ const localImg = this.app.state.images[this.app.state.idx];
2171
+ if (localImg) {
2172
+ // Check if we need to fetch base history first
2173
+ const pageMeta = this.getPageMetadata(this.app.state.idx);
2174
+ const needsBase = (pageMeta?.hasBaseHistory || localImg.hasBaseHistory) && !localImg._baseHistory;
2175
+
2176
+ if (needsBase) {
2177
+ // Queue for base history fetch, use CRDT merge with deltas for now
2178
+ pagesToFetchBase.push(this.app.state.idx);
2179
+ // Don't replace - merge to avoid losing local strokes
2180
+ const mergedHistory = this._crdtMergeHistory(
2181
+ localImg.history || [],
2182
+ deltaHist,
2183
+ Date.now()
2184
+ );
2185
+ localImg.history = mergedHistory;
2186
+ } else if (localImg.hasBaseHistory && localImg._baseHistory) {
2187
+ // If page has base history, merge it with deltas and apply modifications
2188
+ // Get modifications from Liveblocks + R2 (if large)
2189
+ const liveblocksModifications = this.getPageModifications(this.app.state.idx);
2190
+ const r2Modifications = localImg._r2Modifications || {};
2191
+ const modifications = { ...liveblocksModifications, ...r2Modifications };
2192
+
2193
+ // Apply modifications to base history
2194
+ const modifiedBase = localImg._baseHistory.map(item => {
2195
+ if (modifications[item.id]) {
2196
+ // Merge modification onto base item
2197
+ return { ...item, ...modifications[item.id] };
2198
+ }
2199
+ return item;
2200
+ });
2201
+ // Merge: modified base + deltas from Liveblocks
2202
+ localImg.history = [...modifiedBase, ...deltaHist];
2203
+ } else {
2204
+ // CRDT merge: combine local and remote history
2205
+ const mergedHistory = this._crdtMergeHistory(
2206
+ localImg.history || [],
2207
+ deltaHist,
2208
+ Date.now()
2209
+ );
2210
+ localImg.history = mergedHistory;
2211
+ }
2212
+ currentIdxChanged = true;
2213
+ if (this.app.invalidateCache) this.app.invalidateCache();
2214
 
2215
+ // SOTA v2: Reinitialize performance manager with updated history
2216
+ if (localImg.history && localImg.history.length > 500 && this.app.sotaPerf) {
2217
+ this.app.sotaPerf.initialize(localImg.history);
2218
+ }
2219
+
2220
+ // Auto-recalculate infinite canvas bounds from synced strokes
2221
+ if (localImg.isInfinite && this.app.recalculateInfiniteCanvasBounds) {
2222
+ this.app.recalculateInfiniteCanvasBounds(this.app.state.idx);
2223
+ }
2224
  }
2225
  }
2226
 
2227
  // Background sync all other pages (with null check)
2228
  this.app.state.images.forEach((img, idx) => {
2229
  if (!img) return; // Skip null/undefined pages (gaps)
2230
+ if (idx === this.app.state.idx) return; // Already handled
2231
+ const remote = pagesHistory.get(idx.toString());
2232
+ if (remote) {
2233
+ const deltaHist = remote.toArray();
2234
+
2235
+ // Check if we need to fetch base history first
2236
+ const pageMeta = this.getPageMetadata(idx);
2237
+ const needsBase = (pageMeta?.hasBaseHistory || img.hasBaseHistory) && !img._baseHistory;
2238
+
2239
+ if (needsBase) {
2240
+ // Queue for base history fetch, use CRDT merge with deltas for now
2241
+ pagesToFetchBase.push(idx);
2242
+ // Don't replace - merge to avoid losing local strokes
2243
+ const mergedHistory = this._crdtMergeHistory(
2244
+ img.history || [],
2245
+ deltaHist,
2246
+ Date.now()
2247
+ );
2248
+ img.history = mergedHistory;
2249
+ } else if (img.hasBaseHistory && img._baseHistory) {
2250
+ // If page has base history, merge it with deltas and apply modifications
2251
+ // Get modifications from Liveblocks + R2 (if large)
2252
+ const liveblocksModifications = this.getPageModifications(idx);
2253
+ const r2Modifications = img._r2Modifications || {};
2254
+ const modifications = { ...liveblocksModifications, ...r2Modifications };
2255
+
2256
+ const modifiedBase = img._baseHistory.map(item => {
2257
+ if (modifications[item.id]) {
2258
+ return { ...item, ...modifications[item.id] };
2259
+ }
2260
+ return item;
2261
+ });
2262
+ img.history = [...modifiedBase, ...deltaHist];
2263
+ } else {
2264
+ // CRDT merge: combine local and remote history
2265
+ const mergedHistory = this._crdtMergeHistory(
2266
+ img.history || [],
2267
+ deltaHist,
2268
+ Date.now()
2269
+ );
2270
+ img.history = mergedHistory;
2271
+ }
2272
 
2273
+ // Auto-recalculate infinite canvas bounds for all infinite pages
2274
+ if (img.isInfinite && this.app.recalculateInfiniteCanvasBounds) {
2275
+ this.app.recalculateInfiniteCanvasBounds(idx);
2276
+ }
2277
  }
2278
  });
2279
 
2280
+ if (currentIdxChanged) this.app.render();
 
 
 
 
 
 
 
 
 
 
 
 
 
2281
 
2282
  // Fetch base history for pages that need it (async, will trigger re-sync)
2283
  if (pagesToFetchBase.length > 0) {
 
2285
  }
2286
  }
2287
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2288
  /**
2289
  * CRDT-based history merge for multi-user collaboration
2290
  * Implements a Last-Writer-Wins with ID-based merge strategy:
 
2382
  }
2383
 
2384
  // Fetches base history for multiple pages and re-syncs
2385
+ async _fetchMissingBaseHistories(pageIndices) {
2386
+ console.log(`[LiveSync] Fetching base history for ${pageIndices.length} pages...`);
2387
 
2388
+ for (const pageIdx of pageIndices) {
2389
+ await this.ensureBaseHistory(pageIdx);
 
 
 
 
 
 
2390
  }
2391
 
2392
  // Re-sync history now that base is loaded
 
2397
  }, 100);
2398
  }
2399
 
2400
+ // Fetch and cache base history for a page (called when page is loaded)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2401
  async ensureBaseHistory(pageIdx) {
2402
  const localImg = this.app.state.images[pageIdx];
2403
  if (!localImg) return;
 
2815
  const pageMetadata = {};
2816
  for (const page of this.app.state.images) {
2817
  if (page && page.pageId) {
2818
+ pageMetadata[page.pageId] = {
2819
  width: page.width || this.app.state.viewW,
2820
  height: page.height || this.app.state.viewH,
2821
  bg: page.bg || '#ffffff',
 
2823
  templateType: page.templateType || null,
2824
  pageIndex: page.pageIndex
2825
  };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2826
  }
2827
  }
2828
 
 
2970
  console.log('[handlePageStructureChange] Running reconciliation...');
2971
  await this.reconcilePageStructure();
2972
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2973
  }
public/scripts/modules/ColorRmStorage.js CHANGED
@@ -1,54 +1,6 @@
1
  import { fetchWithTimeout, TIMEOUT } from './NetworkUtils.js';
2
 
3
- // =============================================
4
- // VERSIONED DELTA SYNC SYSTEM
5
- // =============================================
6
- // R2 stores periodic snapshots (base history)
7
- // Liveblocks syncs only deltas since last R2 snapshot
8
- // This keeps Liveblocks under 1MB while supporting large histories
9
- // =============================================
10
-
11
- // Constants for delta sync
12
- const R2_IDLE_SYNC_DELAY = 1500; // Sync to R2 after 1.5s of inactivity
13
- const LIVEBLOCKS_MAX_DELTA_SIZE = 200; // Max items in Liveblocks delta before forcing R2 sync
14
- const R2_SYNC_BATCH_SIZE = 500; // Sync to R2 when history grows by this much
15
-
16
  export const ColorRmStorage = {
17
- // =============================================
18
- // DELTA SYNC STATE (per page)
19
- // =============================================
20
- // _deltaSyncState[pageId] = {
21
- // r2Version: number, // Version of history in R2
22
- // r2HistoryCount: number, // Number of items in R2 snapshot
23
- // lastR2SyncTime: number, // Timestamp of last R2 sync
24
- // pendingDeltas: [], // Items added since last R2 sync
25
- // idleTimer: null // Timer for idle sync
26
- // }
27
- _deltaSyncState: {},
28
-
29
- /**
30
- * Initialize delta sync state for a page
31
- */
32
- _initDeltaSyncState(pageId) {
33
- if (!this._deltaSyncState[pageId]) {
34
- this._deltaSyncState[pageId] = {
35
- r2Version: 0,
36
- r2HistoryCount: 0,
37
- lastR2SyncTime: 0,
38
- pendingDeltas: [],
39
- idleTimer: null
40
- };
41
- }
42
- return this._deltaSyncState[pageId];
43
- },
44
-
45
- /**
46
- * Get delta sync state for a page (backwards compatible)
47
- */
48
- _getDeltaSyncState(pageId) {
49
- return this._deltaSyncState[pageId] || this._initDeltaSyncState(pageId);
50
- },
51
-
52
  async dbPut(s, v) {
53
  return new Promise((resolve, reject) => {
54
  try {
@@ -148,236 +100,81 @@ export const ColorRmStorage = {
148
  }
149
  },
150
 
151
- /**
152
- * VERSIONED DELTA SYNC SYSTEM
153
- *
154
- * Architecture:
155
- * - R2 stores periodic snapshots (base history) with version number
156
- * - Liveblocks only syncs deltas since last R2 snapshot
157
- * - R2 syncs on idle (1500ms) to ensure consistency
158
- * - Version tracking: r2Version tracks items synced to R2
159
- *
160
- * Flow:
161
- * 1. On save: calculate deltas (items since r2HistoryCount)
162
- * 2. If delta count < LIVEBLOCKS_MAX_DELTA_SIZE: sync to Liveblocks only
163
- * 3. Start idle timer (R2_IDLE_SYNC_DELAY) to sync full state to R2
164
- * 4. If delta exceeds limit OR idle timer fires: sync to R2, reset deltas
165
- * 5. New users: fetch R2 base + Liveblocks deltas, merge
166
- */
167
  async saveCurrentImg(skipRemoteSync = false) {
168
  // Invalidate cache immediately since history changed in memory
169
  if (this.invalidateCache) this.invalidateCache();
170
 
171
- if (!this.state.sessionId) return;
172
-
173
- const currentPage = this.state.images[this.state.idx];
174
- await this.dbPut('pages', currentPage);
175
-
176
- // Check if sync is enabled
177
- if (!this.state.syncEnabled) {
178
- console.log('[saveCurrentImg] Sync disabled, skipping remote sync');
179
- return;
180
- }
181
-
182
- if (skipRemoteSync || !this.liveSync || this.liveSync.isInitializing) {
183
- return;
184
- }
185
-
186
- const pageId = currentPage.pageId;
187
- if (!pageId) {
188
- console.warn('[saveCurrentImg] Page has no pageId, skipping sync');
189
- return;
190
- }
191
-
192
- // Get or initialize delta sync state for this page
193
- const syncState = this._getDeltaSyncState(pageId);
194
- const history = currentPage.history || [];
195
- const historyCount = history.length;
196
-
197
- // Calculate delta: items since last R2 sync
198
- const deltaStartIdx = syncState.r2HistoryCount;
199
- const deltaItems = history.slice(deltaStartIdx);
200
- const deltaCount = deltaItems.length;
201
-
202
- // Also collect modifications to items in R2 base
203
- const modifications = {};
204
- if (deltaStartIdx > 0) {
205
- // Check if any base items were modified
206
- for (let i = 0; i < Math.min(deltaStartIdx, history.length); i++) {
207
- const item = history[i];
208
- if (item && item._modified) {
209
- modifications[item.id] = item;
210
- delete item._modified; // Clear flag after syncing
211
- }
212
- // Check for deletions
213
- if (item && item.deleted && !item._deletionSynced) {
214
- modifications[item.id] = { id: item.id, deleted: true, lastMod: item.lastMod || Date.now() };
215
- item._deletionSynced = true;
216
- }
217
- }
218
- }
219
-
220
- // Decision: Should we force R2 sync?
221
- const forceR2Sync = deltaCount > LIVEBLOCKS_MAX_DELTA_SIZE ||
222
- (syncState.r2HistoryCount === 0 && historyCount > LIVEBLOCKS_MAX_DELTA_SIZE);
223
-
224
- if (forceR2Sync) {
225
- // Delta too large - sync full history to R2 immediately
226
- console.log(`[saveCurrentImg] Delta ${deltaCount} exceeds limit, forcing R2 sync`);
227
- this._scheduleR2Sync(pageId, currentPage, true); // immediate=true
228
- } else {
229
- // Delta is small enough for Liveblocks
230
- // Sync delta to Liveblocks
231
- this.liveSync.syncVersionedDelta(this.state.idx, {
232
- r2Version: syncState.r2Version,
233
- r2HistoryCount: syncState.r2HistoryCount,
234
- deltas: deltaItems,
235
- modifications: modifications,
236
- timestamp: Date.now()
237
- });
238
 
239
- // Schedule idle R2 sync (will reset timer if called again)
240
- this._scheduleR2Sync(pageId, currentPage, false);
241
- }
242
- },
243
-
244
- /**
245
- * Schedule R2 sync - debounced with idle timer
246
- * @param {string} pageId - Page identifier
247
- * @param {object} currentPage - Page data
248
- * @param {boolean} immediate - If true, sync immediately without delay
249
- */
250
- _scheduleR2Sync(pageId, currentPage, immediate = false) {
251
- const syncState = this._getDeltaSyncState(pageId);
252
-
253
- // Clear existing timer
254
- if (syncState.idleTimer) {
255
- clearTimeout(syncState.idleTimer);
256
- syncState.idleTimer = null;
257
- }
258
-
259
- const doSync = async () => {
260
- if (this._r2SyncInProgress?.[pageId]) {
261
- console.log(`[R2Sync] Sync already in progress for ${pageId}`);
262
  return;
263
  }
264
 
265
- // Mark sync in progress
266
- if (!this._r2SyncInProgress) this._r2SyncInProgress = {};
267
- this._r2SyncInProgress[pageId] = true;
268
-
269
- try {
270
- const history = currentPage.history || [];
271
- const historyCount = history.length;
272
-
273
- // Skip if no history
274
- if (historyCount === 0) {
275
- console.log(`[R2Sync] No history to sync for ${pageId}`);
276
- return;
277
- }
278
-
279
- // Skip if nothing changed since last R2 sync
280
- if (syncState.r2HistoryCount === historyCount) {
281
- console.log(`[R2Sync] No changes since last sync for ${pageId}`);
282
- return;
283
- }
284
-
285
- // Upload to R2
286
- const historyUrl = window.Config?.apiUrl(`/api/color_rm/history/${this.state.sessionId}/${pageId}`)
287
- || `/api/color_rm/history/${this.state.sessionId}/${pageId}`;
288
-
289
- const newVersion = syncState.r2Version + 1;
290
- const payload = {
291
- version: newVersion,
292
- historyCount: historyCount,
293
- history: history,
294
- timestamp: Date.now()
295
- };
296
-
297
- console.log(`[R2Sync] Uploading to ${historyUrl}`);
298
- console.log(`[R2Sync] Payload: v${newVersion}, ${historyCount} items, ${JSON.stringify(payload).length} bytes`);
299
-
300
- const response = await fetchWithTimeout(historyUrl, {
301
- method: 'POST',
302
- headers: { 'Content-Type': 'application/json' },
303
- body: JSON.stringify(payload)
304
- }, TIMEOUT.LONG);
305
-
306
- console.log(`[R2Sync] Response status: ${response.status}`);
307
-
308
- if (!response.ok) {
309
- const errorText = await response.text();
310
- console.error(`[R2Sync] Upload failed: ${response.status}`, errorText);
311
- throw new Error(`R2 upload failed: ${response.status} - ${errorText}`);
 
 
 
 
 
 
 
 
 
 
 
 
312
  }
313
-
314
- // Update sync state
315
- syncState.r2Version = newVersion;
316
- syncState.r2HistoryCount = historyCount;
317
- syncState.lastR2SyncTime = Date.now();
318
- syncState.pendingDeltas = [];
319
-
320
- // Notify Liveblocks of new R2 version (clears delta in Liveblocks)
321
- this.liveSync.notifyR2SyncComplete(this.state.idx, {
322
- r2Version: newVersion,
323
- r2HistoryCount: historyCount,
324
- timestamp: syncState.lastR2SyncTime
325
- });
326
-
327
- console.log(`[R2Sync] Synced page ${pageId}: v${newVersion}, ${historyCount} items`);
328
-
329
- } catch (e) {
330
- console.error(`[R2Sync] Failed for ${pageId}:`, e);
331
- // Don't show toast for every failure - will retry on next save
332
- } finally {
333
- this._r2SyncInProgress[pageId] = false;
334
  }
335
- };
336
-
337
- if (immediate) {
338
- doSync();
339
- } else {
340
- syncState.idleTimer = setTimeout(doSync, R2_IDLE_SYNC_DELAY);
341
- }
342
- },
343
-
344
- /**
345
- * Initialize delta sync state from R2/Liveblocks on page load
346
- * Called when loading a page to establish baseline
347
- */
348
- async _initPageSyncState(pageId, r2Data, liveblocksData) {
349
- const syncState = this._getDeltaSyncState(pageId);
350
-
351
- if (r2Data) {
352
- syncState.r2Version = r2Data.version || 0;
353
- syncState.r2HistoryCount = r2Data.historyCount || r2Data.history?.length || 0;
354
- syncState.lastR2SyncTime = r2Data.timestamp || 0;
355
- }
356
-
357
- if (liveblocksData) {
358
- // Liveblocks has delta info
359
- if (liveblocksData.r2Version > syncState.r2Version) {
360
- // Liveblocks has newer R2 version info - trust it
361
- syncState.r2Version = liveblocksData.r2Version;
362
- syncState.r2HistoryCount = liveblocksData.r2HistoryCount || 0;
363
- }
364
- }
365
-
366
- console.log(`[DeltaSync] Initialized page ${pageId}: r2v=${syncState.r2Version}, count=${syncState.r2HistoryCount}`);
367
- },
368
-
369
- /**
370
- * Mark an item as modified (for delta tracking)
371
- * Call this when modifying existing items (move, resize, delete)
372
- */
373
- markItemModified(itemId) {
374
- const currentPage = this.state.images[this.state.idx];
375
- if (!currentPage?.history) return;
376
-
377
- const item = currentPage.history.find(h => h.id === itemId);
378
- if (item) {
379
- item._modified = true;
380
- item.lastMod = Date.now();
381
  }
382
  },
383
 
 
1
  import { fetchWithTimeout, TIMEOUT } from './NetworkUtils.js';
2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  export const ColorRmStorage = {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  async dbPut(s, v) {
5
  return new Promise((resolve, reject) => {
6
  try {
 
100
  }
101
  },
102
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  async saveCurrentImg(skipRemoteSync = false) {
104
  // Invalidate cache immediately since history changed in memory
105
  if (this.invalidateCache) this.invalidateCache();
106
 
107
+ if(this.state.sessionId) {
108
+ const currentPage = this.state.images[this.state.idx];
109
+ await this.dbPut('pages', currentPage);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
 
111
+ // Check if sync is enabled
112
+ if (!this.state.syncEnabled) {
113
+ console.log('[saveCurrentImg] Sync disabled, skipping remote sync');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  return;
115
  }
116
 
117
+ if (!skipRemoteSync && this.liveSync && !this.liveSync.isInitializing) {
118
+ // If page has base history (SVG import), only sync deltas
119
+ if (currentPage.hasBaseHistory && currentPage._baseHistory) {
120
+ // Extract deltas: items not in base history + modified base items
121
+ const baseIds = new Set(currentPage._baseHistory.map(item => item.id));
122
+ const deltas = [];
123
+ const modifications = {};
124
+
125
+ currentPage.history.forEach(item => {
126
+ if (!baseIds.has(item.id)) {
127
+ // New item (user scribble) - add to deltas
128
+ deltas.push(item);
129
+ } else {
130
+ // Item exists in base - check if modified
131
+ const baseItem = currentPage._baseHistory.find(b => b.id === item.id);
132
+ if (baseItem && this._isItemModified(baseItem, item)) {
133
+ // Store as modification (only changed properties)
134
+ modifications[item.id] = item;
135
+ }
136
+ }
137
+ });
138
+
139
+ // Also track deleted base items
140
+ currentPage._baseHistory.forEach(baseItem => {
141
+ const currentItem = currentPage.history.find(h => h.id === baseItem.id);
142
+ if (currentItem && currentItem.deleted && !baseItem.deleted) {
143
+ modifications[baseItem.id] = { id: baseItem.id, deleted: true, lastMod: currentItem.lastMod };
144
+ }
145
+ });
146
+
147
+ // Check if modifications are too large for Liveblocks
148
+ const modCount = Object.keys(modifications).length;
149
+ const LARGE_MODIFICATION_THRESHOLD = 100;
150
+
151
+ if (modCount > LARGE_MODIFICATION_THRESHOLD) {
152
+ // Too many modifications - use R2 instead
153
+ console.log(`[saveCurrentImg] ${modCount} modifications exceed threshold, using R2`);
154
+ await this._syncLargeModificationsToR2(currentPage, deltas, modifications);
155
+ } else {
156
+ // Sync deltas and modifications via Liveblocks
157
+ this.liveSync.syncPageDeltas(this.state.idx, deltas, modifications);
158
+ }
159
+ } else {
160
+ // No base history yet - check if history is too large
161
+ const historySize = currentPage.history?.length || 0;
162
+ // Use lower threshold for SVG-imported pages (they have base history in R2)
163
+ // Regular pages can have more items before triggering R2 upload
164
+ const LARGE_HISTORY_THRESHOLD = currentPage.hasBaseHistory ? 400 : 2000;
165
+
166
+ if (historySize > LARGE_HISTORY_THRESHOLD && !this._r2HistoryUploading) {
167
+ // Too large for Liveblocks - convert to hybrid approach
168
+ // Only trigger once (debounced), subsequent calls will use delta sync
169
+ console.log(`[saveCurrentImg] History size ${historySize} exceeds threshold, converting to R2 hybrid`);
170
+ this._syncLargeHistoryToR2(currentPage);
171
+ } else if (!this._r2HistoryUploading) {
172
+ // Sync full history as before
173
+ this.liveSync.setHistory(this.state.idx, currentPage.history);
174
+ }
175
+ // Skip sync if R2 upload is in progress
176
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
177
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
  }
179
  },
180
 
worker/colorRmAssets.ts CHANGED
@@ -400,12 +400,10 @@ export async function handleListPages(request: IRequest, env: Env) {
400
  export async function handleColorRmHistoryUpload(request: IRequest, env: Env) {
401
  const { roomId, pageId } = request.params
402
  if (!roomId || !pageId) {
403
- console.log(`[HistoryUpload] Missing params: roomId=${roomId}, pageId=${pageId}`)
404
  return new Response('Missing roomId or pageId', { status: 400 })
405
  }
406
 
407
  if (!request.body) {
408
- console.log(`[HistoryUpload] Missing request body for page: ${pageId}`)
409
  return new Response('Missing request body', { status: 400 })
410
  }
411
 
@@ -413,50 +411,22 @@ export async function handleColorRmHistoryUpload(request: IRequest, env: Env) {
413
 
414
  try {
415
  const body = await request.text()
416
- console.log(`[HistoryUpload] Received body for page ${pageId}, length: ${body.length}`)
417
-
418
  const parsed = JSON.parse(body)
419
-
420
- // Support both legacy (array) and new versioned format (object with history field)
421
- let historyArray: any[]
422
- let version: number = 1
423
- let historyCount: number
424
- let storeData: string
425
-
426
- if (Array.isArray(parsed)) {
427
- // Legacy format: direct array
428
- historyArray = parsed
429
- historyCount = parsed.length
430
- // Convert to versioned format for storage
431
- storeData = JSON.stringify({
432
- version: 1,
433
- historyCount: historyCount,
434
- history: historyArray,
435
- timestamp: Date.now()
436
- })
437
- console.log(`[HistoryUpload] Legacy format: ${historyCount} items`)
438
- } else if (parsed && typeof parsed === 'object' && parsed.history) {
439
- // New versioned format
440
- historyArray = parsed.history
441
- version = parsed.version || 1
442
- historyCount = parsed.historyCount || historyArray.length
443
- storeData = body // Store as-is
444
- console.log(`[HistoryUpload] Versioned format: v${version}, ${historyCount} items`)
445
- } else {
446
- console.log(`[HistoryUpload] Invalid format for page ${pageId}:`, typeof parsed)
447
- return new Response('History must be an array or versioned object', { status: 400 })
448
  }
449
 
450
- await env.TLDRAW_BUCKET.put(objectKey, storeData, {
451
  httpMetadata: { contentType: 'application/json' }
452
  })
453
- console.log(`[HistoryUpload] Stored v${version} with ${historyCount} items for page: ${pageId}`)
454
- return new Response(JSON.stringify({ success: true, version, count: historyCount }), {
455
  status: 200,
456
  headers: { 'Content-Type': 'application/json' }
457
  })
458
  } catch (e: any) {
459
- console.error('[HistoryUpload] Error:', e.message, e.stack)
460
  return new Response(JSON.stringify({ error: e.message }), { status: 500 })
461
  }
462
  }
@@ -465,54 +435,29 @@ export async function handleColorRmHistoryUpload(request: IRequest, env: Env) {
465
  export async function handleColorRmHistoryDownload(request: IRequest, env: Env) {
466
  const { roomId, pageId } = request.params
467
  if (!roomId || !pageId) {
468
- console.log(`[HistoryDownload] Missing params: roomId=${roomId}, pageId=${pageId}`)
469
  return new Response('Missing roomId or pageId', { status: 400 })
470
  }
471
 
472
  const objectKey = `color_rm/history/${roomId}/${pageId}`
473
- console.log(`[HistoryDownload] Fetching: ${objectKey}`)
474
 
475
  try {
476
  const obj = await env.TLDRAW_BUCKET.get(objectKey)
477
 
478
  if (!obj) {
479
- // No base history - return empty versioned response
480
- console.log(`[HistoryDownload] No history found for page: ${pageId}`)
481
- return new Response(JSON.stringify({ version: 0, historyCount: 0, history: [], timestamp: 0 }), {
482
  status: 200,
483
  headers: { 'Content-Type': 'application/json' }
484
  })
485
  }
486
 
487
  const data = await obj.text()
488
- console.log(`[HistoryDownload] Found history for page ${pageId}, size: ${data.length} bytes`)
489
-
490
- // Check if it's versioned format or legacy array
491
- try {
492
- const parsed = JSON.parse(data)
493
- if (Array.isArray(parsed)) {
494
- // Legacy format - convert to versioned for client
495
- console.log(`[HistoryDownload] Converting legacy format: ${parsed.length} items`)
496
- return new Response(JSON.stringify({
497
- version: 1,
498
- historyCount: parsed.length,
499
- history: parsed,
500
- timestamp: Date.now()
501
- }), {
502
- status: 200,
503
- headers: { 'Content-Type': 'application/json' }
504
- })
505
- }
506
- } catch (e) {
507
- // Not valid JSON, return as-is
508
- }
509
-
510
  return new Response(data, {
511
  status: 200,
512
  headers: { 'Content-Type': 'application/json' }
513
  })
514
  } catch (e: any) {
515
- console.error('[HistoryDownload] Error:', e.message, e.stack)
516
  return new Response(JSON.stringify({ error: e.message }), { status: 500 })
517
  }
518
  }
 
400
  export async function handleColorRmHistoryUpload(request: IRequest, env: Env) {
401
  const { roomId, pageId } = request.params
402
  if (!roomId || !pageId) {
 
403
  return new Response('Missing roomId or pageId', { status: 400 })
404
  }
405
 
406
  if (!request.body) {
 
407
  return new Response('Missing request body', { status: 400 })
408
  }
409
 
 
411
 
412
  try {
413
  const body = await request.text()
414
+ // Validate it's valid JSON array
 
415
  const parsed = JSON.parse(body)
416
+ if (!Array.isArray(parsed)) {
417
+ return new Response('History must be an array', { status: 400 })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
418
  }
419
 
420
+ await env.TLDRAW_BUCKET.put(objectKey, body, {
421
  httpMetadata: { contentType: 'application/json' }
422
  })
423
+ console.log(`[HistoryUpload] Stored ${parsed.length} items for page: ${pageId}`)
424
+ return new Response(JSON.stringify({ success: true, count: parsed.length }), {
425
  status: 200,
426
  headers: { 'Content-Type': 'application/json' }
427
  })
428
  } catch (e: any) {
429
+ console.error('Error uploading page history:', e)
430
  return new Response(JSON.stringify({ error: e.message }), { status: 500 })
431
  }
432
  }
 
435
  export async function handleColorRmHistoryDownload(request: IRequest, env: Env) {
436
  const { roomId, pageId } = request.params
437
  if (!roomId || !pageId) {
 
438
  return new Response('Missing roomId or pageId', { status: 400 })
439
  }
440
 
441
  const objectKey = `color_rm/history/${roomId}/${pageId}`
 
442
 
443
  try {
444
  const obj = await env.TLDRAW_BUCKET.get(objectKey)
445
 
446
  if (!obj) {
447
+ // No base history - return empty array
448
+ return new Response('[]', {
 
449
  status: 200,
450
  headers: { 'Content-Type': 'application/json' }
451
  })
452
  }
453
 
454
  const data = await obj.text()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
455
  return new Response(data, {
456
  status: 200,
457
  headers: { 'Content-Type': 'application/json' }
458
  })
459
  } catch (e: any) {
460
+ console.error('Error downloading page history:', e)
461
  return new Response(JSON.stringify({ error: e.message }), { status: 500 })
462
  }
463
  }