Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- .build_counter +1 -1
- public/scripts/ColorRmApp.js +0 -6
- public/scripts/LiveSync.js +110 -580
- public/scripts/modules/ColorRmStorage.js +65 -268
- worker/colorRmAssets.ts +10 -65
.build_counter
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
|
|
|
|
| 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
|
| 2246 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2247 |
|
| 2248 |
-
|
| 2249 |
-
|
| 2250 |
-
|
| 2251 |
-
|
| 2252 |
-
|
| 2253 |
-
//
|
| 2254 |
-
|
|
|
|
|
|
|
| 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 ===
|
| 2262 |
-
|
| 2263 |
-
if (
|
| 2264 |
-
|
| 2265 |
-
|
| 2266 |
-
|
| 2267 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2268 |
|
| 2269 |
-
|
| 2270 |
-
|
| 2271 |
-
|
|
|
|
| 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(
|
| 2520 |
-
console.log(`[LiveSync] Fetching base history for ${
|
| 2521 |
|
| 2522 |
-
for (const
|
| 2523 |
-
|
| 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 |
-
|
| 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
|
| 172 |
-
|
| 173 |
-
|
| 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 |
-
//
|
| 240 |
-
this.
|
| 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 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
const
|
| 310 |
-
|
| 311 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 417 |
-
|
| 418 |
const parsed = JSON.parse(body)
|
| 419 |
-
|
| 420 |
-
|
| 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,
|
| 451 |
httpMetadata: { contentType: 'application/json' }
|
| 452 |
})
|
| 453 |
-
console.log(`[HistoryUpload] Stored
|
| 454 |
-
return new Response(JSON.stringify({ success: true,
|
| 455 |
status: 200,
|
| 456 |
headers: { 'Content-Type': 'application/json' }
|
| 457 |
})
|
| 458 |
} catch (e: any) {
|
| 459 |
-
console.error('
|
| 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
|
| 480 |
-
|
| 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('
|
| 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 |
}
|