Jaimodiji commited on
Commit
55f321a
·
verified ·
1 Parent(s): 62e79e8

Upload folder using huggingface_hub

Browse files
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
- <div style="border:2px dashed var(--border); border-radius:12px; padding:40px 20px; text-align:center; cursor:pointer; transition:all 0.2s;" 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';">
1388
- <i class="bi bi-cloud-upload" style="font-size:3rem; color:#8b5cf6; display:block; margin-bottom:15px;"></i>
1389
- <div style="font-weight:600; color:white; margin-bottom:5px;">Click to upload image</div>
 
1390
  <div style="font-size:0.8rem; color:#888;">PNG, JPG, WebP supported</div>
1391
  </div>
1392
- <p style="text-align:center; font-size:0.8rem; color:#666; margin-top:15px;">
1393
- <i class="bi bi-info-circle"></i> Image will be added as a new page
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- const maskCacheKey = st.mask.src;
 
 
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 = st.mask.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
- // Create offscreen canvas for the content
 
 
 
 
 
989
  const contentCanvas = document.createElement('canvas');
990
- contentCanvas.width = w;
991
- contentCanvas.height = h;
992
  const contentCtx = contentCanvas.getContext('2d');
 
 
993
 
994
- // Draw content image
995
- contentCtx.drawImage(contentImg, 0, 0, w, h);
996
 
997
- // Create offscreen canvas for the mask (luminance to alpha conversion)
998
  const maskCanvas = document.createElement('canvas');
999
- maskCanvas.width = w;
1000
- maskCanvas.height = h;
1001
  const maskCtx = maskCanvas.getContext('2d');
 
 
1002
 
1003
- // Draw mask image
1004
- maskCtx.drawImage(maskImg, 0, 0, w, h);
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, w, h);
1009
- const contentData = contentCtx.getImageData(0, 0, w, h);
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.drawImage(contentCanvas, st.x, st.y);
 
 
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
- // Create offscreen canvas for the content
 
 
 
 
 
76
  const contentCanvas = document.createElement('canvas');
77
- contentCanvas.width = w;
78
- contentCanvas.height = h;
79
  const contentCtx = contentCanvas.getContext('2d');
 
 
80
 
81
- // Draw content image
82
- contentCtx.drawImage(contentImg, 0, 0, w, h);
83
 
84
- // Create offscreen canvas for the mask
85
  const maskCanvas = document.createElement('canvas');
86
- maskCanvas.width = w;
87
- maskCanvas.height = h;
88
  const maskCtx = maskCanvas.getContext('2d');
 
 
89
 
90
- // Draw mask image
91
- maskCtx.drawImage(maskImg, 0, 0, w, h);
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, w, h);
96
- const contentData = contentCtx.getImageData(0, 0, w, h);
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.drawImage(contentCanvas, x, y);
 
 
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
- maskImg.src = item.mask.src;
 
 
126
  });
127
  },
128
 
@@ -226,7 +240,14 @@ export const ColorRmSession = {
226
  await this.dbPut('pages', pageObj);
227
  this.state.images.push(pageObj);
228
 
229
- // Sync to LiveSync
 
 
 
 
 
 
 
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
- this.ui.showToast(`SVG imported as infinite canvas (${toSync.length} editable items)`);
 
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
- this.ui.showToast(`SVG imported ${modeText} (${toSync.length} editable items)`);
 
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)