Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files
.build_counter
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
|
|
|
|
| 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
|
| 2143 |
-
|
| 2144 |
-
//
|
| 2145 |
-
const
|
| 2146 |
-
|
| 2147 |
-
|
| 2148 |
-
|
| 2149 |
-
|
| 2150 |
-
|
| 2151 |
-
const
|
| 2152 |
-
|
| 2153 |
-
|
| 2154 |
-
|
| 2155 |
-
|
| 2156 |
-
|
| 2157 |
-
|
| 2158 |
-
|
| 2159 |
-
|
| 2160 |
-
|
| 2161 |
-
|
| 2162 |
-
|
| 2163 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2164 |
// CRDT merge: combine local and remote history
|
| 2165 |
const mergedHistory = this._crdtMergeHistory(
|
| 2166 |
localImg.history || [],
|
| 2167 |
-
|
| 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 (
|
| 2187 |
-
this.app.sotaPerf.initialize(
|
| 2188 |
}
|
| 2189 |
|
| 2190 |
// Auto-recalculate infinite canvas bounds from synced strokes
|
| 2191 |
-
if (
|
| 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 |
-
|
|
|
|
|
|
|
| 2202 |
|
| 2203 |
-
|
| 2204 |
-
this.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2205 |
}
|
| 2206 |
});
|
| 2207 |
|
| 2208 |
if (currentIdxChanged) this.app.render();
|
| 2209 |
|
| 2210 |
-
// Fetch
|
| 2211 |
-
if (
|
| 2212 |
-
this.
|
| 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 |
-
|
| 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 |
-
|
| 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
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
|
| 109 |
-
|
| 110 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
|
| 112 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
|
| 114 |
-
//
|
| 115 |
-
if (
|
| 116 |
-
|
| 117 |
}
|
| 118 |
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
}
|
| 122 |
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
return;
|
| 127 |
-
}
|
| 128 |
|
| 129 |
-
|
| 130 |
-
|
|
|
|
|
|
|
| 131 |
|
| 132 |
-
|
| 133 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
},
|
| 143 |
|
| 144 |
/**
|
| 145 |
-
*
|
| 146 |
-
*
|
|
|
|
| 147 |
*/
|
| 148 |
-
async
|
| 149 |
-
|
| 150 |
-
if (this.
|
| 151 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
}
|
| 153 |
-
if (!this._r2UploadTimers) this._r2UploadTimers = {};
|
| 154 |
|
| 155 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(`
|
| 170 |
}
|
| 171 |
|
| 172 |
-
|
| 173 |
-
|
|
|
|
| 174 |
|
| 175 |
-
//
|
| 176 |
-
this.liveSync.
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
|
|
|
| 180 |
});
|
| 181 |
|
|
|
|
| 182 |
} catch (e) {
|
| 183 |
-
console.error('[
|
| 184 |
-
|
| 185 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
}
|
| 187 |
-
}
|
|
|
|
|
|
|
| 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
|