Jaimodiji commited on
Commit
983a8ed
·
verified ·
1 Parent(s): 07c18d7

Upload folder using huggingface_hub

Browse files
.build_counter CHANGED
@@ -1 +1 @@
1
- 28
 
1
+ 24
public/scripts/LiveSync.js CHANGED
@@ -1,9 +1,22 @@
1
 
2
  import { createClient, LiveObject, LiveMap, LiveList } from 'https://cdn.jsdelivr.net/npm/@liveblocks/client@3.12.1/+esm';
3
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  export class LiveSyncClient {
5
  constructor(appInstance) {
6
  this.app = appInstance;
 
7
  this.client = null;
8
  this.room = null;
9
  this.userId = localStorage.getItem('color_rm_user_id');
@@ -13,6 +26,11 @@ export class LiveSyncClient {
13
  this.isInitializing = true;
14
  this.root = null;
15
 
 
 
 
 
 
16
  // Track recent local page changes to prevent sync conflicts
17
  this.lastLocalPageChange = 0;
18
  this.PAGE_CHANGE_GRACE_PERIOD = 500; // 500ms grace period for fluid sync
@@ -45,6 +63,11 @@ export class LiveSyncClient {
45
  this.ownerId = ownerId;
46
  this.projectId = projectId;
47
 
 
 
 
 
 
48
  const roomId = `room_${ownerId}`;
49
 
50
  // Update URL only if this is the main app (hacky check? or let the app handle it)
@@ -2137,111 +2160,128 @@ export class LiveSyncClient {
2137
  if (!project || this.isInitializing) return;
2138
 
2139
  const pagesHistory = project.get("pagesHistory");
2140
- const pagesR2Keys = project.get("pagesR2Keys");
2141
  let currentIdxChanged = false;
2142
- const pagesToFetchFromR2 = [];
2143
-
2144
- // Helper to sync a single page
2145
- const syncPage = (pageIdx, localImg) => {
2146
- if (!localImg) return false;
2147
-
2148
- // Check if this page uses R2 storage
2149
- const r2Info = pagesR2Keys?.get(pageIdx.toString());
2150
- if (r2Info) {
2151
- const r2Data = r2Info.toObject ? r2Info.toObject() : r2Info;
2152
- // Check if we need to fetch from R2
2153
- if (!localImg._r2Fetched || localImg._r2Timestamp < r2Data.timestamp) {
2154
- pagesToFetchFromR2.push({ idx: pageIdx, pageId: r2Data.r2Key, timestamp: r2Data.timestamp });
2155
- }
2156
- return false; // Will be updated after R2 fetch
2157
- }
2158
-
2159
- // Normal Liveblocks sync
2160
- const remote = pagesHistory?.get(pageIdx.toString());
2161
- if (remote) {
2162
- const remoteHist = remote.toArray();
2163
- if (remoteHist.length > 0) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2164
  // CRDT merge: combine local and remote history
2165
  const mergedHistory = this._crdtMergeHistory(
2166
  localImg.history || [],
2167
- remoteHist,
2168
  Date.now()
2169
  );
2170
  localImg.history = mergedHistory;
2171
- return true;
2172
  }
2173
- }
2174
- return false;
2175
- };
2176
-
2177
- // Sync current page first (priority)
2178
- const currentImg = this.app.state.images[this.app.state.idx];
2179
- if (currentImg) {
2180
- currentIdxChanged = syncPage(this.app.state.idx, currentImg);
2181
-
2182
- if (currentIdxChanged) {
2183
  if (this.app.invalidateCache) this.app.invalidateCache();
2184
 
2185
  // SOTA v2: Reinitialize performance manager with updated history
2186
- if (currentImg.history?.length > 500 && this.app.sotaPerf) {
2187
- this.app.sotaPerf.initialize(currentImg.history);
2188
  }
2189
 
2190
  // Auto-recalculate infinite canvas bounds from synced strokes
2191
- if (currentImg.isInfinite && this.app.recalculateInfiniteCanvasBounds) {
2192
  this.app.recalculateInfiniteCanvasBounds(this.app.state.idx);
2193
  }
2194
  }
2195
  }
2196
 
2197
- // Background sync all other pages
2198
  this.app.state.images.forEach((img, idx) => {
2199
- if (!img) return;
2200
  if (idx === this.app.state.idx) return; // Already handled
2201
- syncPage(idx, img);
 
 
2202
 
2203
- if (img.isInfinite && this.app.recalculateInfiniteCanvasBounds) {
2204
- this.app.recalculateInfiniteCanvasBounds(idx);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2205
  }
2206
  });
2207
 
2208
  if (currentIdxChanged) this.app.render();
2209
 
2210
- // Fetch from R2 for pages that need it
2211
- if (pagesToFetchFromR2.length > 0) {
2212
- this._fetchHistoriesFromR2(pagesToFetchFromR2);
2213
- }
2214
- }
2215
-
2216
- /**
2217
- * Fetch histories from R2 for multiple pages
2218
- */
2219
- async _fetchHistoriesFromR2(pageInfos) {
2220
- console.log(`[LiveSync] Fetching history from R2 for ${pageInfos.length} pages...`);
2221
-
2222
- for (const { idx, pageId, timestamp } of pageInfos) {
2223
- const localImg = this.app.state.images[idx];
2224
- if (!localImg) continue;
2225
-
2226
- const history = await this.fetchHistoryFromR2(pageId);
2227
- if (history.length > 0) {
2228
- // CRDT merge with local
2229
- localImg.history = this._crdtMergeHistory(
2230
- localImg.history || [],
2231
- history,
2232
- Date.now()
2233
- );
2234
- localImg._r2Fetched = true;
2235
- localImg._r2Timestamp = timestamp;
2236
-
2237
- console.log(`[LiveSync] Applied R2 history for page ${idx}: ${history.length} items`);
2238
-
2239
- // Render if current page
2240
- if (idx === this.app.state.idx) {
2241
- if (this.app.invalidateCache) this.app.invalidateCache();
2242
- this.app.render();
2243
- }
2244
- }
2245
  }
2246
  }
2247
 
@@ -2457,90 +2497,6 @@ export class LiveSyncClient {
2457
  if (!project) return;
2458
  const pagesHistory = project.get("pagesHistory");
2459
  pagesHistory.set(pageIdx.toString(), new LiveList(history || []));
2460
-
2461
- // Clear R2 key if we're setting inline history
2462
- const pagesR2Keys = project.get("pagesR2Keys");
2463
- if (pagesR2Keys && pagesR2Keys.has(pageIdx.toString())) {
2464
- pagesR2Keys.delete(pageIdx.toString());
2465
- }
2466
- }
2467
-
2468
- /**
2469
- * Set R2 key for a page's history (for large histories > 500 items)
2470
- * Instead of storing history in Liveblocks, store just the R2 reference
2471
- */
2472
- setHistoryR2Key(pageIdx, r2Info) {
2473
- // Beta mode: Not supported yet
2474
- if (this.useBetaSync) {
2475
- console.warn('[setHistoryR2Key] Not supported in beta mode');
2476
- return;
2477
- }
2478
-
2479
- const project = this.getProject();
2480
- if (!project) return;
2481
-
2482
- const key = pageIdx.toString();
2483
-
2484
- // Store R2 key in a separate LiveMap
2485
- let pagesR2Keys = project.get("pagesR2Keys");
2486
- if (!pagesR2Keys) {
2487
- project.set("pagesR2Keys", new LiveMap());
2488
- pagesR2Keys = project.get("pagesR2Keys");
2489
- }
2490
-
2491
- pagesR2Keys.set(key, new LiveObject({
2492
- r2Key: r2Info.r2Key,
2493
- count: r2Info.count,
2494
- timestamp: r2Info.timestamp
2495
- }));
2496
-
2497
- // Clear inline history since we're using R2
2498
- const pagesHistory = project.get("pagesHistory");
2499
- if (pagesHistory.has(key)) {
2500
- pagesHistory.set(key, new LiveList([]));
2501
- }
2502
-
2503
- console.log(`[LiveSync] Set R2 key for page ${pageIdx}: ${r2Info.r2Key} (${r2Info.count} items)`);
2504
- }
2505
-
2506
- /**
2507
- * Get R2 key for a page (if history is stored in R2)
2508
- */
2509
- getHistoryR2Key(pageIdx) {
2510
- if (this.useBetaSync) return null;
2511
-
2512
- const project = this.getProject();
2513
- if (!project) return null;
2514
-
2515
- const pagesR2Keys = project.get("pagesR2Keys");
2516
- if (!pagesR2Keys) return null;
2517
-
2518
- const r2Info = pagesR2Keys.get(pageIdx.toString());
2519
- return r2Info ? r2Info.toObject() : null;
2520
- }
2521
-
2522
- /**
2523
- * Fetch history from R2 for a page
2524
- */
2525
- async fetchHistoryFromR2(pageId) {
2526
- if (!this.app.state.sessionId || !pageId) return [];
2527
-
2528
- try {
2529
- const historyUrl = window.Config?.apiUrl(`/api/color_rm/history/${this.app.state.sessionId}/${pageId}`)
2530
- || `/api/color_rm/history/${this.app.state.sessionId}/${pageId}`;
2531
-
2532
- console.log(`[LiveSync] Fetching history from R2: ${pageId}`);
2533
- const response = await fetch(historyUrl);
2534
-
2535
- if (response.ok) {
2536
- const history = await response.json();
2537
- console.log(`[LiveSync] Fetched ${history.length} items from R2 for page ${pageId}`);
2538
- return history;
2539
- }
2540
- } catch (e) {
2541
- console.error('[LiveSync] Failed to fetch history from R2:', e);
2542
- }
2543
- return [];
2544
  }
2545
 
2546
  /**
 
1
 
2
  import { createClient, LiveObject, LiveMap, LiveList } from 'https://cdn.jsdelivr.net/npm/@liveblocks/client@3.12.1/+esm';
3
 
4
+ // Yjs imports - lazy loaded only when beta mode is used
5
+ let Y = null;
6
+ let WebsocketProvider = null;
7
+ async function loadYjs() {
8
+ if (!Y) {
9
+ Y = await import('https://cdn.jsdelivr.net/npm/yjs@13.6.10/+esm');
10
+ const ywsModule = await import('https://cdn.jsdelivr.net/npm/y-websocket@2.0.4/+esm');
11
+ WebsocketProvider = ywsModule.WebsocketProvider;
12
+ console.log('[LiveSync] Yjs modules loaded');
13
+ }
14
+ }
15
+
16
  export class LiveSyncClient {
17
  constructor(appInstance) {
18
  this.app = appInstance;
19
+ this.useBetaSync = appInstance.config.useBetaSync || false; // Beta mode flag
20
  this.client = null;
21
  this.room = null;
22
  this.userId = localStorage.getItem('color_rm_user_id');
 
26
  this.isInitializing = true;
27
  this.root = null;
28
 
29
+ // Yjs-specific (only used when useBetaSync=true)
30
+ this.yjsDoc = null;
31
+ this.yjsProvider = null;
32
+ this.yjsRoot = null;
33
+
34
  // Track recent local page changes to prevent sync conflicts
35
  this.lastLocalPageChange = 0;
36
  this.PAGE_CHANGE_GRACE_PERIOD = 500; // 500ms grace period for fluid sync
 
63
  this.ownerId = ownerId;
64
  this.projectId = projectId;
65
 
66
+ // Beta mode: Use Yjs instead of Liveblocks
67
+ if (this.useBetaSync) {
68
+ return this._initYjs(ownerId, projectId);
69
+ }
70
+
71
  const roomId = `room_${ownerId}`;
72
 
73
  // Update URL only if this is the main app (hacky check? or let the app handle it)
 
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) {
2284
+ this._fetchMissingBaseHistories(pagesToFetchBase);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2285
  }
2286
  }
2287
 
 
2497
  if (!project) return;
2498
  const pagesHistory = project.get("pagesHistory");
2499
  pagesHistory.set(pageIdx.toString(), new LiveList(history || []));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2500
  }
2501
 
2502
  /**
public/scripts/modules/ColorRmInput.js CHANGED
@@ -107,16 +107,12 @@ export const ColorRmInput = {
107
  // Undo Creation: Mark items as deleted
108
  // Save 'create' action to redo stack (Redo will un-delete)
109
  img.redoStack.push(action);
110
-
111
  action.ids.forEach(id => {
112
  const item = img.history.find(i => i.id === id);
113
  if (item) {
114
  item.deleted = true;
115
  item.lastMod = Date.now();
116
- // Update SOTA spatial index
117
- if (this.sotaPerf) {
118
- this.sotaPerf.deleteStroke(item);
119
- }
120
  }
121
  });
122
  } else if (action.type === 'modify') {
@@ -167,16 +163,12 @@ export const ColorRmInput = {
167
  if (action.type === 'create') {
168
  // Redo Creation: Mark items as NOT deleted
169
  img.undoStack.push(action);
170
-
171
  action.ids.forEach(id => {
172
  const item = img.history.find(i => i.id === id);
173
  if (item) {
174
  item.deleted = false;
175
  item.lastMod = Date.now();
176
- // Update SOTA spatial index - re-add the stroke
177
- if (this.sotaPerf) {
178
- this.sotaPerf.addStroke(item);
179
- }
180
  }
181
  });
182
  } else if (action.type === 'modify') {
@@ -297,10 +289,6 @@ export const ColorRmInput = {
297
  if (item) {
298
  item.deleted = true;
299
  item.lastMod = Date.now();
300
- // Update SOTA spatial index for deleted item
301
- if (this.sotaPerf) {
302
- this.sotaPerf.deleteStroke(item);
303
- }
304
  }
305
  });
306
  this.state.selection = [];
@@ -326,10 +314,6 @@ export const ColorRmInput = {
326
  else { item.x+=20; item.y+=20; }
327
  }
328
  img.history.push(item);
329
- // Update SOTA spatial index for copied item
330
- if (this.sotaPerf) {
331
- this.sotaPerf.addStroke(item);
332
- }
333
  newIds.push(img.history.length-1);
334
  });
335
  if(cut) this.deleteSelected();
@@ -1328,23 +1312,19 @@ export const ColorRmInput = {
1328
  if (hit) {
1329
  // 1. Initialize array if this is the first item hit in this gesture
1330
  if (!this._eraserSessionState) this._eraserSessionState = [];
1331
-
1332
  // 2. Snapshot the state BEFORE deleting (if we haven't already for this stroke)
1333
  if (!this._eraserSessionState.some(item => item.idx === i)) {
1334
  this._eraserSessionState.push({
1335
  idx: i,
1336
  // JSON.stringify creates a deep copy of the object state (including pts, color, x, y, deleted=false)
1337
- state: JSON.parse(JSON.stringify(st))
1338
  });
1339
  }
1340
 
1341
  // 3. Apply the deletion (mutation)
1342
  st.deleted = true;
1343
  st.lastMod = Date.now();
1344
- // Update SOTA spatial index
1345
- if (this.sotaPerf) {
1346
- this.sotaPerf.deleteStroke(st);
1347
- }
1348
  changed = true;
1349
  }
1350
  }
@@ -1664,10 +1644,6 @@ export const ColorRmInput = {
1664
 
1665
  this.state.images[this.state.idx].history.push(newShape);
1666
  this.recordCreation(newShape);
1667
- // Update SOTA spatial index for new shape
1668
- if (this.sotaPerf) {
1669
- this.sotaPerf.addStroke(newShape);
1670
- }
1671
  this.saveCurrentImg();
1672
  this.state.selection=[this.state.images[this.state.idx].history.length-1];
1673
  this.setTool('lasso');
@@ -1701,10 +1677,6 @@ export const ColorRmInput = {
1701
  this._clearRedoStack(); // Clear redo when adding new item
1702
  this.state.images[this.state.idx].history.push(shapeObj);
1703
  this.recordCreation(shapeObj);
1704
- // Update SOTA spatial index
1705
- if (this.sotaPerf) {
1706
- this.sotaPerf.addStroke(shapeObj);
1707
- }
1708
  this.saveCurrentImg(true);
1709
  if (this.liveSync && !this.liveSync.isInitializing) {
1710
  this.liveSync.addStroke(this.state.idx, shapeObj);
@@ -1720,10 +1692,6 @@ export const ColorRmInput = {
1720
  const newStroke = {id: Date.now() + Math.random(), lastMod: Date.now(), tool:this.state.tool, pts:this.currentStroke, color:this.state.penColor, size:this.state.tool==='eraser'?this.state.eraserSize:this.state.penSize, deleted: false};
1721
  this.state.images[this.state.idx].history.push(newStroke);
1722
  this.recordCreation(newStroke);
1723
- // Update SOTA spatial index for new stroke
1724
- if (this.sotaPerf) {
1725
- this.sotaPerf.addStroke(newStroke);
1726
- }
1727
  this.saveCurrentImg(true);
1728
  if (this.liveSync && !this.liveSync.isInitializing) {
1729
  this.liveSync.addStroke(this.state.idx, newStroke);
@@ -1797,10 +1765,6 @@ export const ColorRmInput = {
1797
  const st = img.history[i];
1798
  st.deleted = true;
1799
  st.lastMod = Date.now();
1800
- // Update SOTA spatial index
1801
- if (this.sotaPerf) {
1802
- this.sotaPerf.deleteStroke(st);
1803
- }
1804
  });
1805
 
1806
  this.invalidateCache();
@@ -2789,14 +2753,10 @@ export const ColorRmInput = {
2789
  existingItem.w = textWidth;
2790
  existingItem.h = textHeight;
2791
  existingItem.lastMod = Date.now();
2792
- // Update SOTA spatial index for modified text
2793
- if (this.sotaPerf) {
2794
- this.sotaPerf.modifyStroke(existingItem, { w: textWidth, h: textHeight });
2795
- }
2796
  } else {
2797
  // Add new text
2798
  this._clearRedoStack(); // Clear redo when adding new item
2799
- const newTextItem = {
2800
  id: Date.now() + Math.random(),
2801
  lastMod: Date.now(),
2802
  tool: 'text',
@@ -2808,12 +2768,7 @@ export const ColorRmInput = {
2808
  rotation: 0,
2809
  w: textWidth,
2810
  h: textHeight
2811
- };
2812
- img.history.push(newTextItem);
2813
- // Update SOTA spatial index for new text
2814
- if (this.sotaPerf) {
2815
- this.sotaPerf.addStroke(newTextItem);
2816
- }
2817
  // Select the new text
2818
  this.state.selection = [img.history.length - 1];
2819
  }
@@ -2918,19 +2873,11 @@ export const ColorRmInput = {
2918
  sortedSelection.forEach(i => {
2919
  history[i].deleted = true;
2920
  history[i].lastMod = Date.now();
2921
- // Update SOTA spatial index for deleted item
2922
- if (this.sotaPerf) {
2923
- this.sotaPerf.deleteStroke(history[i]);
2924
- }
2925
  });
2926
 
2927
  // Add group to history
2928
  this._clearRedoStack(); // Clear redo when adding new item
2929
  history.push(groupObj);
2930
- // Update SOTA spatial index for new group
2931
- if (this.sotaPerf) {
2932
- this.sotaPerf.addStroke(groupObj);
2933
- }
2934
 
2935
  // Select the new group
2936
  this.state.selection = [history.length - 1];
@@ -2996,20 +2943,12 @@ export const ColorRmInput = {
2996
  }
2997
 
2998
  history.push(newItem);
2999
- // Update SOTA spatial index for ungrouped item
3000
- if (this.sotaPerf) {
3001
- this.sotaPerf.addStroke(newItem);
3002
- }
3003
  newIndices.push(history.length - 1);
3004
  });
3005
 
3006
  // Mark group as deleted
3007
  groupObj.deleted = true;
3008
  groupObj.lastMod = Date.now();
3009
- // Update SOTA spatial index for deleted group
3010
- if (this.sotaPerf) {
3011
- this.sotaPerf.deleteStroke(groupObj);
3012
- }
3013
 
3014
  // Select the ungrouped items
3015
  this.state.selection = newIndices;
@@ -3479,10 +3418,6 @@ export const ColorRmInput = {
3479
  }
3480
 
3481
  img.history.push(newItem);
3482
- // Update SOTA spatial index for pasted item
3483
- if (this.sotaPerf) {
3484
- this.sotaPerf.addStroke(newItem);
3485
- }
3486
  newIndices.push(img.history.length - 1);
3487
 
3488
  // Sync with liveblocks
 
107
  // Undo Creation: Mark items as deleted
108
  // Save 'create' action to redo stack (Redo will un-delete)
109
  img.redoStack.push(action);
110
+
111
  action.ids.forEach(id => {
112
  const item = img.history.find(i => i.id === id);
113
  if (item) {
114
  item.deleted = true;
115
  item.lastMod = Date.now();
 
 
 
 
116
  }
117
  });
118
  } else if (action.type === 'modify') {
 
163
  if (action.type === 'create') {
164
  // Redo Creation: Mark items as NOT deleted
165
  img.undoStack.push(action);
166
+
167
  action.ids.forEach(id => {
168
  const item = img.history.find(i => i.id === id);
169
  if (item) {
170
  item.deleted = false;
171
  item.lastMod = Date.now();
 
 
 
 
172
  }
173
  });
174
  } else if (action.type === 'modify') {
 
289
  if (item) {
290
  item.deleted = true;
291
  item.lastMod = Date.now();
 
 
 
 
292
  }
293
  });
294
  this.state.selection = [];
 
314
  else { item.x+=20; item.y+=20; }
315
  }
316
  img.history.push(item);
 
 
 
 
317
  newIds.push(img.history.length-1);
318
  });
319
  if(cut) this.deleteSelected();
 
1312
  if (hit) {
1313
  // 1. Initialize array if this is the first item hit in this gesture
1314
  if (!this._eraserSessionState) this._eraserSessionState = [];
1315
+
1316
  // 2. Snapshot the state BEFORE deleting (if we haven't already for this stroke)
1317
  if (!this._eraserSessionState.some(item => item.idx === i)) {
1318
  this._eraserSessionState.push({
1319
  idx: i,
1320
  // JSON.stringify creates a deep copy of the object state (including pts, color, x, y, deleted=false)
1321
+ state: JSON.parse(JSON.stringify(st))
1322
  });
1323
  }
1324
 
1325
  // 3. Apply the deletion (mutation)
1326
  st.deleted = true;
1327
  st.lastMod = Date.now();
 
 
 
 
1328
  changed = true;
1329
  }
1330
  }
 
1644
 
1645
  this.state.images[this.state.idx].history.push(newShape);
1646
  this.recordCreation(newShape);
 
 
 
 
1647
  this.saveCurrentImg();
1648
  this.state.selection=[this.state.images[this.state.idx].history.length-1];
1649
  this.setTool('lasso');
 
1677
  this._clearRedoStack(); // Clear redo when adding new item
1678
  this.state.images[this.state.idx].history.push(shapeObj);
1679
  this.recordCreation(shapeObj);
 
 
 
 
1680
  this.saveCurrentImg(true);
1681
  if (this.liveSync && !this.liveSync.isInitializing) {
1682
  this.liveSync.addStroke(this.state.idx, shapeObj);
 
1692
  const newStroke = {id: Date.now() + Math.random(), lastMod: Date.now(), tool:this.state.tool, pts:this.currentStroke, color:this.state.penColor, size:this.state.tool==='eraser'?this.state.eraserSize:this.state.penSize, deleted: false};
1693
  this.state.images[this.state.idx].history.push(newStroke);
1694
  this.recordCreation(newStroke);
 
 
 
 
1695
  this.saveCurrentImg(true);
1696
  if (this.liveSync && !this.liveSync.isInitializing) {
1697
  this.liveSync.addStroke(this.state.idx, newStroke);
 
1765
  const st = img.history[i];
1766
  st.deleted = true;
1767
  st.lastMod = Date.now();
 
 
 
 
1768
  });
1769
 
1770
  this.invalidateCache();
 
2753
  existingItem.w = textWidth;
2754
  existingItem.h = textHeight;
2755
  existingItem.lastMod = Date.now();
 
 
 
 
2756
  } else {
2757
  // Add new text
2758
  this._clearRedoStack(); // Clear redo when adding new item
2759
+ img.history.push({
2760
  id: Date.now() + Math.random(),
2761
  lastMod: Date.now(),
2762
  tool: 'text',
 
2768
  rotation: 0,
2769
  w: textWidth,
2770
  h: textHeight
2771
+ });
 
 
 
 
 
2772
  // Select the new text
2773
  this.state.selection = [img.history.length - 1];
2774
  }
 
2873
  sortedSelection.forEach(i => {
2874
  history[i].deleted = true;
2875
  history[i].lastMod = Date.now();
 
 
 
 
2876
  });
2877
 
2878
  // Add group to history
2879
  this._clearRedoStack(); // Clear redo when adding new item
2880
  history.push(groupObj);
 
 
 
 
2881
 
2882
  // Select the new group
2883
  this.state.selection = [history.length - 1];
 
2943
  }
2944
 
2945
  history.push(newItem);
 
 
 
 
2946
  newIndices.push(history.length - 1);
2947
  });
2948
 
2949
  // Mark group as deleted
2950
  groupObj.deleted = true;
2951
  groupObj.lastMod = Date.now();
 
 
 
 
2952
 
2953
  // Select the ungrouped items
2954
  this.state.selection = newIndices;
 
3418
  }
3419
 
3420
  img.history.push(newItem);
 
 
 
 
3421
  newIndices.push(img.history.length - 1);
3422
 
3423
  // Sync with liveblocks
public/scripts/modules/ColorRmRenderer.js CHANGED
@@ -439,8 +439,7 @@ export const ColorRmRenderer = {
439
  const activeHistory = currentImg?.history?.filter(st => !st.deleted) || [];
440
 
441
  // SOTA v2: Use optimized rendering for large history (8000+ items)
442
- // TEMPORARILY DISABLED: Force render all strokes to debug visibility issues
443
- const useSOTAv2Rendering = false; // this.sotaPerf && activeHistory.length > 500;
444
 
445
  // HYBRID RENDERING: Use cache when idle, render live when interacting
446
  const isInteracting = this.isDragging || this.state.selection.length > 0;
 
439
  const activeHistory = currentImg?.history?.filter(st => !st.deleted) || [];
440
 
441
  // SOTA v2: Use optimized rendering for large history (8000+ items)
442
+ const useSOTAv2Rendering = this.sotaPerf && activeHistory.length > 500;
 
443
 
444
  // HYBRID RENDERING: Use cache when idle, render live when interacting
445
  const isInteracting = this.isDragging || this.state.selection.length > 0;
public/scripts/modules/ColorRmSOTAPerformance.js CHANGED
@@ -1064,8 +1064,8 @@ export class SOTAPerformanceManager {
1064
  this.incrementalSync.addStroke(stroke);
1065
  }
1066
 
1067
- // Update spatial index (insert even if index is empty)
1068
- if (this.config.useSpatialIndex && this.spatialIndex) {
1069
  const bounds = this._getItemBounds(stroke);
1070
  this.spatialIndex.insert(stroke, bounds);
1071
  }
 
1064
  this.incrementalSync.addStroke(stroke);
1065
  }
1066
 
1067
+ // Update spatial index
1068
+ if (this.config.useSpatialIndex && this.spatialIndex.itemCount > 0) {
1069
  const bounds = this._getItemBounds(stroke);
1070
  this.spatialIndex.insert(stroke, bounds);
1071
  }
public/scripts/modules/ColorRmStorage.js CHANGED
@@ -104,87 +104,216 @@ export const ColorRmStorage = {
104
  // Invalidate cache immediately since history changed in memory
105
  if (this.invalidateCache) this.invalidateCache();
106
 
107
- if (!this.state.sessionId) return;
 
 
 
 
 
 
 
 
108
 
109
- const currentPage = this.state.images[this.state.idx];
110
- if (!currentPage) return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
 
112
- await this.dbPut('pages', currentPage);
 
 
 
 
 
 
 
113
 
114
- // Check if sync is enabled
115
- if (!this.state.syncEnabled) {
116
- return;
117
  }
118
 
119
- if (skipRemoteSync || !this.liveSync || this.liveSync.isInitializing) {
120
- return;
121
- }
122
 
123
- const pageId = currentPage.pageId;
124
- if (!pageId) {
125
- console.warn('[saveCurrentImg] Page has no pageId, skipping sync');
126
- return;
127
- }
128
 
129
- const history = currentPage.history || [];
130
- const historySize = history.length;
 
 
131
 
132
- // Simple threshold: 500 items
133
- const R2_THRESHOLD = 500;
 
 
 
 
 
 
134
 
135
- if (historySize > R2_THRESHOLD) {
136
- // Large history - upload to R2, send key via Liveblocks
137
- this._uploadHistoryToR2(pageId, history);
138
- } else {
139
- // Small history - send directly via Liveblocks
140
- this.liveSync.setHistory(this.state.idx, history);
141
- }
 
 
 
 
 
 
 
 
 
 
 
142
  },
143
 
144
  /**
145
- * Upload history to R2 and notify Liveblocks with the R2 key
146
- * Simple approach: full history in R2, Liveblocks just has the reference
 
147
  */
148
- async _uploadHistoryToR2(pageId, history) {
149
- // Debounce to avoid spamming R2
150
- if (this._r2UploadTimers?.[pageId]) {
151
- clearTimeout(this._r2UploadTimers[pageId]);
 
 
 
 
152
  }
153
- if (!this._r2UploadTimers) this._r2UploadTimers = {};
154
 
155
- this._r2UploadTimers[pageId] = setTimeout(async () => {
 
 
 
 
 
 
 
 
156
  try {
 
157
  const historyUrl = window.Config?.apiUrl(`/api/color_rm/history/${this.state.sessionId}/${pageId}`)
158
  || `/api/color_rm/history/${this.state.sessionId}/${pageId}`;
159
 
160
- console.log(`[R2Upload] Uploading ${history.length} items for page ${pageId}`);
161
-
162
  const response = await fetchWithTimeout(historyUrl, {
163
  method: 'POST',
164
  headers: { 'Content-Type': 'application/json' },
165
- body: JSON.stringify(history)
166
  }, TIMEOUT.LONG);
167
 
168
  if (!response.ok) {
169
- throw new Error(`R2 upload failed: ${response.status}`);
170
  }
171
 
172
- const result = await response.json();
173
- console.log(`[R2Upload] Success: ${result.count} items stored`);
 
174
 
175
- // Tell Liveblocks to use R2 key instead of inline history
176
- this.liveSync.setHistoryR2Key(this.state.idx, {
177
- r2Key: pageId,
178
- count: history.length,
179
- timestamp: Date.now()
 
180
  });
181
 
 
182
  } catch (e) {
183
- console.error('[R2Upload] Failed:', e);
184
- // Fallback: try to send via Liveblocks anyway (may fail for large data)
185
- this.liveSync.setHistory(this.state.idx, history);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
  }
187
- }, 500); // 500ms debounce
 
 
188
  },
189
 
190
  // Debounced save - call this instead of saveCurrentImg for frequent updates
 
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
 
181
+ /**
182
+ * Sync large modifications to R2 instead of Liveblocks
183
+ * Used when modification count exceeds threshold
184
+ * Debounced to avoid too many uploads
185
+ */
186
+ async _syncLargeModificationsToR2(currentPage, deltas, modifications) {
187
+ const pageId = currentPage.pageId;
188
+ if (!pageId || !this.state.sessionId) return;
189
 
190
+ // Debounce R2 uploads (500ms)
191
+ if (this._r2ModUploadTimeout) {
192
+ clearTimeout(this._r2ModUploadTimeout);
193
  }
194
 
195
+ // Store pending data
196
+ this._pendingR2Mods = { currentPage, deltas, modifications };
 
197
 
198
+ this._r2ModUploadTimeout = setTimeout(async () => {
199
+ const { currentPage: page, deltas: d, modifications: m } = this._pendingR2Mods;
200
+ this._pendingR2Mods = null;
 
 
201
 
202
+ try {
203
+ // Upload modifications to R2
204
+ const modsUrl = window.Config?.apiUrl(`/api/color_rm/modifications/${this.state.sessionId}/${page.pageId}`)
205
+ || `/api/color_rm/modifications/${this.state.sessionId}/${page.pageId}`;
206
 
207
+ const response = await fetchWithTimeout(modsUrl, {
208
+ method: 'POST',
209
+ headers: { 'Content-Type': 'application/json' },
210
+ body: JSON.stringify({
211
+ modifications: m,
212
+ timestamp: Date.now()
213
+ })
214
+ }, TIMEOUT.MEDIUM);
215
 
216
+ if (!response.ok) {
217
+ throw new Error(`Upload failed: ${response.status}`);
218
+ }
219
+
220
+ // Only sync deltas via Liveblocks (usually small)
221
+ // Also notify that modifications are in R2
222
+ this.liveSync.syncPageDeltas(this.state.idx, d, {});
223
+ this.liveSync.updatePageMetadata(this.state.idx, {
224
+ hasR2Modifications: true,
225
+ r2ModTimestamp: Date.now()
226
+ });
227
+
228
+ console.log(`[_syncLargeModificationsToR2] Uploaded ${Object.keys(m).length} modifications to R2`);
229
+ } catch (e) {
230
+ console.error('[_syncLargeModificationsToR2] Failed:', e);
231
+ this.ui.showToast('Sync failed - modifications too large');
232
+ }
233
+ }, 500);
234
  },
235
 
236
  /**
237
+ * Sync large history to R2 with only a reference in Liveblocks
238
+ * Used for pages with very many strokes
239
+ * Debounced to avoid excessive uploads
240
  */
241
+ async _syncLargeHistoryToR2(currentPage) {
242
+ const pageId = currentPage.pageId;
243
+ if (!pageId || !this.state.sessionId) return;
244
+
245
+ // Prevent duplicate uploads
246
+ if (this._r2HistoryUploading) {
247
+ console.log('[_syncLargeHistoryToR2] Upload already in progress, skipping');
248
+ return;
249
  }
 
250
 
251
+ // Debounce R2 history uploads (1s)
252
+ if (this._r2HistoryUploadTimeout) {
253
+ clearTimeout(this._r2HistoryUploadTimeout);
254
+ }
255
+
256
+ this._r2HistoryUploadTimeout = setTimeout(async () => {
257
+ // Mark as uploading to prevent duplicates
258
+ this._r2HistoryUploading = true;
259
+
260
  try {
261
+ // Upload full history to R2
262
  const historyUrl = window.Config?.apiUrl(`/api/color_rm/history/${this.state.sessionId}/${pageId}`)
263
  || `/api/color_rm/history/${this.state.sessionId}/${pageId}`;
264
 
 
 
265
  const response = await fetchWithTimeout(historyUrl, {
266
  method: 'POST',
267
  headers: { 'Content-Type': 'application/json' },
268
+ body: JSON.stringify(currentPage.history)
269
  }, TIMEOUT.LONG);
270
 
271
  if (!response.ok) {
272
+ throw new Error(`Upload failed: ${response.status}`);
273
  }
274
 
275
+ // Mark page as having base history in R2
276
+ currentPage.hasBaseHistory = true;
277
+ currentPage._baseHistory = [...currentPage.history];
278
 
279
+ // Notify via Liveblocks (metadata only)
280
+ this.liveSync.setHistory(this.state.idx, []); // Clear Liveblocks history
281
+ this.liveSync.updatePageMetadata(this.state.idx, {
282
+ hasBaseHistory: true,
283
+ baseHistoryCount: currentPage.history.length,
284
+ r2SyncTimestamp: Date.now()
285
  });
286
 
287
+ console.log(`[_syncLargeHistoryToR2] Uploaded ${currentPage.history.length} items to R2`);
288
  } catch (e) {
289
+ console.error('[_syncLargeHistoryToR2] Failed:', e);
290
+ this.ui.showToast('Sync failed - history too large');
291
+ } finally {
292
+ this._r2HistoryUploading = false;
293
+ }
294
+ }, 1000);
295
+ },
296
+
297
+ // Check if an item has been modified from its base version
298
+ _isItemModified(baseItem, currentItem) {
299
+ // Check key properties that indicate modification
300
+ if (currentItem.deleted !== baseItem.deleted) return true;
301
+ if (currentItem.x !== baseItem.x || currentItem.y !== baseItem.y) return true;
302
+ if (currentItem.w !== baseItem.w || currentItem.h !== baseItem.h) return true;
303
+ if (currentItem.rotation !== baseItem.rotation) return true;
304
+
305
+ // For pen strokes, check if points changed
306
+ if (currentItem.pts && baseItem.pts) {
307
+ if (currentItem.pts.length !== baseItem.pts.length) return true;
308
+ // Quick check: compare first and last points
309
+ if (currentItem.pts.length > 0) {
310
+ const first = currentItem.pts[0];
311
+ const basefirst = baseItem.pts[0];
312
+ if (first.x !== basefirst.x || first.y !== basefirst.y) return true;
313
  }
314
+ }
315
+
316
+ return false;
317
  },
318
 
319
  // Debounced save - call this instead of saveCurrentImg for frequent updates