Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- .build_counter +1 -1
- public/scripts/LiveSync.js +12 -2
- public/scripts/UI.js +71 -2
- public/scripts/modules/ColorRmExport.js +6 -1
- public/scripts/modules/ColorRmInput.js +28 -6
- public/scripts/modules/ColorRmRenderer.js +38 -3
- public/scripts/modules/ColorRmSession.js +14 -5
- public/scripts/modules/ColorRmStorage.js +60 -7
- public/scripts/modules/NetworkUtils.js +159 -0
.build_counter
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
|
|
|
|
| 1 |
+
21
|
public/scripts/LiveSync.js
CHANGED
|
@@ -210,9 +210,15 @@ export class LiveSyncClient {
|
|
| 210 |
if (typeof event.data === 'string') {
|
| 211 |
const msg = JSON.parse(event.data);
|
| 212 |
this._handleYjsMessage(msg);
|
|
|
|
|
|
|
|
|
|
| 213 |
}
|
| 214 |
} catch (err) {
|
| 215 |
-
|
|
|
|
|
|
|
|
|
|
| 216 |
}
|
| 217 |
};
|
| 218 |
|
|
@@ -401,7 +407,11 @@ export class LiveSyncClient {
|
|
| 401 |
color: msg.color,
|
| 402 |
size: msg.size
|
| 403 |
});
|
| 404 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 405 |
|
| 406 |
// Follow mode: Navigate to the same page as the other user
|
| 407 |
// (Skip if this is an echo of our own change or if we recently changed pages)
|
|
|
|
| 210 |
if (typeof event.data === 'string') {
|
| 211 |
const msg = JSON.parse(event.data);
|
| 212 |
this._handleYjsMessage(msg);
|
| 213 |
+
} else if (event.data instanceof Blob) {
|
| 214 |
+
// Binary message - could be Yjs update
|
| 215 |
+
// Ignore for now as we use JSON-based sync
|
| 216 |
}
|
| 217 |
} catch (err) {
|
| 218 |
+
// Only log actual parse errors, not empty objects
|
| 219 |
+
if (event.data && event.data.length > 2) {
|
| 220 |
+
console.warn('[Yjs] Message parse error:', err.message);
|
| 221 |
+
}
|
| 222 |
}
|
| 223 |
};
|
| 224 |
|
|
|
|
| 407 |
color: msg.color,
|
| 408 |
size: msg.size
|
| 409 |
});
|
| 410 |
+
// Throttle presence logging to reduce console spam
|
| 411 |
+
if (!this._lastPresenceLogTime || Date.now() - this._lastPresenceLogTime > 2000) {
|
| 412 |
+
console.log(`[Yjs] Presence from ${msg.clientId}: page ${msg.pageIdx}`);
|
| 413 |
+
this._lastPresenceLogTime = Date.now();
|
| 414 |
+
}
|
| 415 |
|
| 416 |
// Follow mode: Navigate to the same page as the other user
|
| 417 |
// (Skip if this is an echo of our own change or if we recently changed pages)
|
public/scripts/UI.js
CHANGED
|
@@ -1,8 +1,41 @@
|
|
| 1 |
export const UI = {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
showDashboard: () => {
|
| 3 |
const db = document.getElementById('dashboardModal');
|
| 4 |
if (db) db.style.display='flex';
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
},
|
| 7 |
hideDashboard: () => {
|
| 8 |
const db = document.getElementById('dashboardModal');
|
|
@@ -11,7 +44,17 @@ export const UI = {
|
|
| 11 |
showExportModal: () => {
|
| 12 |
const em = document.getElementById('exportModal');
|
| 13 |
if (em) em.style.display='flex';
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
// Load persisted export preferences
|
| 17 |
try {
|
|
@@ -117,10 +160,21 @@ export const UI = {
|
|
| 117 |
if (msgEl) msgEl.innerText = message;
|
| 118 |
modal.style.display = 'flex';
|
| 119 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
const cleanup = () => {
|
| 121 |
modal.style.display = 'none';
|
| 122 |
okBtn.onclick = null;
|
| 123 |
cancelBtn.onclick = null;
|
|
|
|
| 124 |
};
|
| 125 |
|
| 126 |
okBtn.onclick = () => {
|
|
@@ -133,6 +187,9 @@ export const UI = {
|
|
| 133 |
if (onCancel) onCancel();
|
| 134 |
resolve(false);
|
| 135 |
};
|
|
|
|
|
|
|
|
|
|
| 136 |
});
|
| 137 |
},
|
| 138 |
showAlert: (title, message) => {
|
|
@@ -162,10 +219,22 @@ export const UI = {
|
|
| 162 |
if (msgEl) msgEl.innerText = message;
|
| 163 |
modal.style.display = 'flex';
|
| 164 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
okBtn.onclick = () => {
|
| 166 |
modal.style.display = 'none';
|
|
|
|
| 167 |
resolve();
|
| 168 |
};
|
|
|
|
|
|
|
|
|
|
| 169 |
});
|
| 170 |
},
|
| 171 |
showPrompt: (title, placeholder, defaultValue = '') => {
|
|
|
|
| 1 |
export const UI = {
|
| 2 |
+
// Mini loading indicator for quick operations (non-blocking)
|
| 3 |
+
showMiniLoader: (text = 'Loading...') => {
|
| 4 |
+
let mini = document.getElementById('miniLoader');
|
| 5 |
+
if (!mini) {
|
| 6 |
+
mini = document.createElement('div');
|
| 7 |
+
mini.id = 'miniLoader';
|
| 8 |
+
mini.style.cssText = 'position:fixed;top:10px;right:10px;background:rgba(0,0,0,0.8);color:white;padding:8px 16px;border-radius:6px;font-size:13px;z-index:9999;display:flex;align-items:center;gap:8px;';
|
| 9 |
+
mini.innerHTML = '<span class="spinner" style="width:14px;height:14px;border:2px solid rgba(255,255,255,0.3);border-top-color:white;border-radius:50%;animation:spin 0.8s linear infinite;"></span><span id="miniLoaderText"></span>';
|
| 10 |
+
// Add spinner animation if not exists
|
| 11 |
+
if (!document.getElementById('miniLoaderStyle')) {
|
| 12 |
+
const style = document.createElement('style');
|
| 13 |
+
style.id = 'miniLoaderStyle';
|
| 14 |
+
style.textContent = '@keyframes spin{to{transform:rotate(360deg)}}';
|
| 15 |
+
document.head.appendChild(style);
|
| 16 |
+
}
|
| 17 |
+
document.body.appendChild(mini);
|
| 18 |
+
}
|
| 19 |
+
const textEl = mini.querySelector('#miniLoaderText');
|
| 20 |
+
if (textEl) textEl.textContent = text;
|
| 21 |
+
mini.style.display = 'flex';
|
| 22 |
+
},
|
| 23 |
+
|
| 24 |
+
hideMiniLoader: () => {
|
| 25 |
+
const mini = document.getElementById('miniLoader');
|
| 26 |
+
if (mini) mini.style.display = 'none';
|
| 27 |
+
},
|
| 28 |
+
|
| 29 |
showDashboard: () => {
|
| 30 |
const db = document.getElementById('dashboardModal');
|
| 31 |
if (db) db.style.display='flex';
|
| 32 |
+
// Show loading indicator while loading sessions
|
| 33 |
+
UI.showMiniLoader('Loading projects...');
|
| 34 |
+
if (window.App && window.App.loadSessionList) {
|
| 35 |
+
window.App.loadSessionList().finally(() => UI.hideMiniLoader());
|
| 36 |
+
} else {
|
| 37 |
+
UI.hideMiniLoader();
|
| 38 |
+
}
|
| 39 |
},
|
| 40 |
hideDashboard: () => {
|
| 41 |
const db = document.getElementById('dashboardModal');
|
|
|
|
| 44 |
showExportModal: () => {
|
| 45 |
const em = document.getElementById('exportModal');
|
| 46 |
if (em) em.style.display='flex';
|
| 47 |
+
// Show loading indicator while preparing export grid
|
| 48 |
+
UI.showMiniLoader('Loading pages...');
|
| 49 |
+
if (window.App && window.App.renderDlGrid) {
|
| 50 |
+
try {
|
| 51 |
+
window.App.renderDlGrid();
|
| 52 |
+
} finally {
|
| 53 |
+
UI.hideMiniLoader();
|
| 54 |
+
}
|
| 55 |
+
} else {
|
| 56 |
+
UI.hideMiniLoader();
|
| 57 |
+
}
|
| 58 |
|
| 59 |
// Load persisted export preferences
|
| 60 |
try {
|
|
|
|
| 160 |
if (msgEl) msgEl.innerText = message;
|
| 161 |
modal.style.display = 'flex';
|
| 162 |
|
| 163 |
+
// Keyboard handler for accessibility
|
| 164 |
+
const keyHandler = (e) => {
|
| 165 |
+
if (e.key === 'Escape') {
|
| 166 |
+
cancelBtn.click();
|
| 167 |
+
} else if (e.key === 'Enter') {
|
| 168 |
+
okBtn.click();
|
| 169 |
+
}
|
| 170 |
+
};
|
| 171 |
+
document.addEventListener('keydown', keyHandler);
|
| 172 |
+
|
| 173 |
const cleanup = () => {
|
| 174 |
modal.style.display = 'none';
|
| 175 |
okBtn.onclick = null;
|
| 176 |
cancelBtn.onclick = null;
|
| 177 |
+
document.removeEventListener('keydown', keyHandler);
|
| 178 |
};
|
| 179 |
|
| 180 |
okBtn.onclick = () => {
|
|
|
|
| 187 |
if (onCancel) onCancel();
|
| 188 |
resolve(false);
|
| 189 |
};
|
| 190 |
+
|
| 191 |
+
// Focus confirm button for keyboard access
|
| 192 |
+
okBtn.focus();
|
| 193 |
});
|
| 194 |
},
|
| 195 |
showAlert: (title, message) => {
|
|
|
|
| 219 |
if (msgEl) msgEl.innerText = message;
|
| 220 |
modal.style.display = 'flex';
|
| 221 |
|
| 222 |
+
// Keyboard handler for accessibility
|
| 223 |
+
const keyHandler = (e) => {
|
| 224 |
+
if (e.key === 'Escape' || e.key === 'Enter') {
|
| 225 |
+
okBtn.click();
|
| 226 |
+
}
|
| 227 |
+
};
|
| 228 |
+
document.addEventListener('keydown', keyHandler);
|
| 229 |
+
|
| 230 |
okBtn.onclick = () => {
|
| 231 |
modal.style.display = 'none';
|
| 232 |
+
document.removeEventListener('keydown', keyHandler);
|
| 233 |
resolve();
|
| 234 |
};
|
| 235 |
+
|
| 236 |
+
// Focus OK button for keyboard access
|
| 237 |
+
okBtn.focus();
|
| 238 |
});
|
| 239 |
},
|
| 240 |
showPrompt: (title, placeholder, defaultValue = '') => {
|
public/scripts/modules/ColorRmExport.js
CHANGED
|
@@ -28,7 +28,12 @@ export const ColorRmExport = {
|
|
| 28 |
this.state.dlSelection.sort((a,b)=>a-b);
|
| 29 |
el.className = `thumb-item ${this.state.dlSelection.includes(i)?'selected':''}`;
|
| 30 |
};
|
| 31 |
-
const im = new Image();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
el.appendChild(im);
|
| 33 |
const sp = document.createElement('span'); sp.innerText = i+1; el.appendChild(sp);
|
| 34 |
g.appendChild(el);
|
|
|
|
| 28 |
this.state.dlSelection.sort((a,b)=>a-b);
|
| 29 |
el.className = `thumb-item ${this.state.dlSelection.includes(i)?'selected':''}`;
|
| 30 |
};
|
| 31 |
+
const im = new Image();
|
| 32 |
+
const blobUrl = URL.createObjectURL(img.blob);
|
| 33 |
+
im.src = blobUrl;
|
| 34 |
+
// Revoke blob URL after image loads to prevent memory leak
|
| 35 |
+
im.onload = () => URL.revokeObjectURL(blobUrl);
|
| 36 |
+
im.onerror = () => URL.revokeObjectURL(blobUrl);
|
| 37 |
el.appendChild(im);
|
| 38 |
const sp = document.createElement('span'); sp.innerText = i+1; el.appendChild(sp);
|
| 39 |
g.appendChild(el);
|
public/scripts/modules/ColorRmInput.js
CHANGED
|
@@ -375,14 +375,32 @@ export const ColorRmInput = {
|
|
| 375 |
let isDragging = false; let startX, startY, initLeft, initTop;
|
| 376 |
const handle = this.getElement('pickerDragHandle');
|
| 377 |
if(handle) {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 378 |
handle.onmousedown = (e) => { isDragging = true; startX = e.clientX; startY = e.clientY; const r = el.getBoundingClientRect(); initLeft = r.left; initTop = r.top; };
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 383 |
}
|
| 384 |
},
|
| 385 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 386 |
setupShortcuts() {
|
| 387 |
const target = this.container || document;
|
| 388 |
|
|
@@ -1335,10 +1353,12 @@ export const ColorRmInput = {
|
|
| 1335 |
};
|
| 1336 |
|
| 1337 |
window.addEventListener('pointermove', onPointerMove, { passive: true });
|
|
|
|
|
|
|
| 1338 |
|
| 1339 |
// Only main app listens to window resize for cursor re-rendering
|
| 1340 |
if (this.config.isMain) {
|
| 1341 |
-
|
| 1342 |
if (this.liveSync && this.liveSync.renderCursors) this.liveSync.renderCursors();
|
| 1343 |
|
| 1344 |
// Update infinite canvas dimensions on resize
|
|
@@ -1355,7 +1375,9 @@ export const ColorRmInput = {
|
|
| 1355 |
this.render();
|
| 1356 |
}
|
| 1357 |
}
|
| 1358 |
-
}
|
|
|
|
|
|
|
| 1359 |
}
|
| 1360 |
const vp = this.getElement('viewport');
|
| 1361 |
if(vp) vp.addEventListener('scroll', () => this.liveSync && this.liveSync.renderCursors && this.liveSync.renderCursors(), { passive: true });
|
|
|
|
| 375 |
let isDragging = false; let startX, startY, initLeft, initTop;
|
| 376 |
const handle = this.getElement('pickerDragHandle');
|
| 377 |
if(handle) {
|
| 378 |
+
// Store event handlers for cleanup
|
| 379 |
+
const onMouseMove = (e) => { if(!isDragging) return; el.style.left = (initLeft + (e.clientX - startX)) + 'px'; el.style.top = (initTop + (e.clientY - startY)) + 'px'; };
|
| 380 |
+
const onMouseUp = () => isDragging = false;
|
| 381 |
+
|
| 382 |
handle.onmousedown = (e) => { isDragging = true; startX = e.clientX; startY = e.clientY; const r = el.getBoundingClientRect(); initLeft = r.left; initTop = r.top; };
|
| 383 |
+
document.addEventListener('mousemove', onMouseMove);
|
| 384 |
+
document.addEventListener('mouseup', onMouseUp);
|
| 385 |
+
|
| 386 |
+
// Store cleanup function for later
|
| 387 |
+
this._draggableCleanup = () => {
|
| 388 |
+
document.removeEventListener('mousemove', onMouseMove);
|
| 389 |
+
document.removeEventListener('mouseup', onMouseUp);
|
| 390 |
+
};
|
| 391 |
}
|
| 392 |
},
|
| 393 |
|
| 394 |
+
// Cleanup event listeners (call when destroying app instance)
|
| 395 |
+
cleanup() {
|
| 396 |
+
if (this._draggableCleanup) this._draggableCleanup();
|
| 397 |
+
if (this._pointerMoveCleanup) this._pointerMoveCleanup();
|
| 398 |
+
if (this._pointerUpCleanup) this._pointerUpCleanup();
|
| 399 |
+
if (this._resizeCleanup) this._resizeCleanup();
|
| 400 |
+
if (this._spenEngineCleanup) this._spenEngineCleanup();
|
| 401 |
+
console.log('[ColorRmInput] Event listeners cleaned up');
|
| 402 |
+
},
|
| 403 |
+
|
| 404 |
setupShortcuts() {
|
| 405 |
const target = this.container || document;
|
| 406 |
|
|
|
|
| 1353 |
};
|
| 1354 |
|
| 1355 |
window.addEventListener('pointermove', onPointerMove, { passive: true });
|
| 1356 |
+
// Store cleanup function
|
| 1357 |
+
this._pointerMoveCleanup = () => window.removeEventListener('pointermove', onPointerMove);
|
| 1358 |
|
| 1359 |
// Only main app listens to window resize for cursor re-rendering
|
| 1360 |
if (this.config.isMain) {
|
| 1361 |
+
const onResize = () => {
|
| 1362 |
if (this.liveSync && this.liveSync.renderCursors) this.liveSync.renderCursors();
|
| 1363 |
|
| 1364 |
// Update infinite canvas dimensions on resize
|
|
|
|
| 1375 |
this.render();
|
| 1376 |
}
|
| 1377 |
}
|
| 1378 |
+
};
|
| 1379 |
+
window.addEventListener('resize', onResize);
|
| 1380 |
+
this._resizeCleanup = () => window.removeEventListener('resize', onResize);
|
| 1381 |
}
|
| 1382 |
const vp = this.getElement('viewport');
|
| 1383 |
if(vp) vp.addEventListener('scroll', () => this.liveSync && this.liveSync.renderCursors && this.liveSync.renderCursors(), { passive: true });
|
public/scripts/modules/ColorRmRenderer.js
CHANGED
|
@@ -1036,28 +1036,63 @@ export const ColorRmRenderer = {
|
|
| 1036 |
return { minX, minY, maxX, maxY, w: maxX - minX, h: maxY - minY };
|
| 1037 |
},
|
| 1038 |
|
| 1039 |
-
// Image cache for loaded images
|
| 1040 |
_imageCache: new Map(),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1041 |
|
| 1042 |
// Helper to render images (v2 feature)
|
| 1043 |
_renderImage(ctx, st) {
|
| 1044 |
const cacheKey = st.src;
|
| 1045 |
let img = this._imageCache.get(cacheKey);
|
| 1046 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1047 |
if (!img) {
|
| 1048 |
// Load image asynchronously
|
| 1049 |
img = new Image();
|
| 1050 |
img.onload = () => {
|
| 1051 |
-
this.
|
| 1052 |
img._loaded = true;
|
| 1053 |
// Trigger re-render
|
| 1054 |
this.invalidateCache();
|
| 1055 |
};
|
| 1056 |
img.onerror = () => {
|
| 1057 |
console.warn('Failed to load image:', st.src?.substring(0, 50));
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1058 |
};
|
| 1059 |
img.src = st.src;
|
| 1060 |
-
this.
|
| 1061 |
return; // Don't render until loaded
|
| 1062 |
}
|
| 1063 |
|
|
|
|
| 1036 |
return { minX, minY, maxX, maxY, w: maxX - minX, h: maxY - minY };
|
| 1037 |
},
|
| 1038 |
|
| 1039 |
+
// Image cache for loaded images with size limit to prevent memory leaks
|
| 1040 |
_imageCache: new Map(),
|
| 1041 |
+
_imageCacheOrder: [], // LRU tracking
|
| 1042 |
+
_imageCacheMaxSize: 50, // Maximum cached images
|
| 1043 |
+
|
| 1044 |
+
// Manage image cache with LRU eviction
|
| 1045 |
+
_addToImageCache(key, img) {
|
| 1046 |
+
// Remove oldest entries if at limit
|
| 1047 |
+
while (this._imageCache.size >= this._imageCacheMaxSize && this._imageCacheOrder.length > 0) {
|
| 1048 |
+
const oldestKey = this._imageCacheOrder.shift();
|
| 1049 |
+
this._imageCache.delete(oldestKey);
|
| 1050 |
+
}
|
| 1051 |
+
this._imageCache.set(key, img);
|
| 1052 |
+
this._imageCacheOrder.push(key);
|
| 1053 |
+
},
|
| 1054 |
+
|
| 1055 |
+
_touchImageCache(key) {
|
| 1056 |
+
const idx = this._imageCacheOrder.indexOf(key);
|
| 1057 |
+
if (idx > -1) {
|
| 1058 |
+
this._imageCacheOrder.splice(idx, 1);
|
| 1059 |
+
this._imageCacheOrder.push(key);
|
| 1060 |
+
}
|
| 1061 |
+
},
|
| 1062 |
+
|
| 1063 |
+
// Clear image cache (call when switching pages or low memory)
|
| 1064 |
+
clearImageCache() {
|
| 1065 |
+
this._imageCache.clear();
|
| 1066 |
+
this._imageCacheOrder = [];
|
| 1067 |
+
},
|
| 1068 |
|
| 1069 |
// Helper to render images (v2 feature)
|
| 1070 |
_renderImage(ctx, st) {
|
| 1071 |
const cacheKey = st.src;
|
| 1072 |
let img = this._imageCache.get(cacheKey);
|
| 1073 |
|
| 1074 |
+
if (img) {
|
| 1075 |
+
this._touchImageCache(cacheKey); // Update LRU
|
| 1076 |
+
}
|
| 1077 |
+
|
| 1078 |
if (!img) {
|
| 1079 |
// Load image asynchronously
|
| 1080 |
img = new Image();
|
| 1081 |
img.onload = () => {
|
| 1082 |
+
this._addToImageCache(cacheKey, img);
|
| 1083 |
img._loaded = true;
|
| 1084 |
// Trigger re-render
|
| 1085 |
this.invalidateCache();
|
| 1086 |
};
|
| 1087 |
img.onerror = () => {
|
| 1088 |
console.warn('Failed to load image:', st.src?.substring(0, 50));
|
| 1089 |
+
// Show error to user for important images
|
| 1090 |
+
if (this.ui && this.ui.showToast) {
|
| 1091 |
+
this.ui.showToast('Failed to load image');
|
| 1092 |
+
}
|
| 1093 |
};
|
| 1094 |
img.src = st.src;
|
| 1095 |
+
this._addToImageCache(cacheKey, img);
|
| 1096 |
return; // Don't render until loaded
|
| 1097 |
}
|
| 1098 |
|
public/scripts/modules/ColorRmSession.js
CHANGED
|
@@ -1744,9 +1744,11 @@ export const ColorRmSession = {
|
|
| 1744 |
for (let j = 0; j < BATCH_SIZE && (i + j) <= pdf.numPages; j++) {
|
| 1745 |
const pNum = i + j;
|
| 1746 |
batch.push(pdf.getPage(pNum).then(async page => {
|
|
|
|
|
|
|
| 1747 |
const v = page.getViewport({
|
| 1748 |
-
scale:
|
| 1749 |
-
});
|
| 1750 |
const cvs = document.createElement('canvas');
|
| 1751 |
cvs.width = v.width;
|
| 1752 |
cvs.height = v.height;
|
|
@@ -1754,7 +1756,8 @@ export const ColorRmSession = {
|
|
| 1754 |
canvasContext: cvs.getContext('2d'),
|
| 1755 |
viewport: v
|
| 1756 |
}).promise;
|
| 1757 |
-
|
|
|
|
| 1758 |
const pageIdx = idx + j;
|
| 1759 |
const pageId = this._generatePageId('pdf', pNum - 1); // PDF pages are 1-indexed, we want 0-indexed
|
| 1760 |
const pageObj = {
|
|
@@ -1766,8 +1769,14 @@ export const ColorRmSession = {
|
|
| 1766 |
history: []
|
| 1767 |
};
|
| 1768 |
await this.dbPut('pages', pageObj);
|
| 1769 |
-
|
| 1770 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1771 |
return pageObj;
|
| 1772 |
}));
|
| 1773 |
}
|
|
|
|
| 1744 |
for (let j = 0; j < BATCH_SIZE && (i + j) <= pdf.numPages; j++) {
|
| 1745 |
const pNum = i + j;
|
| 1746 |
batch.push(pdf.getPage(pNum).then(async page => {
|
| 1747 |
+
// 3x DPI (216 DPI) for high-quality rasterization
|
| 1748 |
+
const PDF_RENDER_SCALE = 3.0;
|
| 1749 |
const v = page.getViewport({
|
| 1750 |
+
scale: PDF_RENDER_SCALE
|
| 1751 |
+
});
|
| 1752 |
const cvs = document.createElement('canvas');
|
| 1753 |
cvs.width = v.width;
|
| 1754 |
cvs.height = v.height;
|
|
|
|
| 1756 |
canvasContext: cvs.getContext('2d'),
|
| 1757 |
viewport: v
|
| 1758 |
}).promise;
|
| 1759 |
+
// High quality JPEG (0.92) for good compression with minimal artifacts
|
| 1760 |
+
const b = await new Promise(r => cvs.toBlob(r, 'image/jpeg', 0.92));
|
| 1761 |
const pageIdx = idx + j;
|
| 1762 |
const pageId = this._generatePageId('pdf', pNum - 1); // PDF pages are 1-indexed, we want 0-indexed
|
| 1763 |
const pageObj = {
|
|
|
|
| 1769 |
history: []
|
| 1770 |
};
|
| 1771 |
await this.dbPut('pages', pageObj);
|
| 1772 |
+
|
| 1773 |
+
// Upload rasterized PDF page to R2 for multi-user sync
|
| 1774 |
+
try {
|
| 1775 |
+
await this._uploadPageBlob(pageId, b);
|
| 1776 |
+
} catch (uploadErr) {
|
| 1777 |
+
console.warn(`[PDF Import] Failed to upload page ${pageId} to R2:`, uploadErr);
|
| 1778 |
+
}
|
| 1779 |
+
|
| 1780 |
return pageObj;
|
| 1781 |
}));
|
| 1782 |
}
|
public/scripts/modules/ColorRmStorage.js
CHANGED
|
@@ -1,9 +1,54 @@
|
|
|
|
|
|
|
|
| 1 |
export const ColorRmStorage = {
|
| 2 |
-
async dbPut(s, v) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
-
async dbGet(s, k) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
-
async dbGetAll(s) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
async saveSessionState() {
|
| 9 |
if(!this.state.sessionId || (this.liveSync && this.liveSync.isInitializing) || this.isUploading) return;
|
|
@@ -159,14 +204,18 @@ export const ColorRmStorage = {
|
|
| 159 |
const modsUrl = window.Config?.apiUrl(`/api/color_rm/modifications/${this.state.sessionId}/${page.pageId}`)
|
| 160 |
|| `/api/color_rm/modifications/${this.state.sessionId}/${page.pageId}`;
|
| 161 |
|
| 162 |
-
await
|
| 163 |
method: 'POST',
|
| 164 |
headers: { 'Content-Type': 'application/json' },
|
| 165 |
body: JSON.stringify({
|
| 166 |
modifications: m,
|
| 167 |
timestamp: Date.now()
|
| 168 |
})
|
| 169 |
-
});
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
|
| 171 |
// Only sync deltas via Liveblocks (usually small)
|
| 172 |
// Also notify that modifications are in R2
|
|
@@ -213,11 +262,15 @@ export const ColorRmStorage = {
|
|
| 213 |
const historyUrl = window.Config?.apiUrl(`/api/color_rm/history/${this.state.sessionId}/${pageId}`)
|
| 214 |
|| `/api/color_rm/history/${this.state.sessionId}/${pageId}`;
|
| 215 |
|
| 216 |
-
await
|
| 217 |
method: 'POST',
|
| 218 |
headers: { 'Content-Type': 'application/json' },
|
| 219 |
body: JSON.stringify(currentPage.history)
|
| 220 |
-
});
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
|
| 222 |
// Mark page as having base history in R2
|
| 223 |
currentPage.hasBaseHistory = true;
|
|
|
|
| 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 {
|
| 7 |
+
const t = this.db.transaction(s, 'readwrite');
|
| 8 |
+
t.objectStore(s).put(v);
|
| 9 |
+
t.oncomplete = () => resolve();
|
| 10 |
+
t.onerror = (e) => {
|
| 11 |
+
console.error(`[dbPut] Error saving to ${s}:`, e.target.error);
|
| 12 |
+
reject(e.target.error);
|
| 13 |
+
};
|
| 14 |
+
} catch (e) {
|
| 15 |
+
console.error(`[dbPut] Transaction error:`, e);
|
| 16 |
+
reject(e);
|
| 17 |
+
}
|
| 18 |
+
});
|
| 19 |
+
},
|
| 20 |
|
| 21 |
+
async dbGet(s, k) {
|
| 22 |
+
return new Promise((resolve, reject) => {
|
| 23 |
+
try {
|
| 24 |
+
const q = this.db.transaction(s, 'readonly').objectStore(s).get(k);
|
| 25 |
+
q.onsuccess = () => resolve(q.result);
|
| 26 |
+
q.onerror = (e) => {
|
| 27 |
+
console.error(`[dbGet] Error reading from ${s}:`, e.target.error);
|
| 28 |
+
resolve(null); // Return null on error to avoid breaking flows
|
| 29 |
+
};
|
| 30 |
+
} catch (e) {
|
| 31 |
+
console.error(`[dbGet] Transaction error:`, e);
|
| 32 |
+
resolve(null);
|
| 33 |
+
}
|
| 34 |
+
});
|
| 35 |
+
},
|
| 36 |
|
| 37 |
+
async dbGetAll(s) {
|
| 38 |
+
return new Promise((resolve) => {
|
| 39 |
+
try {
|
| 40 |
+
const q = this.db.transaction(s, 'readonly').objectStore(s).getAll();
|
| 41 |
+
q.onsuccess = () => resolve(q.result || []);
|
| 42 |
+
q.onerror = (e) => {
|
| 43 |
+
console.error(`[dbGetAll] Error reading all from ${s}:`, e.target.error);
|
| 44 |
+
resolve([]);
|
| 45 |
+
};
|
| 46 |
+
} catch (e) {
|
| 47 |
+
console.error(`[dbGetAll] Transaction error:`, e);
|
| 48 |
+
resolve([]);
|
| 49 |
+
}
|
| 50 |
+
});
|
| 51 |
+
},
|
| 52 |
|
| 53 |
async saveSessionState() {
|
| 54 |
if(!this.state.sessionId || (this.liveSync && this.liveSync.isInitializing) || this.isUploading) return;
|
|
|
|
| 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
|
|
|
|
| 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;
|
public/scripts/modules/NetworkUtils.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Network Utilities - Shared helpers for network operations
|
| 3 |
+
* Provides timeout-enabled fetch and consistent error handling
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
/**
|
| 7 |
+
* Fetch with timeout - wraps fetch with AbortController timeout
|
| 8 |
+
* @param {string} url - The URL to fetch
|
| 9 |
+
* @param {object} options - Fetch options
|
| 10 |
+
* @param {number} timeoutMs - Timeout in milliseconds (default: 30000)
|
| 11 |
+
* @returns {Promise<Response>}
|
| 12 |
+
*/
|
| 13 |
+
export async function fetchWithTimeout(url, options = {}, timeoutMs = 30000) {
|
| 14 |
+
const controller = new AbortController();
|
| 15 |
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
| 16 |
+
|
| 17 |
+
try {
|
| 18 |
+
const response = await fetch(url, {
|
| 19 |
+
...options,
|
| 20 |
+
signal: controller.signal
|
| 21 |
+
});
|
| 22 |
+
clearTimeout(timeoutId);
|
| 23 |
+
return response;
|
| 24 |
+
} catch (error) {
|
| 25 |
+
clearTimeout(timeoutId);
|
| 26 |
+
if (error.name === 'AbortError') {
|
| 27 |
+
throw new Error(`Request timeout after ${timeoutMs}ms: ${url}`);
|
| 28 |
+
}
|
| 29 |
+
throw error;
|
| 30 |
+
}
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
/**
|
| 34 |
+
* Fetch with retry - retries failed requests with exponential backoff
|
| 35 |
+
* @param {string} url - The URL to fetch
|
| 36 |
+
* @param {object} options - Fetch options
|
| 37 |
+
* @param {number} maxRetries - Max retry attempts (default: 3)
|
| 38 |
+
* @param {number} baseDelayMs - Base delay between retries (default: 1000)
|
| 39 |
+
* @param {number} timeoutMs - Timeout per request (default: 30000)
|
| 40 |
+
* @param {function} onRetry - Optional callback(attempt, error) called before each retry
|
| 41 |
+
* @returns {Promise<Response>}
|
| 42 |
+
*/
|
| 43 |
+
export async function fetchWithRetry(url, options = {}, maxRetries = 3, baseDelayMs = 1000, timeoutMs = 30000, onRetry = null) {
|
| 44 |
+
let lastError;
|
| 45 |
+
|
| 46 |
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
| 47 |
+
try {
|
| 48 |
+
return await fetchWithTimeout(url, options, timeoutMs);
|
| 49 |
+
} catch (error) {
|
| 50 |
+
lastError = error;
|
| 51 |
+
|
| 52 |
+
if (attempt < maxRetries) {
|
| 53 |
+
const delay = baseDelayMs * Math.pow(2, attempt);
|
| 54 |
+
if (onRetry) {
|
| 55 |
+
onRetry(attempt + 1, error);
|
| 56 |
+
}
|
| 57 |
+
await new Promise(r => setTimeout(r, delay));
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
throw lastError;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
/**
|
| 66 |
+
* Upload with progress - performs upload with progress callback
|
| 67 |
+
* Uses XMLHttpRequest for progress events
|
| 68 |
+
* @param {string} url - The URL to upload to
|
| 69 |
+
* @param {FormData|Blob|string} data - The data to upload
|
| 70 |
+
* @param {object} options - Options including headers, method, timeout
|
| 71 |
+
* @param {function} onProgress - Progress callback(percent, loaded, total)
|
| 72 |
+
* @returns {Promise<{status: number, response: any}>}
|
| 73 |
+
*/
|
| 74 |
+
export function uploadWithProgress(url, data, options = {}, onProgress = null) {
|
| 75 |
+
return new Promise((resolve, reject) => {
|
| 76 |
+
const xhr = new XMLHttpRequest();
|
| 77 |
+
const method = options.method || 'POST';
|
| 78 |
+
const timeout = options.timeout || 120000; // 2 minute default for uploads
|
| 79 |
+
|
| 80 |
+
xhr.open(method, url);
|
| 81 |
+
|
| 82 |
+
// Set headers
|
| 83 |
+
if (options.headers) {
|
| 84 |
+
for (const [key, value] of Object.entries(options.headers)) {
|
| 85 |
+
xhr.setRequestHeader(key, value);
|
| 86 |
+
}
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
// Set timeout
|
| 90 |
+
xhr.timeout = timeout;
|
| 91 |
+
|
| 92 |
+
// Progress handler
|
| 93 |
+
if (onProgress && xhr.upload) {
|
| 94 |
+
xhr.upload.onprogress = (e) => {
|
| 95 |
+
if (e.lengthComputable) {
|
| 96 |
+
const percent = (e.loaded / e.total) * 100;
|
| 97 |
+
onProgress(percent, e.loaded, e.total);
|
| 98 |
+
}
|
| 99 |
+
};
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
xhr.onload = () => {
|
| 103 |
+
if (xhr.status >= 200 && xhr.status < 300) {
|
| 104 |
+
try {
|
| 105 |
+
const response = xhr.responseText ? JSON.parse(xhr.responseText) : null;
|
| 106 |
+
resolve({ status: xhr.status, response });
|
| 107 |
+
} catch {
|
| 108 |
+
resolve({ status: xhr.status, response: xhr.responseText });
|
| 109 |
+
}
|
| 110 |
+
} else {
|
| 111 |
+
reject(new Error(`Upload failed with status ${xhr.status}: ${xhr.statusText}`));
|
| 112 |
+
}
|
| 113 |
+
};
|
| 114 |
+
|
| 115 |
+
xhr.onerror = () => reject(new Error('Network error during upload'));
|
| 116 |
+
xhr.ontimeout = () => reject(new Error(`Upload timeout after ${timeout}ms`));
|
| 117 |
+
xhr.onabort = () => reject(new Error('Upload aborted'));
|
| 118 |
+
|
| 119 |
+
xhr.send(data);
|
| 120 |
+
});
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
/**
|
| 124 |
+
* Safe JSON fetch - fetches and parses JSON with error handling
|
| 125 |
+
* @param {string} url - The URL to fetch
|
| 126 |
+
* @param {object} options - Fetch options
|
| 127 |
+
* @param {number} timeoutMs - Timeout in milliseconds
|
| 128 |
+
* @returns {Promise<{ok: boolean, data: any, error: string|null}>}
|
| 129 |
+
*/
|
| 130 |
+
export async function safeJsonFetch(url, options = {}, timeoutMs = 30000) {
|
| 131 |
+
try {
|
| 132 |
+
const response = await fetchWithTimeout(url, options, timeoutMs);
|
| 133 |
+
|
| 134 |
+
if (!response.ok) {
|
| 135 |
+
return {
|
| 136 |
+
ok: false,
|
| 137 |
+
data: null,
|
| 138 |
+
error: `HTTP ${response.status}: ${response.statusText}`
|
| 139 |
+
};
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
const data = await response.json();
|
| 143 |
+
return { ok: true, data, error: null };
|
| 144 |
+
} catch (error) {
|
| 145 |
+
return {
|
| 146 |
+
ok: false,
|
| 147 |
+
data: null,
|
| 148 |
+
error: error.message || 'Unknown network error'
|
| 149 |
+
};
|
| 150 |
+
}
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
// Default timeout constants
|
| 154 |
+
export const TIMEOUT = {
|
| 155 |
+
SHORT: 10000, // 10 seconds - for metadata/small requests
|
| 156 |
+
MEDIUM: 30000, // 30 seconds - default
|
| 157 |
+
LONG: 60000, // 60 seconds - for large operations
|
| 158 |
+
UPLOAD: 120000 // 2 minutes - for file uploads
|
| 159 |
+
};
|