Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- Dockerfile +2 -2
- public/color_rm.html +22 -5
- public/scripts/modules/ColorRmRenderer.js +30 -16
- public/scripts/modules/ColorRmSession.js +307 -20
- worker/pdfToSvg.ts +295 -0
- worker/worker.ts +11 -0
Dockerfile
CHANGED
|
@@ -3,8 +3,8 @@ FROM node:20
|
|
| 3 |
# Create app directory
|
| 4 |
WORKDIR /app
|
| 5 |
|
| 6 |
-
# Install dependencies for HF CLI
|
| 7 |
-
RUN apt-get update && apt-get install -y curl python3-venv && rm -rf /var/lib/apt/lists/*
|
| 8 |
|
| 9 |
# Install HF CLI
|
| 10 |
RUN curl -LsSf https://hf.co/cli/install.sh | bash
|
|
|
|
| 3 |
# Create app directory
|
| 4 |
WORKDIR /app
|
| 5 |
|
| 6 |
+
# Install dependencies for HF CLI and pdf2svg
|
| 7 |
+
RUN apt-get update && apt-get install -y curl python3-venv pdf2svg && rm -rf /var/lib/apt/lists/*
|
| 8 |
|
| 9 |
# Install HF CLI
|
| 10 |
RUN curl -LsSf https://hf.co/cli/install.sh | bash
|
public/color_rm.html
CHANGED
|
@@ -1384,13 +1384,30 @@
|
|
| 1384 |
|
| 1385 |
<!-- Import Tab -->
|
| 1386 |
<div id="pageModalImport" class="page-modal-content" style="display:none;">
|
| 1387 |
-
<
|
| 1388 |
-
|
| 1389 |
-
<
|
|
|
|
| 1390 |
<div style="font-size:0.8rem; color:#888;">PNG, JPG, WebP supported</div>
|
| 1391 |
</div>
|
| 1392 |
-
|
| 1393 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1394 |
</p>
|
| 1395 |
</div>
|
| 1396 |
|
|
|
|
| 1384 |
|
| 1385 |
<!-- Import Tab -->
|
| 1386 |
<div id="pageModalImport" class="page-modal-content" style="display:none;">
|
| 1387 |
+
<!-- Image Import -->
|
| 1388 |
+
<div style="border:2px dashed var(--border); border-radius:12px; padding:30px 20px; text-align:center; cursor:pointer; transition:all 0.2s; margin-bottom:15px;" onclick="document.getElementById('addImagePageInput').click();" onmouseover="this.style.borderColor='#8b5cf6'; this.style.background='rgba(139,92,246,0.05)';" onmouseout="this.style.borderColor='var(--border)'; this.style.background='transparent';">
|
| 1389 |
+
<i class="bi bi-image" style="font-size:2rem; color:#8b5cf6; display:block; margin-bottom:10px;"></i>
|
| 1390 |
+
<div style="font-weight:600; color:white; margin-bottom:5px;">Import Image</div>
|
| 1391 |
<div style="font-size:0.8rem; color:#888;">PNG, JPG, WebP supported</div>
|
| 1392 |
</div>
|
| 1393 |
+
|
| 1394 |
+
<!-- SVG Import -->
|
| 1395 |
+
<div style="border:2px dashed var(--border); border-radius:12px; padding:30px 20px; text-align:center; cursor:pointer; transition:all 0.2s; margin-bottom:15px;" onclick="window.App.importSvgAsInfiniteCanvas();" onmouseover="this.style.borderColor='#10b981'; this.style.background='rgba(16,185,129,0.05)';" onmouseout="this.style.borderColor='var(--border)'; this.style.background='transparent';">
|
| 1396 |
+
<i class="bi bi-filetype-svg" style="font-size:2rem; color:#10b981; display:block; margin-bottom:10px;"></i>
|
| 1397 |
+
<div style="font-weight:600; color:white; margin-bottom:5px;">Import SVG</div>
|
| 1398 |
+
<div style="font-size:0.8rem; color:#888;">Vector graphics as editable elements</div>
|
| 1399 |
+
</div>
|
| 1400 |
+
|
| 1401 |
+
<!-- PDF Import (Experimental) -->
|
| 1402 |
+
<div style="border:2px dashed var(--border); border-radius:12px; padding:30px 20px; text-align:center; cursor:pointer; transition:all 0.2s; position:relative;" onclick="window.App.importPdf();" onmouseover="this.style.borderColor='#ff6b6b'; this.style.background='rgba(255,107,107,0.05)';" onmouseout="this.style.borderColor='var(--border)'; this.style.background='transparent';">
|
| 1403 |
+
<span style="position:absolute; top:8px; right:8px; background:#ff6b6b; color:white; font-size:0.6rem; padding:2px 6px; border-radius:4px; font-weight:600;">EXPERIMENTAL</span>
|
| 1404 |
+
<i class="bi bi-file-pdf" style="font-size:2rem; color:#ff6b6b; display:block; margin-bottom:10px;"></i>
|
| 1405 |
+
<div style="font-weight:600; color:white; margin-bottom:5px;">Import PDF</div>
|
| 1406 |
+
<div style="font-size:0.8rem; color:#888;">Convert PDF pages to SVG (server-side)</div>
|
| 1407 |
+
</div>
|
| 1408 |
+
|
| 1409 |
+
<p style="text-align:center; font-size:0.75rem; color:#666; margin-top:15px;">
|
| 1410 |
+
<i class="bi bi-info-circle"></i> Files will be added as new pages
|
| 1411 |
</p>
|
| 1412 |
</div>
|
| 1413 |
|
public/scripts/modules/ColorRmRenderer.js
CHANGED
|
@@ -964,8 +964,11 @@ export const ColorRmRenderer = {
|
|
| 964 |
},
|
| 965 |
|
| 966 |
// Helper to render masked images (v2 feature)
|
|
|
|
| 967 |
_renderMaskedImage(ctx, st, contentImg) {
|
| 968 |
-
|
|
|
|
|
|
|
| 969 |
let maskImg = this._imageCache.get(maskCacheKey);
|
| 970 |
|
| 971 |
if (!maskImg) {
|
|
@@ -975,7 +978,7 @@ export const ColorRmRenderer = {
|
|
| 975 |
maskImg._loaded = true;
|
| 976 |
this.invalidateCache();
|
| 977 |
};
|
| 978 |
-
maskImg.src =
|
| 979 |
this._imageCache.set(maskCacheKey, maskImg);
|
| 980 |
return;
|
| 981 |
}
|
|
@@ -985,28 +988,37 @@ export const ColorRmRenderer = {
|
|
| 985 |
const w = Math.max(1, Math.round(st.w));
|
| 986 |
const h = Math.max(1, Math.round(st.h));
|
| 987 |
|
| 988 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 989 |
const contentCanvas = document.createElement('canvas');
|
| 990 |
-
contentCanvas.width =
|
| 991 |
-
contentCanvas.height =
|
| 992 |
const contentCtx = contentCanvas.getContext('2d');
|
|
|
|
|
|
|
| 993 |
|
| 994 |
-
// Draw content image
|
| 995 |
-
contentCtx.drawImage(contentImg, 0, 0,
|
| 996 |
|
| 997 |
-
// Create offscreen canvas for the mask
|
| 998 |
const maskCanvas = document.createElement('canvas');
|
| 999 |
-
maskCanvas.width =
|
| 1000 |
-
maskCanvas.height =
|
| 1001 |
const maskCtx = maskCanvas.getContext('2d');
|
|
|
|
|
|
|
| 1002 |
|
| 1003 |
-
// Draw mask image
|
| 1004 |
-
maskCtx.drawImage(maskImg, 0, 0,
|
| 1005 |
|
| 1006 |
// Convert mask luminance to alpha
|
| 1007 |
// In SVG luminance masks: white = visible (alpha 1), black = hidden (alpha 0)
|
| 1008 |
-
const maskData = maskCtx.getImageData(0, 0,
|
| 1009 |
-
const contentData = contentCtx.getImageData(0, 0,
|
| 1010 |
|
| 1011 |
for (let i = 0; i < maskData.data.length; i += 4) {
|
| 1012 |
// Calculate luminance from mask RGB
|
|
@@ -1026,8 +1038,10 @@ export const ColorRmRenderer = {
|
|
| 1026 |
// Put the masked content back
|
| 1027 |
contentCtx.putImageData(contentData, 0, 0);
|
| 1028 |
|
| 1029 |
-
// Draw result to main canvas
|
| 1030 |
-
ctx.
|
|
|
|
|
|
|
| 1031 |
},
|
| 1032 |
|
| 1033 |
// Helper to render text with SVG data (v2 feature)
|
|
|
|
| 964 |
},
|
| 965 |
|
| 966 |
// Helper to render masked images (v2 feature)
|
| 967 |
+
// Uses 2x supersampling for higher quality output
|
| 968 |
_renderMaskedImage(ctx, st, contentImg) {
|
| 969 |
+
// Clean up mask src - remove newlines that may be in base64 data
|
| 970 |
+
const cleanMaskSrc = st.mask.src.replace(/\s/g, '');
|
| 971 |
+
const maskCacheKey = cleanMaskSrc;
|
| 972 |
let maskImg = this._imageCache.get(maskCacheKey);
|
| 973 |
|
| 974 |
if (!maskImg) {
|
|
|
|
| 978 |
maskImg._loaded = true;
|
| 979 |
this.invalidateCache();
|
| 980 |
};
|
| 981 |
+
maskImg.src = cleanMaskSrc;
|
| 982 |
this._imageCache.set(maskCacheKey, maskImg);
|
| 983 |
return;
|
| 984 |
}
|
|
|
|
| 988 |
const w = Math.max(1, Math.round(st.w));
|
| 989 |
const h = Math.max(1, Math.round(st.h));
|
| 990 |
|
| 991 |
+
// Use 2x supersampling for higher quality
|
| 992 |
+
const scale = 2;
|
| 993 |
+
const scaledW = w * scale;
|
| 994 |
+
const scaledH = h * scale;
|
| 995 |
+
|
| 996 |
+
// Create offscreen canvas for the content at higher resolution
|
| 997 |
const contentCanvas = document.createElement('canvas');
|
| 998 |
+
contentCanvas.width = scaledW;
|
| 999 |
+
contentCanvas.height = scaledH;
|
| 1000 |
const contentCtx = contentCanvas.getContext('2d');
|
| 1001 |
+
contentCtx.imageSmoothingEnabled = true;
|
| 1002 |
+
contentCtx.imageSmoothingQuality = 'high';
|
| 1003 |
|
| 1004 |
+
// Draw content image at higher resolution
|
| 1005 |
+
contentCtx.drawImage(contentImg, 0, 0, scaledW, scaledH);
|
| 1006 |
|
| 1007 |
+
// Create offscreen canvas for the mask at higher resolution
|
| 1008 |
const maskCanvas = document.createElement('canvas');
|
| 1009 |
+
maskCanvas.width = scaledW;
|
| 1010 |
+
maskCanvas.height = scaledH;
|
| 1011 |
const maskCtx = maskCanvas.getContext('2d');
|
| 1012 |
+
maskCtx.imageSmoothingEnabled = true;
|
| 1013 |
+
maskCtx.imageSmoothingQuality = 'high';
|
| 1014 |
|
| 1015 |
+
// Draw mask image at higher resolution
|
| 1016 |
+
maskCtx.drawImage(maskImg, 0, 0, scaledW, scaledH);
|
| 1017 |
|
| 1018 |
// Convert mask luminance to alpha
|
| 1019 |
// In SVG luminance masks: white = visible (alpha 1), black = hidden (alpha 0)
|
| 1020 |
+
const maskData = maskCtx.getImageData(0, 0, scaledW, scaledH);
|
| 1021 |
+
const contentData = contentCtx.getImageData(0, 0, scaledW, scaledH);
|
| 1022 |
|
| 1023 |
for (let i = 0; i < maskData.data.length; i += 4) {
|
| 1024 |
// Calculate luminance from mask RGB
|
|
|
|
| 1038 |
// Put the masked content back
|
| 1039 |
contentCtx.putImageData(contentData, 0, 0);
|
| 1040 |
|
| 1041 |
+
// Draw result to main canvas, scaling down for final output
|
| 1042 |
+
ctx.imageSmoothingEnabled = true;
|
| 1043 |
+
ctx.imageSmoothingQuality = 'high';
|
| 1044 |
+
ctx.drawImage(contentCanvas, st.x, st.y, w, h);
|
| 1045 |
},
|
| 1046 |
|
| 1047 |
// Helper to render text with SVG data (v2 feature)
|
public/scripts/modules/ColorRmSession.js
CHANGED
|
@@ -62,6 +62,7 @@ export const ColorRmSession = {
|
|
| 62 |
/**
|
| 63 |
* Rasterize a masked image using luminance mask
|
| 64 |
* Applies SVG-style luminance masking where white=visible, black=hidden
|
|
|
|
| 65 |
*/
|
| 66 |
async _rasterizeMaskedImage(ctx, item, contentImg) {
|
| 67 |
return new Promise((resolve) => {
|
|
@@ -72,28 +73,37 @@ export const ColorRmSession = {
|
|
| 72 |
const w = Math.max(1, Math.round(item.w || contentImg.width));
|
| 73 |
const h = Math.max(1, Math.round(item.h || contentImg.height));
|
| 74 |
|
| 75 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
const contentCanvas = document.createElement('canvas');
|
| 77 |
-
contentCanvas.width =
|
| 78 |
-
contentCanvas.height =
|
| 79 |
const contentCtx = contentCanvas.getContext('2d');
|
|
|
|
|
|
|
| 80 |
|
| 81 |
-
// Draw content image
|
| 82 |
-
contentCtx.drawImage(contentImg, 0, 0,
|
| 83 |
|
| 84 |
-
// Create offscreen canvas for the mask
|
| 85 |
const maskCanvas = document.createElement('canvas');
|
| 86 |
-
maskCanvas.width =
|
| 87 |
-
maskCanvas.height =
|
| 88 |
const maskCtx = maskCanvas.getContext('2d');
|
|
|
|
|
|
|
| 89 |
|
| 90 |
-
// Draw mask image
|
| 91 |
-
maskCtx.drawImage(maskImg, 0, 0,
|
| 92 |
|
| 93 |
// Convert mask luminance to alpha
|
| 94 |
// In SVG luminance masks: white = visible (alpha 1), black = hidden (alpha 0)
|
| 95 |
-
const maskData = maskCtx.getImageData(0, 0,
|
| 96 |
-
const contentData = contentCtx.getImageData(0, 0,
|
| 97 |
|
| 98 |
for (let i = 0; i < maskData.data.length; i += 4) {
|
| 99 |
// Calculate luminance from mask RGB
|
|
@@ -112,17 +122,21 @@ export const ColorRmSession = {
|
|
| 112 |
// Put the masked content back
|
| 113 |
contentCtx.putImageData(contentData, 0, 0);
|
| 114 |
|
| 115 |
-
// Draw result to main canvas
|
| 116 |
-
ctx.
|
|
|
|
|
|
|
| 117 |
resolve();
|
| 118 |
};
|
| 119 |
-
maskImg.onerror = () => {
|
| 120 |
// Fallback: draw without mask
|
| 121 |
-
console.warn('Failed to load mask image, drawing without mask:', item.id);
|
| 122 |
ctx.drawImage(contentImg, item.x || 0, item.y || 0, item.w || contentImg.width, item.h || contentImg.height);
|
| 123 |
resolve();
|
| 124 |
};
|
| 125 |
-
|
|
|
|
|
|
|
| 126 |
});
|
| 127 |
},
|
| 128 |
|
|
@@ -226,7 +240,14 @@ export const ColorRmSession = {
|
|
| 226 |
await this.dbPut('pages', pageObj);
|
| 227 |
this.state.images.push(pageObj);
|
| 228 |
|
| 229 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
if (this.liveSync && !this.liveSync.isInitializing && toSync.length > 0) {
|
| 231 |
this.liveSync.setHistory(newPageIndex, toSync);
|
| 232 |
}
|
|
@@ -235,7 +256,8 @@ export const ColorRmSession = {
|
|
| 235 |
await this.loadPage(newPageIndex, false);
|
| 236 |
if (this.state.activeSideTab === 'pages') this.renderPageSidebar();
|
| 237 |
|
| 238 |
-
|
|
|
|
| 239 |
console.log(`SVG imported as infinite: ${toRasterize.length} rasterized, ${toSync.length} synced`);
|
| 240 |
} catch (e) {
|
| 241 |
console.error('SVG infinite import error:', e);
|
|
@@ -314,6 +336,260 @@ export const ColorRmSession = {
|
|
| 314 |
});
|
| 315 |
},
|
| 316 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 317 |
// =============================================
|
| 318 |
// REFACTORED HELPERS (Phase 1 - Core Helpers)
|
| 319 |
// =============================================
|
|
@@ -1363,6 +1639,16 @@ export const ColorRmSession = {
|
|
| 1363 |
}
|
| 1364 |
}
|
| 1365 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1366 |
// Notify Liveblocks that this page has base history (metadata only, no data)
|
| 1367 |
if (this.liveSync && !this.liveSync.isInitializing) {
|
| 1368 |
this.liveSync.updatePageMetadata(idx, { hasBaseHistory: true, baseHistoryCount: toSync.length });
|
|
@@ -1373,7 +1659,8 @@ export const ColorRmSession = {
|
|
| 1373 |
idx++;
|
| 1374 |
|
| 1375 |
const modeText = importOptions.asInfinite ? 'as infinite canvas ∞' : '';
|
| 1376 |
-
|
|
|
|
| 1377 |
console.log(`SVG imported: ${toRasterize.length} items rasterized, ${toSync.length} items stored in R2, infinite=${importOptions.asInfinite}`);
|
| 1378 |
} catch (e) {
|
| 1379 |
console.error('SVG import error:', e);
|
|
|
|
| 62 |
/**
|
| 63 |
* Rasterize a masked image using luminance mask
|
| 64 |
* Applies SVG-style luminance masking where white=visible, black=hidden
|
| 65 |
+
* Uses 2x supersampling for higher quality output
|
| 66 |
*/
|
| 67 |
async _rasterizeMaskedImage(ctx, item, contentImg) {
|
| 68 |
return new Promise((resolve) => {
|
|
|
|
| 73 |
const w = Math.max(1, Math.round(item.w || contentImg.width));
|
| 74 |
const h = Math.max(1, Math.round(item.h || contentImg.height));
|
| 75 |
|
| 76 |
+
// Use 2x supersampling for higher quality
|
| 77 |
+
const scale = 2;
|
| 78 |
+
const scaledW = w * scale;
|
| 79 |
+
const scaledH = h * scale;
|
| 80 |
+
|
| 81 |
+
// Create offscreen canvas for the content at higher resolution
|
| 82 |
const contentCanvas = document.createElement('canvas');
|
| 83 |
+
contentCanvas.width = scaledW;
|
| 84 |
+
contentCanvas.height = scaledH;
|
| 85 |
const contentCtx = contentCanvas.getContext('2d');
|
| 86 |
+
contentCtx.imageSmoothingEnabled = true;
|
| 87 |
+
contentCtx.imageSmoothingQuality = 'high';
|
| 88 |
|
| 89 |
+
// Draw content image at higher resolution
|
| 90 |
+
contentCtx.drawImage(contentImg, 0, 0, scaledW, scaledH);
|
| 91 |
|
| 92 |
+
// Create offscreen canvas for the mask at higher resolution
|
| 93 |
const maskCanvas = document.createElement('canvas');
|
| 94 |
+
maskCanvas.width = scaledW;
|
| 95 |
+
maskCanvas.height = scaledH;
|
| 96 |
const maskCtx = maskCanvas.getContext('2d');
|
| 97 |
+
maskCtx.imageSmoothingEnabled = true;
|
| 98 |
+
maskCtx.imageSmoothingQuality = 'high';
|
| 99 |
|
| 100 |
+
// Draw mask image at higher resolution
|
| 101 |
+
maskCtx.drawImage(maskImg, 0, 0, scaledW, scaledH);
|
| 102 |
|
| 103 |
// Convert mask luminance to alpha
|
| 104 |
// In SVG luminance masks: white = visible (alpha 1), black = hidden (alpha 0)
|
| 105 |
+
const maskData = maskCtx.getImageData(0, 0, scaledW, scaledH);
|
| 106 |
+
const contentData = contentCtx.getImageData(0, 0, scaledW, scaledH);
|
| 107 |
|
| 108 |
for (let i = 0; i < maskData.data.length; i += 4) {
|
| 109 |
// Calculate luminance from mask RGB
|
|
|
|
| 122 |
// Put the masked content back
|
| 123 |
contentCtx.putImageData(contentData, 0, 0);
|
| 124 |
|
| 125 |
+
// Draw result to main canvas, scaling down for final output
|
| 126 |
+
ctx.imageSmoothingEnabled = true;
|
| 127 |
+
ctx.imageSmoothingQuality = 'high';
|
| 128 |
+
ctx.drawImage(contentCanvas, x, y, w, h);
|
| 129 |
resolve();
|
| 130 |
};
|
| 131 |
+
maskImg.onerror = (e) => {
|
| 132 |
// Fallback: draw without mask
|
| 133 |
+
console.warn('Failed to load mask image, drawing without mask:', item.id, e);
|
| 134 |
ctx.drawImage(contentImg, item.x || 0, item.y || 0, item.w || contentImg.width, item.h || contentImg.height);
|
| 135 |
resolve();
|
| 136 |
};
|
| 137 |
+
// Clean up mask src - remove newlines that may be in base64 data
|
| 138 |
+
const cleanMaskSrc = item.mask.src.replace(/\s/g, '');
|
| 139 |
+
maskImg.src = cleanMaskSrc;
|
| 140 |
});
|
| 141 |
},
|
| 142 |
|
|
|
|
| 240 |
await this.dbPut('pages', pageObj);
|
| 241 |
this.state.images.push(pageObj);
|
| 242 |
|
| 243 |
+
// CRITICAL: Upload the page blob to R2 so other users can fetch it
|
| 244 |
+
const uploadSuccess = await this._uploadPageBlob(pageObj.pageId, pageObj.blob);
|
| 245 |
+
|
| 246 |
+
// Sync page structure to R2 and Liveblocks
|
| 247 |
+
await this._syncPageStructureToServer();
|
| 248 |
+
this._syncPageStructureToLive();
|
| 249 |
+
|
| 250 |
+
// Sync history to LiveSync (for deltas)
|
| 251 |
if (this.liveSync && !this.liveSync.isInitializing && toSync.length > 0) {
|
| 252 |
this.liveSync.setHistory(newPageIndex, toSync);
|
| 253 |
}
|
|
|
|
| 256 |
await this.loadPage(newPageIndex, false);
|
| 257 |
if (this.state.activeSideTab === 'pages') this.renderPageSidebar();
|
| 258 |
|
| 259 |
+
const syncStatus = uploadSuccess ? '✓ Synced' : '⚠ Local only';
|
| 260 |
+
this.ui.showToast(`SVG imported as infinite canvas (${toSync.length} editable items) ${syncStatus}`);
|
| 261 |
console.log(`SVG imported as infinite: ${toRasterize.length} rasterized, ${toSync.length} synced`);
|
| 262 |
} catch (e) {
|
| 263 |
console.error('SVG infinite import error:', e);
|
|
|
|
| 336 |
});
|
| 337 |
},
|
| 338 |
|
| 339 |
+
// =============================================
|
| 340 |
+
// PDF IMPORT (Experimental)
|
| 341 |
+
// =============================================
|
| 342 |
+
|
| 343 |
+
/**
|
| 344 |
+
* Import PDF file - opens file picker and initiates server-side conversion
|
| 345 |
+
* This is an experimental feature that converts PDF pages to SVG
|
| 346 |
+
*/
|
| 347 |
+
async importPdf() {
|
| 348 |
+
const input = document.createElement('input');
|
| 349 |
+
input.type = 'file';
|
| 350 |
+
input.accept = '.pdf,application/pdf';
|
| 351 |
+
|
| 352 |
+
input.onchange = async (e) => {
|
| 353 |
+
const file = e.target.files?.[0];
|
| 354 |
+
if (!file) return;
|
| 355 |
+
|
| 356 |
+
this.ui.toggleLoader(true, 'Uploading PDF...');
|
| 357 |
+
|
| 358 |
+
try {
|
| 359 |
+
// Upload PDF to server
|
| 360 |
+
const formData = new FormData();
|
| 361 |
+
formData.append('file', file);
|
| 362 |
+
|
| 363 |
+
const uploadUrl = window.Config?.apiUrl(`/api/color_rm/pdf/${this.state.sessionId}`)
|
| 364 |
+
|| `/api/color_rm/pdf/${this.state.sessionId}`;
|
| 365 |
+
|
| 366 |
+
const response = await fetch(uploadUrl, {
|
| 367 |
+
method: 'POST',
|
| 368 |
+
body: formData
|
| 369 |
+
});
|
| 370 |
+
|
| 371 |
+
if (!response.ok) {
|
| 372 |
+
throw new Error('Upload failed');
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
const result = await response.json();
|
| 376 |
+
|
| 377 |
+
// Show conversion status dialog
|
| 378 |
+
this._showPdfConversionDialog(result.jobId, file.name);
|
| 379 |
+
|
| 380 |
+
} catch (e) {
|
| 381 |
+
console.error('PDF upload error:', e);
|
| 382 |
+
this.ui.showToast(`PDF upload failed: ${e.message}`);
|
| 383 |
+
} finally {
|
| 384 |
+
this.ui.toggleLoader(false);
|
| 385 |
+
}
|
| 386 |
+
};
|
| 387 |
+
|
| 388 |
+
input.click();
|
| 389 |
+
},
|
| 390 |
+
|
| 391 |
+
/**
|
| 392 |
+
* Shows PDF conversion status dialog with polling
|
| 393 |
+
* @param {string} jobId - The conversion job ID
|
| 394 |
+
* @param {string} fileName - Original file name
|
| 395 |
+
*/
|
| 396 |
+
_showPdfConversionDialog(jobId, fileName) {
|
| 397 |
+
const modal = document.createElement('div');
|
| 398 |
+
modal.className = 'overlay';
|
| 399 |
+
modal.style.cssText = 'position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.8); z-index:300; display:flex; align-items:center; justify-content:center;';
|
| 400 |
+
|
| 401 |
+
modal.innerHTML = `
|
| 402 |
+
<div class="card" style="max-width:450px; width:90%; background:var(--bg-panel); border:1px solid var(--border); padding:20px;">
|
| 403 |
+
<h3 style="margin:0 0 15px; color:white; font-size:1.1rem;">
|
| 404 |
+
<i class="bi bi-file-pdf" style="color:#ff6b6b;"></i> PDF Import (Experimental)
|
| 405 |
+
</h3>
|
| 406 |
+
<p style="color:#aaa; margin:0 0 15px; font-size:0.9rem;">${fileName}</p>
|
| 407 |
+
|
| 408 |
+
<div id="pdfStatus" style="padding:15px; background:var(--bg-dark); border-radius:8px; margin-bottom:15px;">
|
| 409 |
+
<div style="display:flex; align-items:center; gap:10px; color:#888;">
|
| 410 |
+
<div class="spinner" style="width:20px; height:20px; border:2px solid #333; border-top-color:#4dabf7; border-radius:50%; animation:spin 1s linear infinite;"></div>
|
| 411 |
+
<span>Processing PDF...</span>
|
| 412 |
+
</div>
|
| 413 |
+
<div id="pdfProgress" style="margin-top:10px; font-size:0.85rem; color:#666;"></div>
|
| 414 |
+
</div>
|
| 415 |
+
|
| 416 |
+
<div id="pdfPages" style="max-height:200px; overflow-y:auto; display:none;">
|
| 417 |
+
<!-- Page list will be populated here -->
|
| 418 |
+
</div>
|
| 419 |
+
|
| 420 |
+
<div style="display:flex; gap:10px; margin-top:15px;">
|
| 421 |
+
<button id="pdfImportAll" class="btn btn-primary" style="flex:1; display:none;">Import All Pages</button>
|
| 422 |
+
<button id="pdfClose" class="btn btn-secondary" style="flex:1;">Cancel</button>
|
| 423 |
+
</div>
|
| 424 |
+
|
| 425 |
+
<p style="font-size:0.75rem; color:#666; margin:15px 0 0; text-align:center;">
|
| 426 |
+
⚠️ Experimental: PDF conversion quality may vary
|
| 427 |
+
</p>
|
| 428 |
+
</div>
|
| 429 |
+
<style>
|
| 430 |
+
@keyframes spin { to { transform: rotate(360deg); } }
|
| 431 |
+
</style>
|
| 432 |
+
`;
|
| 433 |
+
|
| 434 |
+
document.body.appendChild(modal);
|
| 435 |
+
|
| 436 |
+
let pollInterval = null;
|
| 437 |
+
let jobData = null;
|
| 438 |
+
|
| 439 |
+
const pollStatus = async () => {
|
| 440 |
+
try {
|
| 441 |
+
const statusUrl = window.Config?.apiUrl(`/api/color_rm/pdf/${this.state.sessionId}/job/${jobId}`)
|
| 442 |
+
|| `/api/color_rm/pdf/${this.state.sessionId}/job/${jobId}`;
|
| 443 |
+
|
| 444 |
+
const response = await fetch(statusUrl);
|
| 445 |
+
if (!response.ok) throw new Error('Status check failed');
|
| 446 |
+
|
| 447 |
+
jobData = await response.json();
|
| 448 |
+
const statusEl = modal.querySelector('#pdfStatus');
|
| 449 |
+
const progressEl = modal.querySelector('#pdfProgress');
|
| 450 |
+
const pagesEl = modal.querySelector('#pdfPages');
|
| 451 |
+
const importAllBtn = modal.querySelector('#pdfImportAll');
|
| 452 |
+
|
| 453 |
+
if (jobData.status === 'processing') {
|
| 454 |
+
progressEl.textContent = `Processing page ${jobData.processedPages} of ${jobData.pageCount || '?'}...`;
|
| 455 |
+
} else if (jobData.status === 'completed') {
|
| 456 |
+
clearInterval(pollInterval);
|
| 457 |
+
|
| 458 |
+
statusEl.innerHTML = `
|
| 459 |
+
<div style="color:#51cf66; display:flex; align-items:center; gap:10px;">
|
| 460 |
+
<i class="bi bi-check-circle-fill"></i>
|
| 461 |
+
<span>Conversion complete!</span>
|
| 462 |
+
</div>
|
| 463 |
+
`;
|
| 464 |
+
|
| 465 |
+
if (jobData.availablePages && jobData.availablePages.length > 0) {
|
| 466 |
+
pagesEl.style.display = 'block';
|
| 467 |
+
pagesEl.innerHTML = jobData.availablePages.map(pageNum => `
|
| 468 |
+
<div class="pdf-page-item" data-page="${pageNum}" style="padding:10px; border:1px solid var(--border); margin-bottom:5px; border-radius:4px; cursor:pointer; display:flex; justify-content:space-between; align-items:center;">
|
| 469 |
+
<span>Page ${pageNum}</span>
|
| 470 |
+
<button class="btn btn-sm pdf-import-page" data-page="${pageNum}">Import</button>
|
| 471 |
+
</div>
|
| 472 |
+
`).join('');
|
| 473 |
+
|
| 474 |
+
importAllBtn.style.display = 'block';
|
| 475 |
+
|
| 476 |
+
// Add click handlers for individual page imports
|
| 477 |
+
pagesEl.querySelectorAll('.pdf-import-page').forEach(btn => {
|
| 478 |
+
btn.onclick = (e) => {
|
| 479 |
+
e.stopPropagation();
|
| 480 |
+
this._importPdfPage(jobId, parseInt(btn.dataset.page));
|
| 481 |
+
};
|
| 482 |
+
});
|
| 483 |
+
}
|
| 484 |
+
} else if (jobData.status === 'failed') {
|
| 485 |
+
clearInterval(pollInterval);
|
| 486 |
+
statusEl.innerHTML = `
|
| 487 |
+
<div style="color:#ff6b6b; display:flex; align-items:center; gap:10px;">
|
| 488 |
+
<i class="bi bi-exclamation-triangle-fill"></i>
|
| 489 |
+
<span>Conversion failed: ${jobData.error || 'Unknown error'}</span>
|
| 490 |
+
</div>
|
| 491 |
+
`;
|
| 492 |
+
} else if (jobData.status === 'pending') {
|
| 493 |
+
progressEl.textContent = 'Waiting in queue...';
|
| 494 |
+
}
|
| 495 |
+
|
| 496 |
+
} catch (e) {
|
| 497 |
+
console.error('Status poll error:', e);
|
| 498 |
+
}
|
| 499 |
+
};
|
| 500 |
+
|
| 501 |
+
// Start polling
|
| 502 |
+
pollInterval = setInterval(pollStatus, 2000);
|
| 503 |
+
pollStatus(); // Initial check
|
| 504 |
+
|
| 505 |
+
// Import all pages button
|
| 506 |
+
modal.querySelector('#pdfImportAll').onclick = async () => {
|
| 507 |
+
if (jobData?.availablePages) {
|
| 508 |
+
for (const pageNum of jobData.availablePages) {
|
| 509 |
+
await this._importPdfPage(jobId, pageNum);
|
| 510 |
+
}
|
| 511 |
+
document.body.removeChild(modal);
|
| 512 |
+
}
|
| 513 |
+
};
|
| 514 |
+
|
| 515 |
+
// Close button
|
| 516 |
+
modal.querySelector('#pdfClose').onclick = () => {
|
| 517 |
+
clearInterval(pollInterval);
|
| 518 |
+
document.body.removeChild(modal);
|
| 519 |
+
};
|
| 520 |
+
|
| 521 |
+
// Close on backdrop click
|
| 522 |
+
modal.onclick = (e) => {
|
| 523 |
+
if (e.target === modal) {
|
| 524 |
+
clearInterval(pollInterval);
|
| 525 |
+
document.body.removeChild(modal);
|
| 526 |
+
}
|
| 527 |
+
};
|
| 528 |
+
},
|
| 529 |
+
|
| 530 |
+
/**
|
| 531 |
+
* Import a single PDF page as SVG
|
| 532 |
+
* @param {string} jobId - The conversion job ID
|
| 533 |
+
* @param {number} pageNum - Page number to import
|
| 534 |
+
*/
|
| 535 |
+
async _importPdfPage(jobId, pageNum) {
|
| 536 |
+
this.ui.toggleLoader(true, `Importing page ${pageNum}...`);
|
| 537 |
+
|
| 538 |
+
try {
|
| 539 |
+
const pageUrl = window.Config?.apiUrl(`/api/color_rm/pdf/${this.state.sessionId}/job/${jobId}/page/${pageNum}`)
|
| 540 |
+
|| `/api/color_rm/pdf/${this.state.sessionId}/job/${jobId}/page/${pageNum}`;
|
| 541 |
+
|
| 542 |
+
const response = await fetch(pageUrl);
|
| 543 |
+
if (!response.ok) throw new Error('Failed to download page');
|
| 544 |
+
|
| 545 |
+
const svgContent = await response.text();
|
| 546 |
+
|
| 547 |
+
// Create a File-like object for the SVG importer
|
| 548 |
+
const svgBlob = new Blob([svgContent], { type: 'image/svg+xml' });
|
| 549 |
+
const svgFile = new File([svgBlob], `pdf_page_${pageNum}.svg`, { type: 'image/svg+xml' });
|
| 550 |
+
|
| 551 |
+
// Show import options
|
| 552 |
+
const options = await this._showSvgImportOptions(`PDF Page ${pageNum}`);
|
| 553 |
+
if (options.cancelled) {
|
| 554 |
+
this.ui.toggleLoader(false);
|
| 555 |
+
return;
|
| 556 |
+
}
|
| 557 |
+
|
| 558 |
+
// Import using existing SVG import logic
|
| 559 |
+
const newPageIndex = this.state.images.length;
|
| 560 |
+
const { pageObj, toSync } = await this.importSvgAsPage(svgFile, newPageIndex, options);
|
| 561 |
+
|
| 562 |
+
await this.dbPut('pages', pageObj);
|
| 563 |
+
this.state.images.push(pageObj);
|
| 564 |
+
|
| 565 |
+
// CRITICAL: Upload the page blob to R2 so other users can fetch it
|
| 566 |
+
const uploadSuccess = await this._uploadPageBlob(pageObj.pageId, pageObj.blob);
|
| 567 |
+
|
| 568 |
+
// Sync page structure to R2 and Liveblocks
|
| 569 |
+
await this._syncPageStructureToServer();
|
| 570 |
+
this._syncPageStructureToLive();
|
| 571 |
+
|
| 572 |
+
if (this.liveSync && !this.liveSync.isInitializing && toSync.length > 0) {
|
| 573 |
+
this.liveSync.setHistory(newPageIndex, toSync);
|
| 574 |
+
}
|
| 575 |
+
|
| 576 |
+
// Navigate to the new page
|
| 577 |
+
this.state.idx = newPageIndex;
|
| 578 |
+
await this.loadImage();
|
| 579 |
+
|
| 580 |
+
if (this.state.activeSideTab === 'pages') this.renderPageSidebar();
|
| 581 |
+
|
| 582 |
+
const syncStatus = uploadSuccess ? '✓ Synced' : '⚠ Local only';
|
| 583 |
+
this.ui.showToast(`Imported PDF page ${pageNum} ${syncStatus}`);
|
| 584 |
+
|
| 585 |
+
} catch (e) {
|
| 586 |
+
console.error('PDF page import error:', e);
|
| 587 |
+
this.ui.showToast(`Import failed: ${e.message}`);
|
| 588 |
+
} finally {
|
| 589 |
+
this.ui.toggleLoader(false);
|
| 590 |
+
}
|
| 591 |
+
},
|
| 592 |
+
|
| 593 |
// =============================================
|
| 594 |
// REFACTORED HELPERS (Phase 1 - Core Helpers)
|
| 595 |
// =============================================
|
|
|
|
| 1639 |
}
|
| 1640 |
}
|
| 1641 |
|
| 1642 |
+
// CRITICAL: Upload the page blob to R2 so other users can fetch it
|
| 1643 |
+
const uploadSuccess = await this._uploadPageBlob(pageObj.pageId, pageObj.blob);
|
| 1644 |
+
if (!uploadSuccess) {
|
| 1645 |
+
console.warn('[SVG Import] Failed to upload page blob to R2');
|
| 1646 |
+
}
|
| 1647 |
+
|
| 1648 |
+
// Sync page structure to R2 and Liveblocks
|
| 1649 |
+
await this._syncPageStructureToServer();
|
| 1650 |
+
this._syncPageStructureToLive();
|
| 1651 |
+
|
| 1652 |
// Notify Liveblocks that this page has base history (metadata only, no data)
|
| 1653 |
if (this.liveSync && !this.liveSync.isInitializing) {
|
| 1654 |
this.liveSync.updatePageMetadata(idx, { hasBaseHistory: true, baseHistoryCount: toSync.length });
|
|
|
|
| 1659 |
idx++;
|
| 1660 |
|
| 1661 |
const modeText = importOptions.asInfinite ? 'as infinite canvas ∞' : '';
|
| 1662 |
+
const syncStatus = uploadSuccess ? '✓ Synced' : '⚠ Local only';
|
| 1663 |
+
this.ui.showToast(`SVG imported ${modeText} (${toSync.length} editable items) ${syncStatus}`);
|
| 1664 |
console.log(`SVG imported: ${toRasterize.length} items rasterized, ${toSync.length} items stored in R2, infinite=${importOptions.asInfinite}`);
|
| 1665 |
} catch (e) {
|
| 1666 |
console.error('SVG import error:', e);
|
worker/pdfToSvg.ts
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* PDF to SVG Conversion Handler (Experimental)
|
| 3 |
+
*
|
| 4 |
+
* This module handles server-side PDF to SVG conversion using external services
|
| 5 |
+
* or libraries. Due to Cloudflare Workers limitations, we use a queue-based
|
| 6 |
+
* approach for processing.
|
| 7 |
+
*
|
| 8 |
+
* Flow:
|
| 9 |
+
* 1. Client uploads PDF
|
| 10 |
+
* 2. Server stores PDF in R2 and creates a job
|
| 11 |
+
* 3. Job processes PDF pages to SVG (via external service or scheduled task)
|
| 12 |
+
* 4. Client polls for job status and downloads results
|
| 13 |
+
*/
|
| 14 |
+
|
| 15 |
+
interface PdfJob {
|
| 16 |
+
id: string;
|
| 17 |
+
roomId: string;
|
| 18 |
+
status: 'pending' | 'processing' | 'completed' | 'failed';
|
| 19 |
+
pageCount: number;
|
| 20 |
+
processedPages: number;
|
| 21 |
+
error?: string;
|
| 22 |
+
createdAt: number;
|
| 23 |
+
updatedAt: number;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
interface Env {
|
| 27 |
+
TLDRAW_BUCKET: R2Bucket;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
/**
|
| 31 |
+
* Upload a PDF file and create a conversion job
|
| 32 |
+
*/
|
| 33 |
+
export async function handlePdfUpload(request: Request, env: Env): Promise<Response> {
|
| 34 |
+
try {
|
| 35 |
+
const url = new URL(request.url);
|
| 36 |
+
const roomId = url.pathname.split('/')[4]; // /api/color_rm/pdf/:roomId
|
| 37 |
+
|
| 38 |
+
if (!roomId) {
|
| 39 |
+
return new Response(JSON.stringify({ error: 'Room ID required' }), {
|
| 40 |
+
status: 400,
|
| 41 |
+
headers: { 'Content-Type': 'application/json' }
|
| 42 |
+
});
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
const formData = await request.formData();
|
| 46 |
+
const file = formData.get('file') as File;
|
| 47 |
+
|
| 48 |
+
if (!file) {
|
| 49 |
+
return new Response(JSON.stringify({ error: 'No file provided' }), {
|
| 50 |
+
status: 400,
|
| 51 |
+
headers: { 'Content-Type': 'application/json' }
|
| 52 |
+
});
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
// Generate job ID
|
| 56 |
+
const jobId = `pdf_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
| 57 |
+
|
| 58 |
+
// Store PDF in R2
|
| 59 |
+
const pdfKey = `pdf_jobs/${roomId}/${jobId}/source.pdf`;
|
| 60 |
+
await env.TLDRAW_BUCKET.put(pdfKey, file);
|
| 61 |
+
|
| 62 |
+
// Create job metadata
|
| 63 |
+
const job: PdfJob = {
|
| 64 |
+
id: jobId,
|
| 65 |
+
roomId,
|
| 66 |
+
status: 'pending',
|
| 67 |
+
pageCount: 0,
|
| 68 |
+
processedPages: 0,
|
| 69 |
+
createdAt: Date.now(),
|
| 70 |
+
updatedAt: Date.now()
|
| 71 |
+
};
|
| 72 |
+
|
| 73 |
+
// Store job metadata
|
| 74 |
+
const jobKey = `pdf_jobs/${roomId}/${jobId}/job.json`;
|
| 75 |
+
await env.TLDRAW_BUCKET.put(jobKey, JSON.stringify(job));
|
| 76 |
+
|
| 77 |
+
// Note: Actual PDF processing would be triggered here
|
| 78 |
+
// Options:
|
| 79 |
+
// 1. Use a Queue (Cloudflare Queues) to process async
|
| 80 |
+
// 2. Call an external PDF-to-SVG service API
|
| 81 |
+
// 3. Use a scheduled worker with pdf.js or similar
|
| 82 |
+
|
| 83 |
+
// For now, we return the job ID for the client to poll
|
| 84 |
+
return new Response(JSON.stringify({
|
| 85 |
+
jobId,
|
| 86 |
+
status: 'pending',
|
| 87 |
+
message: 'PDF uploaded. Processing will begin shortly.',
|
| 88 |
+
pollUrl: `/api/color_rm/pdf/${roomId}/job/${jobId}`
|
| 89 |
+
}), {
|
| 90 |
+
headers: { 'Content-Type': 'application/json' }
|
| 91 |
+
});
|
| 92 |
+
|
| 93 |
+
} catch (e) {
|
| 94 |
+
console.error('PDF upload error:', e);
|
| 95 |
+
return new Response(JSON.stringify({ error: 'Upload failed' }), {
|
| 96 |
+
status: 500,
|
| 97 |
+
headers: { 'Content-Type': 'application/json' }
|
| 98 |
+
});
|
| 99 |
+
}
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
/**
|
| 103 |
+
* Get job status
|
| 104 |
+
*/
|
| 105 |
+
export async function handlePdfJobStatus(request: Request, env: Env): Promise<Response> {
|
| 106 |
+
try {
|
| 107 |
+
const url = new URL(request.url);
|
| 108 |
+
const parts = url.pathname.split('/');
|
| 109 |
+
const roomId = parts[4];
|
| 110 |
+
const jobId = parts[6];
|
| 111 |
+
|
| 112 |
+
if (!roomId || !jobId) {
|
| 113 |
+
return new Response(JSON.stringify({ error: 'Room ID and Job ID required' }), {
|
| 114 |
+
status: 400,
|
| 115 |
+
headers: { 'Content-Type': 'application/json' }
|
| 116 |
+
});
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
const jobKey = `pdf_jobs/${roomId}/${jobId}/job.json`;
|
| 120 |
+
const jobObj = await env.TLDRAW_BUCKET.get(jobKey);
|
| 121 |
+
|
| 122 |
+
if (!jobObj) {
|
| 123 |
+
return new Response(JSON.stringify({ error: 'Job not found' }), {
|
| 124 |
+
status: 404,
|
| 125 |
+
headers: { 'Content-Type': 'application/json' }
|
| 126 |
+
});
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
const job = JSON.parse(await jobObj.text()) as PdfJob;
|
| 130 |
+
|
| 131 |
+
// List available SVG pages
|
| 132 |
+
const svgPrefix = `pdf_jobs/${roomId}/${jobId}/pages/`;
|
| 133 |
+
const listed = await env.TLDRAW_BUCKET.list({ prefix: svgPrefix });
|
| 134 |
+
const pages = listed.objects
|
| 135 |
+
.filter(obj => obj.key.endsWith('.svg'))
|
| 136 |
+
.map(obj => {
|
| 137 |
+
const pageNum = obj.key.match(/page_(\d+)\.svg/)?.[1];
|
| 138 |
+
return pageNum ? parseInt(pageNum) : null;
|
| 139 |
+
})
|
| 140 |
+
.filter(n => n !== null)
|
| 141 |
+
.sort((a, b) => (a as number) - (b as number));
|
| 142 |
+
|
| 143 |
+
return new Response(JSON.stringify({
|
| 144 |
+
...job,
|
| 145 |
+
availablePages: pages,
|
| 146 |
+
downloadUrls: pages.map(p => `/api/color_rm/pdf/${roomId}/job/${jobId}/page/${p}`)
|
| 147 |
+
}), {
|
| 148 |
+
headers: { 'Content-Type': 'application/json' }
|
| 149 |
+
});
|
| 150 |
+
|
| 151 |
+
} catch (e) {
|
| 152 |
+
console.error('Job status error:', e);
|
| 153 |
+
return new Response(JSON.stringify({ error: 'Status check failed' }), {
|
| 154 |
+
status: 500,
|
| 155 |
+
headers: { 'Content-Type': 'application/json' }
|
| 156 |
+
});
|
| 157 |
+
}
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
/**
|
| 161 |
+
* Download a converted SVG page
|
| 162 |
+
*/
|
| 163 |
+
export async function handlePdfPageDownload(request: Request, env: Env): Promise<Response> {
|
| 164 |
+
try {
|
| 165 |
+
const url = new URL(request.url);
|
| 166 |
+
const parts = url.pathname.split('/');
|
| 167 |
+
const roomId = parts[4];
|
| 168 |
+
const jobId = parts[6];
|
| 169 |
+
const pageNum = parts[8];
|
| 170 |
+
|
| 171 |
+
if (!roomId || !jobId || !pageNum) {
|
| 172 |
+
return new Response(JSON.stringify({ error: 'Room ID, Job ID, and page number required' }), {
|
| 173 |
+
status: 400,
|
| 174 |
+
headers: { 'Content-Type': 'application/json' }
|
| 175 |
+
});
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
const svgKey = `pdf_jobs/${roomId}/${jobId}/pages/page_${pageNum}.svg`;
|
| 179 |
+
const svgObj = await env.TLDRAW_BUCKET.get(svgKey);
|
| 180 |
+
|
| 181 |
+
if (!svgObj) {
|
| 182 |
+
return new Response(JSON.stringify({ error: 'Page not found' }), {
|
| 183 |
+
status: 404,
|
| 184 |
+
headers: { 'Content-Type': 'application/json' }
|
| 185 |
+
});
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
return new Response(await svgObj.text(), {
|
| 189 |
+
headers: {
|
| 190 |
+
'Content-Type': 'image/svg+xml',
|
| 191 |
+
'Cache-Control': 'public, max-age=31536000'
|
| 192 |
+
}
|
| 193 |
+
});
|
| 194 |
+
|
| 195 |
+
} catch (e) {
|
| 196 |
+
console.error('Page download error:', e);
|
| 197 |
+
return new Response(JSON.stringify({ error: 'Download failed' }), {
|
| 198 |
+
status: 500,
|
| 199 |
+
headers: { 'Content-Type': 'application/json' }
|
| 200 |
+
});
|
| 201 |
+
}
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
/**
|
| 205 |
+
* Process a PDF job (called by scheduled worker or queue handler)
|
| 206 |
+
* This is a placeholder - actual implementation depends on PDF library choice
|
| 207 |
+
*
|
| 208 |
+
* Options for PDF processing:
|
| 209 |
+
* 1. pdf.js (Mozilla) - Can render to canvas, need to convert to SVG
|
| 210 |
+
* 2. pdf2svg via external service (like AWS Lambda with pdf2svg binary)
|
| 211 |
+
* 3. CloudConvert API or similar commercial service
|
| 212 |
+
* 4. Self-hosted conversion service
|
| 213 |
+
*/
|
| 214 |
+
export async function processPdfJob(env: Env, roomId: string, jobId: string): Promise<void> {
|
| 215 |
+
const jobKey = `pdf_jobs/${roomId}/${jobId}/job.json`;
|
| 216 |
+
|
| 217 |
+
try {
|
| 218 |
+
// Update status to processing
|
| 219 |
+
const jobObj = await env.TLDRAW_BUCKET.get(jobKey);
|
| 220 |
+
if (!jobObj) return;
|
| 221 |
+
|
| 222 |
+
const job = JSON.parse(await jobObj.text()) as PdfJob;
|
| 223 |
+
job.status = 'processing';
|
| 224 |
+
job.updatedAt = Date.now();
|
| 225 |
+
await env.TLDRAW_BUCKET.put(jobKey, JSON.stringify(job));
|
| 226 |
+
|
| 227 |
+
// Get the PDF file
|
| 228 |
+
const pdfKey = `pdf_jobs/${roomId}/${jobId}/source.pdf`;
|
| 229 |
+
const pdfObj = await env.TLDRAW_BUCKET.get(pdfKey);
|
| 230 |
+
if (!pdfObj) {
|
| 231 |
+
throw new Error('PDF file not found');
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
// TODO: Implement actual PDF to SVG conversion
|
| 235 |
+
// This would typically involve:
|
| 236 |
+
// 1. Loading PDF with pdf.js or similar
|
| 237 |
+
// 2. Rendering each page
|
| 238 |
+
// 3. Converting to SVG format
|
| 239 |
+
// 4. Storing each page as SVG in R2
|
| 240 |
+
|
| 241 |
+
// For now, mark as completed (placeholder)
|
| 242 |
+
job.status = 'completed';
|
| 243 |
+
job.updatedAt = Date.now();
|
| 244 |
+
await env.TLDRAW_BUCKET.put(jobKey, JSON.stringify(job));
|
| 245 |
+
|
| 246 |
+
} catch (e) {
|
| 247 |
+
// Update job with error
|
| 248 |
+
const jobObj = await env.TLDRAW_BUCKET.get(jobKey);
|
| 249 |
+
if (jobObj) {
|
| 250 |
+
const job = JSON.parse(await jobObj.text()) as PdfJob;
|
| 251 |
+
job.status = 'failed';
|
| 252 |
+
job.error = e instanceof Error ? e.message : 'Unknown error';
|
| 253 |
+
job.updatedAt = Date.now();
|
| 254 |
+
await env.TLDRAW_BUCKET.put(jobKey, JSON.stringify(job));
|
| 255 |
+
}
|
| 256 |
+
}
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
/**
|
| 260 |
+
* Delete a PDF job and all associated files
|
| 261 |
+
*/
|
| 262 |
+
export async function handlePdfJobDelete(request: Request, env: Env): Promise<Response> {
|
| 263 |
+
try {
|
| 264 |
+
const url = new URL(request.url);
|
| 265 |
+
const parts = url.pathname.split('/');
|
| 266 |
+
const roomId = parts[4];
|
| 267 |
+
const jobId = parts[6];
|
| 268 |
+
|
| 269 |
+
if (!roomId || !jobId) {
|
| 270 |
+
return new Response(JSON.stringify({ error: 'Room ID and Job ID required' }), {
|
| 271 |
+
status: 400,
|
| 272 |
+
headers: { 'Content-Type': 'application/json' }
|
| 273 |
+
});
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
// List and delete all files for this job
|
| 277 |
+
const prefix = `pdf_jobs/${roomId}/${jobId}/`;
|
| 278 |
+
const listed = await env.TLDRAW_BUCKET.list({ prefix });
|
| 279 |
+
|
| 280 |
+
for (const obj of listed.objects) {
|
| 281 |
+
await env.TLDRAW_BUCKET.delete(obj.key);
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
return new Response(JSON.stringify({ success: true }), {
|
| 285 |
+
headers: { 'Content-Type': 'application/json' }
|
| 286 |
+
});
|
| 287 |
+
|
| 288 |
+
} catch (e) {
|
| 289 |
+
console.error('Job delete error:', e);
|
| 290 |
+
return new Response(JSON.stringify({ error: 'Delete failed' }), {
|
| 291 |
+
status: 500,
|
| 292 |
+
headers: { 'Content-Type': 'application/json' }
|
| 293 |
+
});
|
| 294 |
+
}
|
| 295 |
+
}
|
worker/worker.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { handleUnfurlRequest } from 'cloudflare-workers-unfurl'
|
|
| 2 |
import { AutoRouter, error, IRequest } from 'itty-router'
|
| 3 |
import { handleAssetDownload, handleAssetUpload } from './assetUploads'
|
| 4 |
import { handleColorRmDownload, handleColorRmUpload, handleColorRmDelete, handleColorRmPageUpload, handleColorRmPageDownload, handleColorRmPageDelete, handleGetPageStructure, handleSetPageStructure, handleListPages, handleColorRmHistoryUpload, handleColorRmHistoryDownload, handleColorRmHistoryDelete, handleColorRmModificationsUpload, handleColorRmModificationsDownload, handleColorRmModificationsDelete } from './colorRmAssets'
|
|
|
|
| 5 |
import { Liveblocks } from '@liveblocks/node'
|
| 6 |
|
| 7 |
// make sure our sync durable object is made available to cloudflare
|
|
@@ -357,6 +358,16 @@ const router = AutoRouter<IRequest, [env: Env, ctx: ExecutionContext]>({
|
|
| 357 |
.get('/api/color_rm/modifications/:roomId/:pageId', handleColorRmModificationsDownload)
|
| 358 |
.delete('/api/color_rm/modifications/:roomId/:pageId', handleColorRmModificationsDelete)
|
| 359 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 360 |
// --- Color RM Registry Routes ---
|
| 361 |
|
| 362 |
// Clear all projects from registry (for debugging/maintenance)
|
|
|
|
| 2 |
import { AutoRouter, error, IRequest } from 'itty-router'
|
| 3 |
import { handleAssetDownload, handleAssetUpload } from './assetUploads'
|
| 4 |
import { handleColorRmDownload, handleColorRmUpload, handleColorRmDelete, handleColorRmPageUpload, handleColorRmPageDownload, handleColorRmPageDelete, handleGetPageStructure, handleSetPageStructure, handleListPages, handleColorRmHistoryUpload, handleColorRmHistoryDownload, handleColorRmHistoryDelete, handleColorRmModificationsUpload, handleColorRmModificationsDownload, handleColorRmModificationsDelete } from './colorRmAssets'
|
| 5 |
+
import { handlePdfUpload, handlePdfJobStatus, handlePdfPageDownload, handlePdfJobDelete } from './pdfToSvg'
|
| 6 |
import { Liveblocks } from '@liveblocks/node'
|
| 7 |
|
| 8 |
// make sure our sync durable object is made available to cloudflare
|
|
|
|
| 358 |
.get('/api/color_rm/modifications/:roomId/:pageId', handleColorRmModificationsDownload)
|
| 359 |
.delete('/api/color_rm/modifications/:roomId/:pageId', handleColorRmModificationsDelete)
|
| 360 |
|
| 361 |
+
// --- PDF to SVG Conversion (Experimental) ---
|
| 362 |
+
// Upload PDF and create conversion job
|
| 363 |
+
.post('/api/color_rm/pdf/:roomId', handlePdfUpload)
|
| 364 |
+
// Get job status
|
| 365 |
+
.get('/api/color_rm/pdf/:roomId/job/:jobId', handlePdfJobStatus)
|
| 366 |
+
// Download converted SVG page
|
| 367 |
+
.get('/api/color_rm/pdf/:roomId/job/:jobId/page/:pageNum', handlePdfPageDownload)
|
| 368 |
+
// Delete job and all files
|
| 369 |
+
.delete('/api/color_rm/pdf/:roomId/job/:jobId', handlePdfJobDelete)
|
| 370 |
+
|
| 371 |
// --- Color RM Registry Routes ---
|
| 372 |
|
| 373 |
// Clear all projects from registry (for debugging/maintenance)
|