Jaimodiji commited on
Commit
deefa8e
·
verified ·
1 Parent(s): f751881

Upload folder using huggingface_hub

Browse files
client/utils/exportUtils.ts CHANGED
@@ -73,7 +73,7 @@ export const exportToImage = async (editor: Editor, roomId: string, format: 'png
73
  const result = await editor.toImage(shapeIds, {
74
  format: 'svg',
75
  background: true,
76
- scale: 5,
77
  padding: 32,
78
  })
79
 
 
73
  const result = await editor.toImage(shapeIds, {
74
  format: 'svg',
75
  background: true,
76
+ scale: 1,
77
  padding: 32,
78
  })
79
 
public/color_rm.html CHANGED
@@ -384,7 +384,6 @@
384
  </div>
385
  <div class="picker-body">
386
  <div id="iroWheel"></div>
387
- <div id="pickerSwatches" style="display:flex; gap:6px; margin-top:10px; flex-wrap:wrap; justify-content:center; width:100%;"></div>
388
  <button id="pickerActionBtn" class="btn btn-primary" style="width:100%; margin-top:10px;">Set</button>
389
  <button id="pickerNoneBtn" class="btn" style="width:100%; margin-top:5px; display:none">Transparent</button>
390
  </div>
@@ -482,11 +481,12 @@
482
  <div id="toolSettingsPanel" style="background:rgba(0,0,0,0.2); padding:10px; border-radius:8px; margin-bottom:10px; display:none;">
483
 
484
  <div id="penOptions" style="display:none;">
485
- <div style="display:flex; gap:8px;">
486
  <div class="color-dot" style="background:#ef4444" onclick="App.setPenColor('#ef4444')"></div>
487
  <div class="color-dot" style="background:#3b82f6" onclick="App.setPenColor('#3b82f6')"></div>
488
  <div class="color-dot" style="background:#000" onclick="App.setPenColor('#000000')"></div>
489
- <button class="btn btn-sm" onclick="App.openPicker('pen')">Custom</button>
 
490
  </div>
491
  </div>
492
 
 
384
  </div>
385
  <div class="picker-body">
386
  <div id="iroWheel"></div>
 
387
  <button id="pickerActionBtn" class="btn btn-primary" style="width:100%; margin-top:10px;">Set</button>
388
  <button id="pickerNoneBtn" class="btn" style="width:100%; margin-top:5px; display:none">Transparent</button>
389
  </div>
 
481
  <div id="toolSettingsPanel" style="background:rgba(0,0,0,0.2); padding:10px; border-radius:8px; margin-bottom:10px; display:none;">
482
 
483
  <div id="penOptions" style="display:none;">
484
+ <div style="display:flex; gap:6px; align-items:center; flex-wrap:wrap;">
485
  <div class="color-dot" style="background:#ef4444" onclick="App.setPenColor('#ef4444')"></div>
486
  <div class="color-dot" style="background:#3b82f6" onclick="App.setPenColor('#3b82f6')"></div>
487
  <div class="color-dot" style="background:#000" onclick="App.setPenColor('#000000')"></div>
488
+ <div id="customSwatches" style="display:flex; gap:4px; align-items:center; flex-wrap:wrap;"></div>
489
+ <button class="btn btn-sm" onclick="App.openPicker('pen')" title="Custom Color" style="width:22px; height:22px; padding:0; justify-content:center; border-radius:4px;"><i class="bi bi-plus"></i></button>
490
  </div>
491
  </div>
492
 
public/scripts/ColorRmApp.js CHANGED
The diff for this file is too large to render. See raw diff
 
public/scripts/LiveSync.js CHANGED
@@ -16,6 +16,7 @@ export class LiveSyncClient {
16
  // Track recent local page changes to prevent sync conflicts
17
  this.lastLocalPageChange = 0;
18
  this.PAGE_CHANGE_GRACE_PERIOD = 2000; // 2 seconds grace period
 
19
  }
20
 
21
  async init(ownerId, projectId) {
@@ -137,11 +138,15 @@ export class LiveSyncClient {
137
  this.renderUsers();
138
  }
139
 
140
- updateCursor(pt) {
141
  if (!this.room) return;
142
  this.room.updatePresence({
143
  cursor: pt,
144
- pageIdx: this.app.state.idx
 
 
 
 
145
  });
146
  }
147
 
@@ -149,31 +154,122 @@ export class LiveSyncClient {
149
  const container = this.app.getElement('cursorLayer');
150
  if (!container) return;
151
 
152
- // Clear old cursors
153
- container.innerHTML = '';
 
154
 
155
  if (!this.app.state.showCursors) return;
156
 
157
  if (!this.room) return;
158
 
159
- const others = this.room.getOthers();
160
- const canvas = this.app.getElement('canvas');
 
 
 
 
 
 
 
 
 
161
  const viewport = this.app.getElement('viewport');
 
162
  if (!canvas || !viewport) return;
163
 
164
- const rect = canvas.getBoundingClientRect();
165
- const viewRect = viewport.getBoundingClientRect();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
 
167
  others.forEach(user => {
168
  const presence = user.presence;
169
- if (!presence || !presence.cursor || presence.pageIdx !== this.app.state.idx) return;
 
 
 
170
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
171
  const div = document.createElement('div');
 
172
  div.className = 'remote-cursor';
173
 
174
- // Map canvas coordinates to screen coordinates
175
- const x = (presence.cursor.x * this.app.state.zoom + this.app.state.pan.x) * (rect.width / this.app.state.viewW) + rect.left - viewRect.left;
176
- const y = (presence.cursor.y * this.app.state.zoom + this.app.state.pan.y) * (rect.height / this.app.state.viewH) + rect.top - viewRect.top;
 
 
 
 
177
 
178
  div.style.left = `${x}px`;
179
  div.style.top = `${y}px`;
@@ -185,6 +281,9 @@ export class LiveSyncClient {
185
  `;
186
  container.appendChild(div);
187
  });
 
 
 
188
  }
189
 
190
  renderUsers() {
@@ -298,6 +397,7 @@ export class LiveSyncClient {
298
  if (localImg) {
299
  localImg.history = newHist;
300
  currentIdxChanged = true;
 
301
  }
302
  }
303
 
 
16
  // Track recent local page changes to prevent sync conflicts
17
  this.lastLocalPageChange = 0;
18
  this.PAGE_CHANGE_GRACE_PERIOD = 2000; // 2 seconds grace period
19
+ this.remoteTrails = {};
20
  }
21
 
22
  async init(ownerId, projectId) {
 
138
  this.renderUsers();
139
  }
140
 
141
+ updateCursor(pt, tool, isDrawing, color, size) {
142
  if (!this.room) return;
143
  this.room.updatePresence({
144
  cursor: pt,
145
+ pageIdx: this.app.state.idx,
146
+ tool: tool,
147
+ isDrawing: isDrawing,
148
+ color: color,
149
+ size: size
150
  });
151
  }
152
 
 
154
  const container = this.app.getElement('cursorLayer');
155
  if (!container) return;
156
 
157
+ // Clear old cursors but keep canvas
158
+ const oldCursors = container.querySelectorAll('.remote-cursor');
159
+ oldCursors.forEach(el => el.remove());
160
 
161
  if (!this.app.state.showCursors) return;
162
 
163
  if (!this.room) return;
164
 
165
+ // Setup Trail Canvas
166
+ let trailCanvas = container.querySelector('#remote-trails-canvas');
167
+ if (!trailCanvas) {
168
+ trailCanvas = document.createElement('canvas');
169
+ trailCanvas.id = 'remote-trails-canvas';
170
+ trailCanvas.style.position = 'absolute';
171
+ trailCanvas.style.inset = '0';
172
+ trailCanvas.style.pointerEvents = 'none';
173
+ container.appendChild(trailCanvas);
174
+ }
175
+
176
  const viewport = this.app.getElement('viewport');
177
+ const canvas = this.app.getElement('canvas');
178
  if (!canvas || !viewport) return;
179
 
180
+ const rect = canvas.getBoundingClientRect(); // Canvas on screen rect
181
+ const viewRect = viewport.getBoundingClientRect(); // Viewport rect
182
+
183
+ // 1. Align cursorLayer to match canvas exactly (fixes alignment & trail black screen issues)
184
+ container.style.position = 'absolute';
185
+ container.style.width = rect.width + 'px';
186
+ container.style.height = rect.height + 'px';
187
+ container.style.left = (rect.left - viewRect.left) + 'px';
188
+ container.style.top = (rect.top - viewRect.top) + 'px';
189
+ container.style.inset = 'auto'; // Override CSS inset:0
190
+
191
+ // 2. Match trailCanvas resolution to main canvas internal resolution
192
+ if (trailCanvas.width !== this.app.state.viewW || trailCanvas.height !== this.app.state.viewH) {
193
+ trailCanvas.width = this.app.state.viewW;
194
+ trailCanvas.height = this.app.state.viewH;
195
+ }
196
+ trailCanvas.style.width = '100%';
197
+ trailCanvas.style.height = '100%';
198
+ trailCanvas.style.backgroundColor = 'transparent'; // Ensure transparent
199
+
200
+ const ctx = trailCanvas.getContext('2d');
201
+ // Reset transform to identity to ensure full clear
202
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
203
+ ctx.clearRect(0, 0, trailCanvas.width, trailCanvas.height);
204
+
205
+ const others = this.room.getOthers();
206
+ let hasActiveTrails = false;
207
 
208
  others.forEach(user => {
209
  const presence = user.presence;
210
+ if (!presence || !presence.cursor || presence.pageIdx !== this.app.state.idx) {
211
+ if (this.remoteTrails[user.connectionId]) delete this.remoteTrails[user.connectionId];
212
+ return;
213
+ }
214
 
215
+ // --- Draw Live Trail ---
216
+ if (presence.isDrawing && presence.tool === 'pen') {
217
+ hasActiveTrails = true;
218
+ let trail = this.remoteTrails[user.connectionId] || [];
219
+ // Add point if new
220
+ const lastPt = trail[trail.length - 1];
221
+ if (!lastPt || lastPt.x !== presence.cursor.x || lastPt.y !== presence.cursor.y) {
222
+ trail.push(presence.cursor);
223
+ }
224
+ this.remoteTrails[user.connectionId] = trail;
225
+
226
+ if (trail.length > 1) {
227
+ ctx.save();
228
+ // Transform to match main canvas state
229
+ ctx.translate(this.app.state.pan.x, this.app.state.pan.y);
230
+ ctx.scale(this.app.state.zoom, this.app.state.zoom);
231
+
232
+ ctx.beginPath();
233
+ ctx.moveTo(trail[0].x, trail[0].y);
234
+ for (let i = 1; i < trail.length; i++) ctx.lineTo(trail[i].x, trail[i].y);
235
+
236
+ ctx.lineCap = 'round';
237
+ ctx.lineJoin = 'round';
238
+ ctx.lineWidth = (presence.size || 3);
239
+
240
+ // Smooth transition: Use user's color with opacity
241
+ const hex = presence.color || '#000000';
242
+ let r=0, g=0, b=0;
243
+ if(hex.length === 4) {
244
+ r = parseInt(hex[1]+hex[1], 16);
245
+ g = parseInt(hex[2]+hex[2], 16);
246
+ b = parseInt(hex[3]+hex[3], 16);
247
+ } else if (hex.length === 7) {
248
+ r = parseInt(hex.slice(1,3), 16);
249
+ g = parseInt(hex.slice(3,5), 16);
250
+ b = parseInt(hex.slice(5,7), 16);
251
+ }
252
+ ctx.strokeStyle = `rgba(${r}, ${g}, ${b}, 0.5)`;
253
+
254
+ ctx.stroke();
255
+ ctx.restore();
256
+ }
257
+ } else {
258
+ if (this.remoteTrails[user.connectionId]) delete this.remoteTrails[user.connectionId];
259
+ }
260
+
261
+ // --- Draw Cursor ---
262
  const div = document.createElement('div');
263
+ // ... (cursor drawing code) ...
264
  div.className = 'remote-cursor';
265
 
266
+ // Map canvas coordinates to screen coordinates relative to cursorLayer (which now matches canvas)
267
+ // x_screen = (x_internal * zoom + pan) * (screen_width / internal_width)
268
+ const scaleX = rect.width / this.app.state.viewW;
269
+ const scaleY = rect.height / this.app.state.viewH;
270
+
271
+ const x = (presence.cursor.x * this.app.state.zoom + this.app.state.pan.x) * scaleX;
272
+ const y = (presence.cursor.y * this.app.state.zoom + this.app.state.pan.y) * scaleY;
273
 
274
  div.style.left = `${x}px`;
275
  div.style.top = `${y}px`;
 
281
  `;
282
  container.appendChild(div);
283
  });
284
+
285
+ // Hide trail canvas if no active trails to minimize risk of obstruction
286
+ trailCanvas.style.display = hasActiveTrails ? 'block' : 'none';
287
  }
288
 
289
  renderUsers() {
 
397
  if (localImg) {
398
  localImg.history = newHist;
399
  currentIdxChanged = true;
400
+ if (this.app.invalidateCache) this.app.invalidateCache();
401
  }
402
  }
403
 
public/scripts/modules/ColorRmBox.js ADDED
@@ -0,0 +1,351 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const ColorRmBox = {
2
+ // --- The Clipboard Box Feature ---
3
+ // Now uses Blobs instead of base64 for ~10x memory savings
4
+ addToBox(x, y, w, h, srcOrBlob=null, pageIdx=null) {
5
+ const createItem = (blob) => {
6
+ if(!this.state.clipboardBox) this.state.clipboardBox = [];
7
+ this.state.clipboardBox.push({
8
+ id: Date.now() + Math.random(),
9
+ blob: blob, // Store as Blob, not base64
10
+ blobUrl: null, // Lazy-create URL when rendering
11
+ w: w, h: h,
12
+ pageIdx: (pageIdx !== null) ? pageIdx : this.state.idx
13
+ });
14
+
15
+ this.ui.showToast("Added to Box!");
16
+ this.saveSessionState();
17
+ if(this.state.activeSideTab === 'box') this.renderBox();
18
+ };
19
+
20
+ // If a Blob was passed directly
21
+ if (srcOrBlob instanceof Blob) {
22
+ createItem(srcOrBlob);
23
+ return;
24
+ }
25
+
26
+ // If a base64 dataURL was passed (legacy support), convert to Blob
27
+ if (srcOrBlob && typeof srcOrBlob === 'string' && srcOrBlob.startsWith('data:')) {
28
+ fetch(srcOrBlob)
29
+ .then(res => res.blob())
30
+ .then(blob => createItem(blob));
31
+ return;
32
+ }
33
+
34
+ // Capture from canvas
35
+ const cvs = this.getElement('canvas');
36
+ const ctx = cvs.getContext('2d');
37
+ const id = ctx.getImageData(x, y, w, h);
38
+ const tmp = document.createElement('canvas');
39
+ tmp.width = w; tmp.height = h;
40
+ tmp.getContext('2d').putImageData(id, 0, 0);
41
+
42
+ // Use toBlob instead of toDataURL
43
+ tmp.toBlob(blob => {
44
+ createItem(blob);
45
+ }, 'image/jpeg', 0.85);
46
+ },
47
+
48
+ captureFullPage() {
49
+ const cvs = this.getElement('canvas');
50
+ this.addToBox(0, 0, cvs.width, cvs.height);
51
+ },
52
+
53
+ async addRangeToBox() {
54
+ const txt = this.getElement('boxRangeInput').value.trim();
55
+ if(!txt) return alert("Please enter a range (e.g. 1, 3-5)");
56
+
57
+ const indices = [];
58
+ const set = new Set();
59
+ txt.split(',').forEach(p => {
60
+ if(p.includes('-')) {
61
+ const [s,e] = p.split('-').map(n=>parseInt(n));
62
+ if(!isNaN(s) && !isNaN(e)) for(let k=s; k<=e; k++) if(k>0 && k<=this.state.images.length) set.add(k-1);
63
+ } else { const n=parseInt(p); if(!isNaN(n) && n>0 && n<=this.state.images.length) set.add(n-1); }
64
+ });
65
+ indices.push(...Array.from(set).sort((a,b)=>a-b));
66
+
67
+ if(indices.length === 0) return alert("No valid pages found in range");
68
+
69
+ this.ui.toggleLoader(true, "Capturing Pages...");
70
+ const cvs = document.createElement('canvas');
71
+ const ctx = cvs.getContext('2d');
72
+
73
+ for(let i=0; i<indices.length; i++) {
74
+ const idx = indices[i];
75
+ this.ui.updateProgress((i/indices.length)*100, `Processing Page ${idx+1}`);
76
+ const item = this.state.images[idx];
77
+
78
+ // Render Page to Canvas
79
+ const img = new Image();
80
+ img.src = URL.createObjectURL(item.blob);
81
+ await new Promise(r => img.onload = r);
82
+
83
+ cvs.width = img.width; cvs.height = img.height;
84
+ ctx.drawImage(img, 0, 0);
85
+
86
+ // Apply Edits (History) to Canvas
87
+ if(item.history && item.history.length > 0) {
88
+ item.history.forEach(st => {
89
+ ctx.save();
90
+ if(st.rotation && st.tool!=='pen') {
91
+ const cx = st.x + st.w/2; const cy = st.y + st.h/2;
92
+ ctx.translate(cx, cy); ctx.rotate(st.rotation); ctx.translate(-cx, -cy);
93
+ }
94
+ if(st.tool === 'text') { ctx.fillStyle = st.color; ctx.font = `${st.size}px sans-serif`; ctx.textBaseline = 'top'; ctx.fillText(st.text, st.x, st.y); }
95
+ else if(st.tool === 'shape') {
96
+ ctx.strokeStyle = st.border; ctx.lineWidth = st.width; if(st.fill!=='transparent') { ctx.fillStyle=st.fill; }
97
+ ctx.beginPath(); const {x,y,w,h} = st;
98
+ if(st.shapeType==='rectangle') ctx.rect(x,y,w,h); else if(st.shapeType==='circle') ctx.ellipse(x+w/2, y+h/2, Math.abs(w/2), Math.abs(h/2), 0, 0, 2*Math.PI); else if(st.shapeType==='line') { ctx.moveTo(x,y); ctx.lineTo(x+w,y+h); }
99
+ if(st.fill!=='transparent' && !['line','arrow'].includes(st.shapeType)) ctx.fill(); ctx.stroke();
100
+ } else {
101
+ ctx.lineCap='round'; ctx.lineJoin='round'; ctx.lineWidth=st.size; ctx.strokeStyle = st.tool==='eraser' ? '#000' : st.color; if(st.tool==='eraser') ctx.globalCompositeOperation='destination-out';
102
+ ctx.beginPath(); if(st.pts.length) ctx.moveTo(st.pts[0].x, st.pts[0].y); for(let j=1; j<st.pts.length; j++) ctx.lineTo(st.pts[j].x, st.pts[j].y); ctx.stroke();
103
+ }
104
+ ctx.restore();
105
+ });
106
+ }
107
+
108
+ // Use toBlob instead of toDataURL for memory efficiency
109
+ const blob = await new Promise(r => cvs.toBlob(r, 'image/jpeg', 0.85));
110
+ this.addToBox(0, 0, cvs.width, cvs.height, blob, idx);
111
+ await new Promise(r => setTimeout(r, 0));
112
+ }
113
+
114
+ this.ui.toggleLoader(false);
115
+ this.getElement('boxRangeInput').value = '';
116
+ },
117
+
118
+ renderBox() {
119
+ const el = this.getElement('boxList');
120
+ if (!el) return;
121
+
122
+ // Revoke old blob URLs to prevent memory leaks
123
+ if (this.boxBlobUrls) {
124
+ this.boxBlobUrls.forEach(url => URL.revokeObjectURL(url));
125
+ }
126
+ this.boxBlobUrls = [];
127
+
128
+ el.innerHTML = '';
129
+ const countEl = this.getElement('boxCount');
130
+ if (countEl) countEl.innerText = (this.state.clipboardBox || []).length;
131
+
132
+ if(!this.state.clipboardBox || this.state.clipboardBox.length === 0) {
133
+ el.innerHTML = '<div style="grid-column:1/-1; color:#666; text-align:center; padding:20px;">Box is empty. Use Capture Tool or Add Full Page.</div>';
134
+ return;
135
+ }
136
+
137
+ this.state.clipboardBox.forEach((item, idx) => {
138
+ const div = document.createElement('div');
139
+ div.className = 'box-item';
140
+ const im = new Image();
141
+
142
+ // Support both new Blob format and legacy base64 src format
143
+ if (item.blob) {
144
+ const url = URL.createObjectURL(item.blob);
145
+ this.boxBlobUrls.push(url);
146
+ im.src = url;
147
+ } else if (item.src) {
148
+ im.src = item.src; // Legacy base64 support
149
+ }
150
+
151
+ div.appendChild(im);
152
+
153
+ const btn = document.createElement('button');
154
+ btn.className = 'box-del';
155
+ btn.innerHTML = '<i class="bi bi-trash"></i>';
156
+ btn.onclick = () => {
157
+ // Revoke the URL for this item if it has one
158
+ if (item.blob && item.blobUrl) {
159
+ URL.revokeObjectURL(item.blobUrl);
160
+ }
161
+ this.state.clipboardBox.splice(idx, 1);
162
+ this.saveSessionState();
163
+ this.renderBox();
164
+ };
165
+ div.appendChild(btn);
166
+ el.appendChild(div);
167
+ });
168
+ },
169
+
170
+ clearBox() {
171
+ if(confirm("Clear all items in Box?")) {
172
+ // Revoke all blob URLs
173
+ if (this.boxBlobUrls) {
174
+ this.boxBlobUrls.forEach(url => URL.revokeObjectURL(url));
175
+ this.boxBlobUrls = [];
176
+ }
177
+ this.state.clipboardBox = [];
178
+ this.saveSessionState();
179
+ this.renderBox();
180
+ }
181
+ },
182
+
183
+ addBoxTag(t, area) {
184
+ const id = area === 'header' ? 'boxHeaderTxt' : 'boxLabelTxt';
185
+ const el = this.getElement(id);
186
+ if(el) el.value += " " + t;
187
+ },
188
+
189
+ processTags(text, context = {}) {
190
+ const now = new Date();
191
+ const days = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
192
+ let res = text.replace('{date}', now.toLocaleDateString())
193
+ .replace('{day}', days[now.getDay()])
194
+ .replace('{time}', now.toLocaleTimeString())
195
+ .replace('{count}', (this.state.clipboardBox||[]).length);
196
+
197
+ if(context.seq !== undefined) res = res.replace('{seq}', context.seq);
198
+ if(context.page !== undefined) res = res.replace('{page}', context.page);
199
+
200
+ return res;
201
+ },
202
+
203
+ async generateBoxImage() {
204
+ if(!this.state.clipboardBox || this.state.clipboardBox.length === 0) return alert("Box is empty");
205
+
206
+ this.ui.toggleLoader(true, "Generating Sheets...");
207
+
208
+ const cols = parseInt(this.getElement('boxCols').value);
209
+ const pad = 30;
210
+ const A4W = 2480;
211
+ const A4H = 3508;
212
+ const colW = (A4W - (pad * (cols + 1))) / cols;
213
+
214
+ // Configs
215
+ const practiceOn = this.getElement('boxPracticeOn').checked;
216
+ const practiceCol = this.getElement('boxPracticeColor').value;
217
+ const labelsOn = this.getElement('boxLabelsOn').checked;
218
+ const labelPos = this.getElement('boxLabelsPos').value;
219
+ const labelTxt = this.getElement('boxLabelTxt').value;
220
+ const labelH = labelsOn ? 60 : 0;
221
+
222
+ // Pagination State
223
+ let pages = [];
224
+ let currentCanvas = document.createElement('canvas');
225
+ currentCanvas.width = A4W; currentCanvas.height = A4H;
226
+ let ctx = currentCanvas.getContext('2d');
227
+
228
+ // Helper to start new page
229
+ const initPage = () => {
230
+ ctx.fillStyle = "#ffffff"; ctx.fillRect(0,0, A4W, A4H);
231
+ return this.getElement('boxHeaderOn').checked ? 150 : pad;
232
+ };
233
+
234
+ let currentY = initPage();
235
+
236
+ // 1. Organize into Rows
237
+ const rows = [];
238
+ for(let i = 0; i < this.state.clipboardBox.length; i += cols) {
239
+ const rowItems = this.state.clipboardBox.slice(i, i + cols);
240
+
241
+ // Calculate Heights
242
+ const effectiveImgW = practiceOn ? (colW/2 - 10) : colW;
243
+
244
+ const rowHeights = rowItems.map(item => item.h * (effectiveImgW / item.w));
245
+ const maxRowH = Math.max(...rowHeights);
246
+
247
+ rows.push({
248
+ items: rowItems.map((item, idx) => ({
249
+ item,
250
+ finalH: item.h * (effectiveImgW / item.w),
251
+ seq: i + idx + 1
252
+ })),
253
+ height: maxRowH + labelH + pad
254
+ });
255
+ }
256
+
257
+ // 2. Draw Loop
258
+ for(let r=0; r<rows.length; r++) {
259
+ const row = rows[r];
260
+
261
+ // Check Pagination
262
+ if((currentY + row.height + (this.getElement('boxFooterOn').checked ? 100 : 0)) > A4H) {
263
+ this.drawHeaderFooter(ctx, A4W, A4H);
264
+ pages.push(currentCanvas);
265
+
266
+ currentCanvas = document.createElement('canvas');
267
+ currentCanvas.width = A4W; currentCanvas.height = A4H;
268
+ ctx = currentCanvas.getContext('2d');
269
+ currentY = initPage();
270
+ }
271
+
272
+ // Draw Row
273
+ for(let c=0; c<row.items.length; c++) {
274
+ const {item, finalH, seq} = row.items[c];
275
+ const x = pad + (c * colW);
276
+ const y = currentY;
277
+
278
+ // Draw Image
279
+ const im = new Image();
280
+ if (item.blob) {
281
+ im.src = URL.createObjectURL(item.blob);
282
+ } else {
283
+ im.src = item.src;
284
+ }
285
+ await new Promise(res => im.onload = res);
286
+
287
+ const effectiveImgW = practiceOn ? (colW/2 - 10) : colW;
288
+ ctx.drawImage(im, x, y, effectiveImgW, finalH);
289
+
290
+ if (item.blob) URL.revokeObjectURL(im.src);
291
+
292
+ // Draw Practice Space
293
+ if (practiceOn) {
294
+ ctx.fillStyle = practiceCol === 'black' ? '#000000' : '#f0f0f0';
295
+ ctx.fillRect(x + colW/2, y, colW/2 - 10, finalH);
296
+ }
297
+
298
+ // Draw Label
299
+ if (labelsOn) {
300
+ ctx.fillStyle = "#333333";
301
+ ctx.font = "24px Arial";
302
+ ctx.textAlign = "center";
303
+ const labelY = labelPos === 'top' ? y - 10 : y + finalH + 30;
304
+ ctx.fillText(this.processTags(labelTxt, {seq, page: pages.length + 1}), x + colW/2, labelY);
305
+ }
306
+ }
307
+ currentY += row.height;
308
+ }
309
+
310
+ // Finalize last page
311
+ this.drawHeaderFooter(ctx, A4W, A4H);
312
+ pages.push(currentCanvas);
313
+
314
+ this.ui.toggleLoader(false);
315
+
316
+ // 3. Export to PDF
317
+ // Note: Using window.jsPDF because it's loaded via script tag
318
+ if (!window.jspdf) {
319
+ alert("jsPDF library not loaded");
320
+ return;
321
+ }
322
+ const { jsPDF } = window.jspdf;
323
+ const pdf = new jsPDF({ orientation: 'p', unit: 'mm', format: 'a4' });
324
+
325
+ for(let i=0; i<pages.length; i++) {
326
+ if(i > 0) pdf.addPage();
327
+ const data = pages[i].toDataURL('image/jpeg', 0.8);
328
+ pdf.addImage(data, 'JPEG', 0, 0, 210, 297);
329
+ }
330
+
331
+ pdf.save(`ColorRM_Sheet_${Date.now()}.pdf`);
332
+ },
333
+
334
+ drawHeaderFooter(ctx, w, h) {
335
+ ctx.fillStyle = "#333333";
336
+ ctx.textAlign = "center";
337
+ ctx.font = "30px Arial";
338
+
339
+ if(this.getElement('boxHeaderOn').checked) {
340
+ const txt = this.processTags(this.getElement('boxHeaderTxt').value);
341
+ ctx.fillText(txt, w/2, 80);
342
+ ctx.fillRect(30, 100, w-60, 2);
343
+ }
344
+
345
+ if(this.getElement('boxFooterOn').checked) {
346
+ const txt = this.processTags(this.getElement('boxFooterTxt').value);
347
+ ctx.fillRect(30, h-100, w-60, 2);
348
+ ctx.fillText(txt, w/2, h-60);
349
+ }
350
+ }
351
+ };
public/scripts/modules/ColorRmInput.js ADDED
@@ -0,0 +1,525 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const ColorRmInput = {
2
+ setTool(t) {
3
+ this.state.tool = t;
4
+ ['None','Lasso','Pen','Shape','Text','Eraser','Capture','Hand'].forEach(x => {
5
+ const el = this.getElement('tool'+x);
6
+ if(el) el.classList.toggle('active', t===x.toLowerCase());
7
+ });
8
+
9
+ const vp = this.getElement('viewport');
10
+ if(vp) {
11
+ if (t === 'hand') vp.style.cursor = 'grab';
12
+ else vp.style.cursor = 'default';
13
+ }
14
+
15
+ const tsp = this.getElement('toolSettingsPanel');
16
+ if(tsp) tsp.style.display = ['pen','shape','eraser','text'].includes(t) ? 'block' : 'none';
17
+
18
+ const po = this.getElement('penOptions');
19
+ if(po) po.style.display = t==='pen'||t==='text'?'block':'none';
20
+
21
+ const so = this.getElement('shapeOptions');
22
+ if(so) so.style.display = t==='shape'?'block':'none';
23
+
24
+ const eo = this.getElement('eraserOptions');
25
+ if(eo) eo.style.display = t==='eraser'?'block':'none';
26
+
27
+ const range = this.getElement('brushSize');
28
+ const label = this.getElement('sizeLabel');
29
+ if(label) label.innerText = "Size";
30
+
31
+ if(range) {
32
+ if(t === 'pen') { range.value = this.state.penSize; }
33
+ else if(t === 'eraser') { range.value = this.state.eraserSize; }
34
+ else if(t === 'shape') { range.value = this.state.shapeWidth; if(label) label.innerText = "Border Width"; }
35
+ else if(t === 'text') { range.value = this.state.textSize; if(label) label.innerText = "Text Size"; }
36
+ }
37
+
38
+ if(['pen','shape','eraser','text','capture'].includes(t)) {
39
+ this.state.selection = [];
40
+ const tb = this.getElement('contextToolbar');
41
+ if(tb) tb.style.display = 'none';
42
+ this.render();
43
+ }
44
+ },
45
+
46
+ undo() {
47
+ const img = this.state.images[this.state.idx];
48
+ if(img.history.length > 0) {
49
+ if(!img.redo) img.redo = [];
50
+ img.redo.push(img.history.pop());
51
+ this.saveCurrentImg(); this.render();
52
+ }
53
+ },
54
+
55
+ redo() {
56
+ const img = this.state.images[this.state.idx];
57
+ if(img.redo && img.redo.length > 0) {
58
+ img.history.push(img.redo.pop());
59
+ this.saveCurrentImg(); this.render();
60
+ }
61
+ },
62
+
63
+ deleteSelected() {
64
+ const img = this.state.images[this.state.idx];
65
+ this.state.selection.forEach(i => {
66
+ const item = img.history[i];
67
+ if (item) {
68
+ item.deleted = true;
69
+ item.lastMod = Date.now();
70
+ }
71
+ });
72
+ this.state.selection = [];
73
+ const tb = this.getElement('contextToolbar');
74
+ if(tb) tb.style.display = 'none';
75
+
76
+ this.invalidateCache();
77
+ this.saveCurrentImg();
78
+ this.render();
79
+ },
80
+
81
+ copySelected(cut=false) {
82
+ const img = this.state.images[this.state.idx];
83
+ const newIds = [];
84
+ this.state.selection.forEach(i => {
85
+ const item = JSON.parse(JSON.stringify(img.history[i]));
86
+ item.id = Date.now() + Math.random();
87
+ item.lastMod = Date.now();
88
+ item.deleted = false;
89
+ if(!cut) {
90
+ if(item.pts) item.pts.forEach(p=>{p.x+=20; p.y+=20});
91
+ else { item.x+=20; item.y+=20; }
92
+ }
93
+ img.history.push(item);
94
+ newIds.push(img.history.length-1);
95
+ });
96
+ if(cut) this.deleteSelected();
97
+ else {
98
+ this.state.selection = newIds;
99
+ this.saveCurrentImg();
100
+ this.render();
101
+ }
102
+ },
103
+
104
+ lockSelected() {
105
+ const img = this.state.images[this.state.idx];
106
+ this.state.selection.forEach(i => img.history[i].locked = true);
107
+ this.state.selection = [];
108
+ this.render();
109
+ },
110
+
111
+ makeDraggable() {
112
+ const el = this.getElement('floatingPicker');
113
+ if (!el) return;
114
+ let isDragging = false; let startX, startY, initLeft, initTop;
115
+ const handle = this.getElement('pickerDragHandle');
116
+ if(handle) {
117
+ handle.onmousedown = (e) => { isDragging = true; startX = e.clientX; startY = e.clientY; const r = el.getBoundingClientRect(); initLeft = r.left; initTop = r.top; };
118
+ // Use document instead of window to be more contained
119
+ // Each instance's isDragging flag prevents cross-instance interference
120
+ document.addEventListener('mousemove', (e) => { if(!isDragging) return; el.style.left = (initLeft + (e.clientX - startX)) + 'px'; el.style.top = (initTop + (e.clientY - startY)) + 'px'; });
121
+ document.addEventListener('mouseup', () => isDragging = false);
122
+ }
123
+ },
124
+
125
+ setupShortcuts() {
126
+ const target = this.container || document;
127
+
128
+ // Ensure container can receive focus if it's not the document
129
+ if (this.container && !this.container.getAttribute('tabindex')) {
130
+ this.container.setAttribute('tabindex', '0');
131
+ }
132
+
133
+ target.addEventListener('keydown', e => {
134
+ if(e.target.tagName === 'INPUT') return;
135
+ const key = e.key.toLowerCase();
136
+ if(e.key === ' ') {
137
+ e.preventDefault();
138
+ this.state.previewOn = !this.state.previewOn;
139
+ const pt = this.getElement('previewToggle');
140
+ if(pt) pt.checked = this.state.previewOn;
141
+ this.render(); this.saveSessionState();
142
+ return;
143
+ }
144
+ if((e.ctrlKey||e.metaKey) && key==='z') { e.preventDefault(); if(e.shiftKey) this.redo(); else this.undo(); }
145
+ if(key==='v') this.setTool('none'); if(key==='l') this.setTool('lasso'); if(key==='p') this.setTool('pen');
146
+ if(key==='e') this.setTool('eraser'); if(key==='s') this.setTool('shape'); if(key==='t') this.setTool('text');
147
+ if(key==='b') this.setTool('capture'); if(key==='h') this.setTool('hand');
148
+ if(e.key==='ArrowLeft') this.loadPage(this.state.idx-1); if(e.key==='ArrowRight') this.loadPage(this.state.idx+1); if(e.key==='Delete' || e.key==='Backspace') this.deleteSelected();
149
+ });
150
+ },
151
+
152
+ setupDrawing() {
153
+ import('../spen_engine.js')
154
+ .then(({ initializeSPen }) => {
155
+ const canvas = this.getElement('canvas');
156
+ if (canvas) {
157
+ console.log('Initializing S-Pen Engine for ColorRM...');
158
+ initializeSPen(canvas);
159
+ }
160
+ })
161
+ .catch(err => {
162
+ console.log('S-Pen Engine not found, skipping initialization.');
163
+ });
164
+
165
+ const c = this.getElement('canvas');
166
+ if (!c) return;
167
+
168
+ c.addEventListener('contextmenu', e => e.preventDefault());
169
+
170
+ let startPt = null; this.isDragging = false;
171
+ let dragStart = null; let startBounds = null; let startRotation = 0;
172
+ let isMovingSelection = false; let isResizing = false; let isRotating = false; let resizeHandle = null;
173
+ let initialHistoryState = []; let lassoPath = [];
174
+
175
+ // --- S-Pen Button Logic ---
176
+ let previousTool = 'pen';
177
+
178
+ // Track if this instance's canvas is currently being interacted with
179
+ const isActiveInstance = () => {
180
+ if (!this.container) return true; // Main app, no container = always active
181
+ // Check hover OR if we're actively drawing
182
+ return this.container.matches(':hover') || this.isDragging;
183
+ };
184
+
185
+ window.addEventListener('spen-button-down', () => {
186
+ if (!isActiveInstance()) return;
187
+
188
+ if (this.state.tool !== 'eraser') {
189
+ previousTool = this.state.tool;
190
+ this.setTool('eraser');
191
+ console.log('S-Pen: Switched to Eraser');
192
+ }
193
+ });
194
+ window.addEventListener('spen-button-up', () => {
195
+ if (!isActiveInstance()) return;
196
+
197
+ if (this.state.tool === 'eraser') {
198
+ this.setTool(previousTool);
199
+ console.log('S-Pen: Reverted to', previousTool);
200
+ }
201
+ });
202
+
203
+ const getPt = e => {
204
+ const r = c.getBoundingClientRect();
205
+ const screenX = (e.clientX - r.left)*(c.width/r.width);
206
+ const screenY = (e.clientY - r.top)*(c.height/r.height);
207
+ return {
208
+ x: (screenX - this.state.pan.x) / this.state.zoom,
209
+ y: (screenY - this.state.pan.y) / this.state.zoom
210
+ };
211
+ };
212
+
213
+ const getSelectionBounds = () => {
214
+ if(this.state.selection.length===0) return null;
215
+ const img = this.state.images[this.state.idx];
216
+ let minX=Infinity, minY=Infinity, maxX=-Infinity, maxY=-Infinity;
217
+ this.state.selection.forEach(idx => {
218
+ const st = img.history[idx];
219
+ let bx,by,bw,bh;
220
+ if(st.tool==='pen') { bx=st.pts[0].x; by=st.pts[0].y; let rx=bx, ry=by; st.pts.forEach(p=>{bx=Math.min(bx,p.x);by=Math.min(by,p.y);rx=Math.max(rx,p.x);ry=Math.max(ry,p.y);}); bw=rx-bx; bh=ry-by; }
221
+ else { bx=st.x; by=st.y; bw=st.w; bh=st.h; }
222
+ if(bw<0){bx+=bw; bw=-bw;} if(bh<0){by+=bh; bh=-bh;}
223
+ minX=Math.min(minX,bx); minY=Math.min(minY,by); maxX=Math.max(maxX,bx+bw); maxY=Math.max(maxY,by+bh);
224
+ });
225
+ return {minX, minY, maxX, maxY, w:maxX-minX, h:maxY-minY, cx:(minX+maxX)/2, cy:(minY+maxY)/2, maxY:maxY};
226
+ };
227
+
228
+ const hitTest = (pt) => {
229
+ const b = getSelectionBounds(); if(!b) return null;
230
+ if(Math.hypot(pt.x-b.cx, pt.y-(b.maxY+20))<15) return 'rot';
231
+ if(Math.hypot(pt.x-b.minX, pt.y-b.minY)<15) return 'tl'; if(Math.hypot(pt.x-b.maxX, pt.y-b.minY)<15) return 'tr';
232
+ if(Math.hypot(pt.x-b.minX, pt.y-b.maxY)<15) return 'bl'; if(Math.hypot(pt.x-b.maxX, pt.y-b.maxY)<15) return 'br';
233
+ if(pt.x>=b.minX && pt.x<=b.maxX && pt.y>=b.minY && pt.y<=b.maxY) return 'move';
234
+ return null;
235
+ };
236
+
237
+ const syncSidebarToSelection = () => {
238
+ if(this.state.selection.length > 0) {
239
+ const img = this.state.images[this.state.idx];
240
+ const first = img.history[this.state.selection[0]];
241
+ const slider = this.getElement('brushSize');
242
+ const label = this.getElement('sizeLabel');
243
+ const panel = this.getElement('toolSettingsPanel');
244
+ if (panel) panel.style.display = 'block';
245
+ if (slider && label) {
246
+ if(first.tool === 'pen' || first.tool === 'eraser') { slider.value = first.size; label.innerText = "Stroke Size"; }
247
+ else if(first.tool === 'shape') { slider.value = first.width; label.innerText = "Border Width"; }
248
+ else if(first.tool === 'text') { slider.value = first.size; label.innerText = "Text Size"; }
249
+ }
250
+ }
251
+ };
252
+
253
+ c.onpointerdown = e => {
254
+ if (e.pointerType === "touch" && !e.isPrimary) return;
255
+ const pt = getPt(e); startPt = pt;
256
+ this.lastScreenX = e.clientX;
257
+ this.lastScreenY = e.clientY;
258
+
259
+ // Eyedropper mode
260
+ if(this.state.eyedropperMode) {
261
+ const ctx = c.getContext('2d', {willReadFrequently: true});
262
+ const r = c.getBoundingClientRect();
263
+ const screenX = (e.clientX - r.left)*(c.width/r.width);
264
+ const screenY = (e.clientY - r.top)*(c.height/r.height);
265
+ const pixelData = ctx.getImageData(Math.floor(screenX), Math.floor(screenY), 1, 1).data;
266
+ const hex = '#' + [pixelData[0], pixelData[1], pixelData[2]].map(x => x.toString(16).padStart(2, '0')).join('');
267
+ this.state.colors.push({hex, lab: this.rgbToLab(pixelData[0], pixelData[1], pixelData[2])});
268
+ this.renderSwatches();
269
+ this.saveSessionState();
270
+ if (this.liveSync) this.liveSync.updateColors(this.state.colors);
271
+ this.state.eyedropperMode = false;
272
+ const btn = this.getElement('eyedropperBtn');
273
+ if(btn) { btn.style.background = ''; btn.style.color = ''; }
274
+ this.ui.showToast('Color added: ' + hex);
275
+ return;
276
+ }
277
+
278
+ if(this.state.tool === 'text') {
279
+ this.ui.showInput("Add Text", "Type something...", (text) => {
280
+ const img = this.state.images[this.state.idx]; const fs = this.state.textSize;
281
+ img.history.push({ id: Date.now() + Math.random(), lastMod: Date.now(), tool: 'text', text: text, x: pt.x, y: pt.y, size: fs, color: this.state.penColor, rotation: 0, w: fs*text.length*0.6, h: fs });
282
+ this.saveCurrentImg(); this.setTool('none'); this.state.selection = [img.history.length-1]; syncSidebarToSelection(); this.render();
283
+ }); return;
284
+ }
285
+
286
+ if(['none','lasso'].includes(this.state.tool) && this.state.selection.length>0) {
287
+ const hit = hitTest(pt);
288
+ if(hit) {
289
+ startBounds = getSelectionBounds();
290
+ const img = this.state.images[this.state.idx];
291
+ initialHistoryState = this.state.selection.map(i => JSON.parse(JSON.stringify(img.history[i])));
292
+ if(hit==='rot') { isRotating=true; startRotation = Math.atan2(pt.y - startBounds.cy, pt.x - startBounds.cx); }
293
+ else if(hit==='move') { isMovingSelection=true; dragStart=pt; this.dragOffset={x:0,y:0}; }
294
+ else { isResizing=true; resizeHandle=hit; }
295
+ return;
296
+ }
297
+ }
298
+
299
+ if(this.state.selection.length) {
300
+ this.state.selection=[];
301
+ const tb = this.getElement('contextToolbar');
302
+ if(tb) tb.style.display='none';
303
+ this.setTool(this.state.tool); this.render();
304
+ if(this.state.tool==='none') return;
305
+ }
306
+
307
+ this.isDragging = true;
308
+ if(this.state.tool==='lasso') lassoPath=[pt]; else if(this.state.tool!=='shape' && this.state.tool!=='capture') this.currentStroke=[pt];
309
+ };
310
+
311
+ const onPointerMove = e => {
312
+ // Scope: Only process events if this instance is active
313
+ // Check if we're dragging OR if the event target is within our container
314
+ const isOurEvent = this.isDragging ||
315
+ (this.container ? this.container.contains(e.target) : true);
316
+ if (!isOurEvent) return;
317
+
318
+ if (lastPinchDist !== null) return;
319
+ // Only process if target is our canvas or we are dragging
320
+ if (!this.isDragging && e.target !== c) return;
321
+
322
+ const pt = getPt(e);
323
+
324
+ if (this.liveSync && !this.liveSync.isInitializing) {
325
+ const isDrawing = this.isDragging && ['pen', 'eraser'].includes(this.state.tool);
326
+ this.liveSync.updateCursor(
327
+ pt,
328
+ this.state.tool,
329
+ isDrawing,
330
+ this.state.penColor,
331
+ this.state.tool === 'eraser' ? this.state.eraserSize : this.state.penSize
332
+ );
333
+ }
334
+
335
+ if(isMovingSelection) { this.dragOffset = {x:pt.x-dragStart.x, y:pt.y-dragStart.y}; this.render(); return; }
336
+
337
+ if (this.state.tool === 'hand' && this.isDragging) {
338
+ const dx = e.clientX - this.lastScreenX;
339
+ const dy = e.clientY - this.lastScreenY;
340
+ this.state.pan.x += dx;
341
+ this.state.pan.y += dy;
342
+ this.lastScreenX = e.clientX;
343
+ this.lastScreenY = e.clientY;
344
+ this.render();
345
+ return;
346
+ }
347
+
348
+ if(!this.isDragging) return;
349
+
350
+ if(this.state.tool==='lasso') { lassoPath.push(pt); this.renderLasso(c.getContext('2d'), lassoPath); }
351
+ else if(this.state.tool==='shape' || this.state.tool==='capture') {
352
+ let w=pt.x-startPt.x, h=pt.y-startPt.y;
353
+ if(this.state.tool==='shape' && (e.shiftKey || ['rectangle','circle'].includes(this.state.shapeType))) { if(e.shiftKey || Math.abs(Math.abs(w)-Math.abs(h))<15) { const s=Math.max(Math.abs(w),Math.abs(h)); w=(w<0?-1:1)*s; h=(h<0?-1:1)*s; } }
354
+ this.render();
355
+ if(this.state.tool === 'capture') {
356
+ const ctx = c.getContext('2d'); ctx.save();
357
+ ctx.strokeStyle = '#10b981'; ctx.lineWidth = 2; ctx.setLineDash([5,5]);
358
+ ctx.strokeRect(startPt.x, startPt.y, w, h); ctx.restore();
359
+ } else {
360
+ this.renderObject(c.getContext('2d'), {tool:'shape', shapeType:this.state.shapeType, x:startPt.x, y:startPt.y, w:w, h:h, border:this.state.shapeBorder, fill:this.state.shapeFill, width:this.state.shapeWidth});
361
+ }
362
+ }
363
+ else if(['pen','eraser'].includes(this.state.tool)) {
364
+ if (this.state.tool === 'eraser' && this.state.eraserType === 'stroke') {
365
+ const img = this.state.images[this.state.idx];
366
+ const eraserR = this.state.eraserSize / 2;
367
+ let changed = false;
368
+ for (let i = img.history.length - 1; i >= 0; i--) {
369
+ const st = img.history[i];
370
+ if (st.locked) continue;
371
+ let hit = false;
372
+ if (st.tool === 'pen' || st.tool === 'eraser') {
373
+ for (const p of st.pts) {
374
+ if (Math.hypot(p.x - pt.x, p.y - pt.y) < eraserR + st.size) {
375
+ hit = true; break;
376
+ }
377
+ }
378
+ } else if (st.tool === 'shape' || st.tool === 'text') {
379
+ if (pt.x >= st.x - eraserR && pt.x <= st.x + st.w + eraserR &&
380
+ pt.y >= st.y - eraserR && pt.y <= st.y + st.h + eraserR) {
381
+ hit = true;
382
+ }
383
+ }
384
+ if (hit) { st.deleted = true; st.lastMod = Date.now(); changed = true; }
385
+ }
386
+ if (changed) { this.invalidateCache(); this.scheduleSave(); this.render(); }
387
+ return;
388
+ }
389
+
390
+ this.currentStroke.push(pt); const ctx=c.getContext('2d');
391
+ ctx.save();
392
+ ctx.translate(this.state.pan.x, this.state.pan.y);
393
+ ctx.scale(this.state.zoom, this.state.zoom);
394
+ ctx.lineCap='round'; ctx.lineJoin='round'; ctx.lineWidth=this.state.tool==='eraser'?this.state.eraserSize:this.state.penSize;
395
+ ctx.strokeStyle=this.state.tool==='eraser'?(this.state.bg==='transparent'?'#000':this.state.bg):this.state.penColor;
396
+ if(this.state.tool==='eraser'&&this.state.bg==='transparent') ctx.globalCompositeOperation='destination-out';
397
+ ctx.beginPath(); ctx.moveTo(this.currentStroke[this.currentStroke.length-2].x, this.currentStroke[this.currentStroke.length-2].y); ctx.lineTo(pt.x,pt.y); ctx.stroke(); ctx.restore();
398
+ }
399
+ };
400
+
401
+ window.addEventListener('pointermove', onPointerMove);
402
+
403
+ // Only main app listens to window resize for cursor re-rendering
404
+ if (this.config.isMain) {
405
+ window.addEventListener('resize', () => this.liveSync && this.liveSync.renderCursors && this.liveSync.renderCursors());
406
+ }
407
+ const vp = this.getElement('viewport');
408
+ if(vp) vp.addEventListener('scroll', () => this.liveSync && this.liveSync.renderCursors && this.liveSync.renderCursors());
409
+
410
+ // --- Zoom & Pan Logic ---
411
+ let lastPinchDist = null;
412
+ let lastMidpoint = null;
413
+
414
+ c.addEventListener('wheel', e => {
415
+ if (e.ctrlKey) {
416
+ e.preventDefault();
417
+ const r = c.getBoundingClientRect();
418
+ const mouseX = (e.clientX - r.left) * (c.width / r.width);
419
+ const mouseY = (e.clientY - r.top) * (c.height / r.height);
420
+ const zoomSpeed = 0.001;
421
+ const delta = -e.deltaY;
422
+ const factor = Math.pow(1.1, delta / 100);
423
+ const newZoom = Math.min(Math.max(this.state.zoom * factor, 0.1), 10);
424
+ this.state.pan.x = mouseX - (mouseX - this.state.pan.x) * (newZoom / this.state.zoom);
425
+ this.state.pan.y = mouseY - (mouseY - this.state.pan.y) * (newZoom / this.state.zoom);
426
+ this.state.zoom = newZoom;
427
+ this.render();
428
+ } else if (this.state.tool === 'none' || e.shiftKey) {
429
+ e.preventDefault();
430
+ this.state.pan.x -= e.deltaX;
431
+ this.state.pan.y -= e.deltaY;
432
+ this.render();
433
+ }
434
+ }, { passive: false });
435
+
436
+ c.addEventListener('touchstart', e => {
437
+ if (e.touches.length === 2) {
438
+ this.isDragging = false;
439
+ this.currentStroke = null;
440
+ lastPinchDist = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
441
+ lastMidpoint = {
442
+ x: (e.touches[0].clientX + e.touches[1].clientX) / 2,
443
+ y: (e.touches[0].clientY + e.touches[1].clientY) / 2
444
+ };
445
+ }
446
+ }, { passive: false });
447
+
448
+ c.addEventListener('touchmove', e => {
449
+ if (e.touches.length === 2 && lastPinchDist !== null && lastMidpoint !== null) {
450
+ e.preventDefault();
451
+ const dist = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
452
+ const factor = dist / lastPinchDist;
453
+ const curMidpoint = {
454
+ x: (e.touches[0].clientX + e.touches[1].clientX) / 2,
455
+ y: (e.touches[0].clientY + e.touches[1].clientY) / 2
456
+ };
457
+ const r = c.getBoundingClientRect();
458
+ const centerX = (curMidpoint.x - r.left) * (c.width / r.width);
459
+ const centerY = (curMidpoint.y - r.top) * (c.height / r.height);
460
+ const newZoom = Math.min(Math.max(this.state.zoom * factor, 0.1), 10);
461
+ this.state.pan.x += (curMidpoint.x - lastMidpoint.x) * (c.width / r.width);
462
+ this.state.pan.y += (curMidpoint.y - lastMidpoint.y) * (c.height / r.height);
463
+ this.state.pan.x = centerX - (centerX - this.state.pan.x) * (newZoom / this.state.zoom);
464
+ this.state.pan.y = centerY - (centerY - this.state.pan.y) * (newZoom / this.state.zoom);
465
+ this.state.zoom = newZoom;
466
+ lastPinchDist = dist;
467
+ lastMidpoint = curMidpoint;
468
+ this.render();
469
+ }
470
+ }, { passive: false });
471
+
472
+ c.addEventListener('touchend', e => {
473
+ if (e.touches.length < 2) {
474
+ lastPinchDist = null;
475
+ lastMidpoint = null;
476
+ }
477
+ });
478
+
479
+ window.addEventListener('pointerup', e => {
480
+ // Scope: Only process if this instance was actively dragging or selecting
481
+ // This check prevents other instances from stealing our pointerup
482
+ const wasOurInteraction = this.isDragging || isMovingSelection || isResizing || isRotating;
483
+ if (!wasOurInteraction) return;
484
+
485
+ if(isMovingSelection) {
486
+ isMovingSelection=false;
487
+ this.state.selection.forEach(idx => { const st=this.state.images[this.state.idx].history[idx]; if(st.tool==='pen') st.pts.forEach(p=>{p.x+=this.dragOffset.x;p.y+=this.dragOffset.y}); else {st.x+=this.dragOffset.x;st.y+=this.dragOffset.y} });
488
+ this.dragOffset=null; this.saveCurrentImg(); this.render(); return;
489
+ }
490
+ if(!this.isDragging) return; this.isDragging=false;
491
+ const pt = getPt(e);
492
+ if(this.state.tool==='lasso') {
493
+ let minX=Infinity, maxX=-Infinity, minY=Infinity, maxY=-Infinity;
494
+ lassoPath.forEach(p=>{minX=Math.min(minX,p.x);maxX=Math.max(maxX,p.x);minY=Math.min(minY,p.y);maxY=Math.max(maxY,p.y);});
495
+ this.state.selection=[];
496
+ this.state.images[this.state.idx].history.forEach((st,i)=>{
497
+ if(st.locked) return; let cx,cy; if(st.tool==='pen'){cx=st.pts[0].x;cy=st.pts[0].y} else {cx=st.x+st.w/2;cy=st.y+st.h/2}
498
+ if(cx>=minX && cx<=maxX && cy>=minY && cy<=maxY) this.state.selection.push(i);
499
+ });
500
+ syncSidebarToSelection();
501
+ this.render();
502
+ } else if(this.state.tool==='shape') {
503
+ let w=pt.x-startPt.x, h=pt.y-startPt.y;
504
+ if(Math.abs(w)>2) {
505
+ this.state.images[this.state.idx].history.push({id: Date.now() + Math.random(), lastMod: Date.now(), tool:'shape', shapeType:this.state.shapeType, x:startPt.x, y:startPt.y, w:w, h:h, border:this.state.shapeBorder, fill:this.state.shapeFill, width:this.state.shapeWidth, rotation:0});
506
+ this.saveCurrentImg(); this.state.selection=[this.state.images[this.state.idx].history.length-1]; this.setTool('lasso'); syncSidebarToSelection();
507
+ }
508
+ } else if(this.state.tool==='capture') {
509
+ let w = pt.x - startPt.x, h = pt.y - startPt.y;
510
+ if(w < 0) { startPt.x += w; w = Math.abs(w); }
511
+ if(h < 0) { startPt.y += h; h = Math.abs(h); }
512
+ if(w > 5 && h > 5) this.addToBox(startPt.x, startPt.y, w, h);
513
+ this.render();
514
+ } else if(['pen','eraser'].includes(this.state.tool)) {
515
+ const newStroke = {id: Date.now() + Math.random(), lastMod: Date.now(), tool:this.state.tool, pts:this.currentStroke, color:this.state.penColor, size:this.state.tool==='eraser'?this.state.eraserSize:this.state.penSize, deleted: false};
516
+ this.state.images[this.state.idx].history.push(newStroke);
517
+ this.saveCurrentImg(true);
518
+ if (this.liveSync && !this.liveSync.isInitializing) {
519
+ this.liveSync.addStroke(this.state.idx, newStroke);
520
+ }
521
+ this.render();
522
+ }
523
+ });
524
+ }
525
+ };
public/scripts/modules/ColorRmRenderer.js ADDED
@@ -0,0 +1,299 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const ColorRmRenderer = {
2
+ // Invalidate the cached canvas (call when history changes)
3
+ invalidateCache() {
4
+ this.cache.isDirty = true;
5
+ },
6
+
7
+ // Request a render on next animation frame (throttled to 60fps)
8
+ requestRender() {
9
+ if (this.renderPending) return;
10
+ this.renderPending = true;
11
+ requestAnimationFrame(() => {
12
+ this.render();
13
+ this.renderPending = false;
14
+ });
15
+ },
16
+
17
+ // Build the cached canvas with all committed strokes
18
+ buildCommittedCache(ctx, currentImg) {
19
+ if (!this.cache.isDirty && this.cache.committedCanvas) {
20
+ return; // Cache is valid
21
+ }
22
+
23
+ const activeHistory = currentImg?.history?.filter(st => !st.deleted) || [];
24
+
25
+ // Create or resize offscreen canvas
26
+ if (!this.cache.committedCanvas ||
27
+ this.cache.committedCanvas.width !== this.state.viewW ||
28
+ this.cache.committedCanvas.height !== this.state.viewH) {
29
+ this.cache.committedCanvas = document.createElement('canvas');
30
+ this.cache.committedCanvas.width = this.state.viewW;
31
+ this.cache.committedCanvas.height = this.state.viewH;
32
+ this.cache.committedCtx = this.cache.committedCanvas.getContext('2d');
33
+ }
34
+
35
+ const cacheCtx = this.cache.committedCtx;
36
+ cacheCtx.clearRect(0, 0, this.state.viewW, this.state.viewH);
37
+
38
+ // Draw all non-selected, committed strokes to cache
39
+ activeHistory.forEach((st, idx) => {
40
+ // Skip items being dragged (they'll be drawn live)
41
+ if (this.state.selection.includes(idx)) return;
42
+ this.renderObject(cacheCtx, st, 0, 0);
43
+ });
44
+
45
+ this.cache.isDirty = false;
46
+ this.cache.lastHistoryLength = currentImg?.history?.length || 0;
47
+ },
48
+
49
+ render() {
50
+ if(!this.cache.currentImg) return;
51
+ const c = this.getElement('canvas');
52
+ if (!c) return;
53
+ const ctx = c.getContext('2d');
54
+
55
+ // Reset transform before clearing
56
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
57
+ ctx.globalCompositeOperation = 'source-over';
58
+ ctx.clearRect(0,0,c.width,c.height);
59
+
60
+ try {
61
+ ctx.save();
62
+ ctx.translate(this.state.pan.x, this.state.pan.y);
63
+ ctx.scale(this.state.zoom, this.state.zoom);
64
+
65
+ // Preview logic
66
+ if(this.state.previewOn || (this.tempHex && this.state.pickerMode==='remove')) {
67
+ let targets = this.state.colors.map(x=>x.lab);
68
+ if(this.tempHex) {
69
+ const i = parseInt(this.tempHex.slice(1), 16);
70
+ targets.push(this.rgbToLab((i>>16)&255, (i>>8)&255, i&255));
71
+ }
72
+ if(targets.length > 0) {
73
+ const tmpC = document.createElement('canvas');
74
+ tmpC.width = this.state.viewW;
75
+ tmpC.height = this.state.viewH;
76
+ const tmpCtx = tmpC.getContext('2d', {willReadFrequently: true});
77
+ tmpCtx.drawImage(this.cache.currentImg, 0, 0, this.state.viewW, this.state.viewH);
78
+ const imgD = tmpCtx.getImageData(0, 0, this.state.viewW, this.state.viewH);
79
+ const d = imgD.data;
80
+ const lab = this.cache.lab;
81
+ const sq = this.state.strict**2;
82
+ for(let i=0, j=0; i<d.length; i+=4, j+=3) {
83
+ if(d[i+3]===0) continue;
84
+ const l=lab[j], a=lab[j+1], b=lab[j+2];
85
+ let keep = false;
86
+ for(let t of targets) {
87
+ if(((l-t[0])**2 + (a-t[1])**2 + (b-t[2])**2) <= sq) { keep = true; break; }
88
+ }
89
+ if(!keep) d[i+3] = 0;
90
+ }
91
+ tmpCtx.putImageData(imgD, 0, 0);
92
+ ctx.drawImage(tmpC, 0, 0);
93
+ } else {
94
+ ctx.drawImage(this.cache.currentImg, 0, 0, this.state.viewW, this.state.viewH);
95
+ }
96
+ } else {
97
+ ctx.drawImage(this.cache.currentImg, 0, 0, this.state.viewW, this.state.viewH);
98
+ }
99
+
100
+ const currentImg = this.state.images[this.state.idx];
101
+
102
+ // Build cached canvas if needed (only rebuilds when dirty)
103
+ this.buildCommittedCache(ctx, currentImg);
104
+
105
+ // Draw the cached committed strokes
106
+ if (this.cache.committedCanvas) {
107
+ ctx.drawImage(this.cache.committedCanvas, 0, 0);
108
+ }
109
+
110
+ // Draw selected items with drag offset (these are live, not cached)
111
+ if (currentImg && currentImg.history && this.state.selection.length > 0) {
112
+ this.state.selection.forEach(idx => {
113
+ const st = currentImg.history[idx];
114
+ if (!st || st.deleted) return;
115
+ let dx = 0, dy = 0;
116
+ if (this.dragOffset) {
117
+ dx = this.dragOffset.x;
118
+ dy = this.dragOffset.y;
119
+ }
120
+ this.renderObject(ctx, st, dx, dy);
121
+ });
122
+ }
123
+
124
+ // Active stroke (being drawn right now)
125
+ if (this.isDragging && this.currentStroke && this.currentStroke.length > 1 && ['pen','eraser'].includes(this.state.tool)) {
126
+ ctx.save();
127
+ ctx.lineCap='round'; ctx.lineJoin='round';
128
+ ctx.lineWidth = this.state.tool==='eraser' ? this.state.eraserSize : this.state.penSize;
129
+ ctx.strokeStyle = this.state.tool==='eraser' ? (this.state.bg==='transparent'?'#000':this.state.bg) : this.state.penColor;
130
+ if(this.state.tool==='eraser' && this.state.bg==='transparent') ctx.globalCompositeOperation='destination-out';
131
+ ctx.beginPath();
132
+ ctx.moveTo(this.currentStroke[0].x, this.currentStroke[0].y);
133
+ for(let i=1; i<this.currentStroke.length; i++) {
134
+ ctx.lineTo(this.currentStroke[i].x, this.currentStroke[i].y);
135
+ }
136
+ ctx.stroke();
137
+ ctx.restore();
138
+ }
139
+
140
+ if(this.state.selection.length > 0) this.renderSelectionOverlay(ctx, currentImg.history);
141
+
142
+ const zb = this.getElement('zoomBtn');
143
+ if (zb) zb.innerText = Math.round(this.state.zoom * 100) + '%';
144
+
145
+ if(this.state.guideLines.length > 0) {
146
+ ctx.save();
147
+ ctx.strokeStyle = '#f472b6';
148
+ ctx.lineWidth = 1 / this.state.zoom;
149
+ ctx.setLineDash([4 / this.state.zoom, 4 / this.state.zoom]);
150
+ ctx.beginPath();
151
+ this.state.guideLines.forEach(g => {
152
+ if(g.type==='v') { ctx.moveTo(g.x, 0); ctx.lineTo(g.x, this.state.viewH); }
153
+ else { ctx.moveTo(0, g.y); ctx.lineTo(this.state.viewW, g.y); }
154
+ });
155
+ ctx.stroke();
156
+ ctx.restore();
157
+ }
158
+ } catch (e) {
159
+ console.error("Render error:", e);
160
+ } finally {
161
+ ctx.restore();
162
+ }
163
+ },
164
+
165
+ renderLasso(ctx, points) {
166
+ if(points.length < 2) return;
167
+ this.render(); // Clear and redraw base
168
+ ctx.save();
169
+ ctx.strokeStyle = '#3b82f6';
170
+ ctx.setLineDash([5, 5]);
171
+ ctx.lineWidth = 2;
172
+ ctx.beginPath();
173
+ ctx.moveTo(points[0].x, points[0].y);
174
+ for (let i = 1; i < points.length - 1; i++) {
175
+ const xc = (points[i].x + points[i + 1].x) / 2;
176
+ const yc = (points[i].y + points[i + 1].y) / 2;
177
+ ctx.quadraticCurveTo(points[i].x, points[i].y, xc, yc);
178
+ }
179
+ ctx.lineTo(points[points.length - 1].x, points[points.length - 1].y);
180
+ ctx.stroke();
181
+ ctx.restore();
182
+ },
183
+
184
+ renderObject(ctx, st, dx, dy) {
185
+ if (!st) return; // Safety check
186
+ ctx.save();
187
+ if(st.rotation && st.tool!=='pen') {
188
+ const cx = st.x + st.w/2 + dx;
189
+ const cy = st.y + st.h/2 + dy;
190
+ ctx.translate(cx, cy);
191
+ ctx.rotate(st.rotation);
192
+ ctx.translate(-cx, -cy);
193
+ }
194
+ ctx.translate(dx, dy);
195
+
196
+ if(st.tool === 'text') {
197
+ ctx.fillStyle = st.color;
198
+ ctx.font = `${st.size}px sans-serif`;
199
+ ctx.textBaseline = 'top';
200
+ ctx.fillText(st.text, st.x, st.y);
201
+ } else if(st.tool === 'shape') {
202
+ ctx.strokeStyle = st.border; ctx.lineWidth = st.width;
203
+ if(st.fill!=='transparent') { ctx.fillStyle=st.fill; }
204
+ ctx.beginPath();
205
+ const {x,y,w,h} = st;
206
+ if(st.shapeType==='rectangle') ctx.rect(x,y,w,h);
207
+ else if(st.shapeType==='circle') {
208
+ ctx.ellipse(x+w/2, y+h/2, Math.abs(w/2), Math.abs(h/2), 0, 0, 2*Math.PI);
209
+ } else if(st.shapeType==='line') { ctx.moveTo(x,y); ctx.lineTo(x+w,y+h); }
210
+ else if(st.shapeType==='arrow') {
211
+ const head=15; const ang=Math.atan2(h,w);
212
+ ctx.moveTo(x,y); ctx.lineTo(x+w,y+h);
213
+ ctx.lineTo(x+w - head*Math.cos(ang-0.5), y+h - head*Math.sin(ang-0.5));
214
+ ctx.moveTo(x+w,y+h);
215
+ ctx.lineTo(x+w - head*Math.cos(ang+0.5), y+h - head*Math.sin(ang+0.5));
216
+ }
217
+ if(st.fill!=='transparent' && !['line','arrow'].includes(st.shapeType)) ctx.fill();
218
+ ctx.stroke();
219
+ if(this.state.activeShapeRatio) {
220
+ ctx.beginPath(); ctx.strokeStyle = '#f472b6'; ctx.setLineDash([2,2]); ctx.lineWidth=1;
221
+ ctx.moveTo(x,y); ctx.lineTo(x+w, y+h); ctx.stroke();
222
+ }
223
+ } else {
224
+ // Safety check for points
225
+ if (st.pts && st.pts.length > 0) {
226
+ ctx.lineCap='round'; ctx.lineJoin='round'; ctx.lineWidth=st.size;
227
+ ctx.strokeStyle = st.tool==='eraser' ? '#000' : st.color;
228
+ if(st.tool==='eraser') ctx.globalCompositeOperation='destination-out';
229
+ ctx.beginPath();
230
+ ctx.moveTo(st.pts[0].x, st.pts[0].y);
231
+ for(let i=1; i<st.pts.length; i++) ctx.lineTo(st.pts[i].x, st.pts[i].y);
232
+ ctx.stroke();
233
+ }
234
+ }
235
+ ctx.restore();
236
+ },
237
+
238
+ renderSelectionOverlay(ctx, hist) {
239
+ let minX=Infinity, minY=Infinity, maxX=-Infinity, maxY=-Infinity;
240
+ this.state.selection.forEach(idx => {
241
+ const st = hist[idx];
242
+ let bx, by, bw, bh;
243
+ if(st.tool==='pen') {
244
+ bx=st.pts[0].x; by=st.pts[0].y; let rx=bx, ry=by;
245
+ st.pts.forEach(p=>{bx=Math.min(bx,p.x);by=Math.min(by,p.y);rx=Math.max(rx,p.x);ry=Math.max(ry,p.y);});
246
+ bw=rx-bx; bh=ry-by;
247
+ } else { bx=st.x; by=st.y; bw=st.w; bh=st.h; }
248
+
249
+ if(this.dragOffset && this.state.selection.includes(idx)) { bx+=this.dragOffset.x; by+=this.dragOffset.y; }
250
+
251
+ if(bw<0){bx+=bw; bw=-bw;} if(bh<0){by+=bh; bh=-bh;}
252
+ minX=Math.min(minX,bx); minY=Math.min(minY,by); maxX=Math.max(maxX,bx+bw); maxY=Math.max(maxY,by+bh);
253
+ });
254
+
255
+ ctx.save();
256
+ ctx.strokeStyle = '#0ea5e9'; ctx.lineWidth = 2;
257
+ ctx.strokeRect(minX, minY, maxX-minX, maxY-minY);
258
+
259
+ ctx.fillStyle = '#fff'; ctx.lineWidth = 2;
260
+ const drawHandle = (x,y) => { ctx.beginPath(); ctx.arc(x,y,5,0,2*Math.PI); ctx.fill(); ctx.stroke(); };
261
+ drawHandle(minX, minY); drawHandle(maxX, minY);
262
+ drawHandle(maxX, maxY); drawHandle(minX, maxY);
263
+
264
+ ctx.beginPath(); ctx.arc((minX+maxX)/2, maxY+20, 10, 0, 2*Math.PI);
265
+ ctx.strokeStyle='#0ea5e9'; ctx.stroke();
266
+ ctx.fillStyle='#0ea5e9'; ctx.font='16px bootstrap-icons'; ctx.fillText('\uF14B', (minX+maxX)/2-8, maxY+26);
267
+ ctx.restore();
268
+
269
+ const menu = this.getElement('contextToolbar');
270
+ const canvas = this.getElement('canvas');
271
+ if(menu && canvas) {
272
+ const cr = canvas.getBoundingClientRect();
273
+ const sx = cr.width/this.state.viewW; const sy = cr.height/this.state.viewH;
274
+
275
+ menu.style.display = 'flex';
276
+ const screenMinX = (minX * this.state.zoom + this.state.pan.x) * sx;
277
+ const screenMaxX = (maxX * this.state.zoom + this.state.pan.x) * sx;
278
+ const screenMinY = (minY * this.state.zoom + this.state.pan.y) * sy;
279
+ const screenMaxY = (maxY * this.state.zoom + this.state.pan.y) * sy;
280
+
281
+ let mx = (screenMinX + screenMaxX)/2;
282
+ let my = (screenMinY) - 50;
283
+ if(my < 10) my = (screenMaxY) + 50;
284
+
285
+ menu.style.left = (cr.left + mx - menu.offsetWidth/2) + 'px';
286
+ menu.style.top = (cr.top + my) + 'px';
287
+ }
288
+ },
289
+
290
+ rgbToLab(r,g,b) {
291
+ let r_=r/255, g_=g/255, b_=b/255;
292
+ r_ = r_>0.04045 ? Math.pow((r_+0.055)/1.055, 2.4) : r_/12.92;
293
+ g_ = g_>0.04045 ? Math.pow((g_+0.055)/1.055, 2.4) : g_/12.92;
294
+ b_ = b_>0.04045 ? Math.pow((b_+0.055)/1.055, 2.4) : b_/12.92;
295
+ let x=(r_*0.4124+g_*0.3576+b_*0.1805)/0.95047, y=(r_*0.2126+g_*0.7152+b_*0.0722), z=(r_*0.0193+g_*0.1192+b_*0.9505)/1.08883;
296
+ x = x>0.008856?Math.pow(x,1/3):(7.787*x)+16/116; y=y>0.008856?Math.pow(y,1/3):(7.787*y)+16/116; z=z>0.008856?Math.pow(z,1/3):(7.787*z)+16/116;
297
+ return [(116*y)-16, 500*(x-y), 200*(y-z)];
298
+ }
299
+ };
public/scripts/modules/ColorRmSession.js ADDED
@@ -0,0 +1,506 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const ColorRmSession = {
2
+ async retryBaseFetch() {
3
+ if (this.isFetchingBase) return;
4
+ this.isFetchingBase = true;
5
+ try {
6
+ const res = await fetch(window.Config?.apiUrl(`/api/color_rm/base_file/${this.state.sessionId}`) || `/api/color_rm/base_file/${this.state.sessionId}`);
7
+ if (res.ok) {
8
+ const blob = await res.blob();
9
+ await this.importBaseFile(blob);
10
+ console.log("Liveblocks: Base file fetch successful.");
11
+ }
12
+ } catch(e) {
13
+ console.error("Liveblocks: Base file fetch failed:", e);
14
+ } finally {
15
+ this.isFetchingBase = false;
16
+ }
17
+ },
18
+
19
+ async loadSessionList() {
20
+ const userIdEl = this.getElement('dashUserId');
21
+ const projIdEl = this.getElement('dashProjId');
22
+ if (userIdEl) userIdEl.innerText = this.liveSync.userId;
23
+ if (projIdEl) projIdEl.innerText = this.state.sessionId;
24
+
25
+ this.state.selectedSessions = new Set(); // Reset selection
26
+
27
+ const tx = this.db.transaction('sessions', 'readonly');
28
+ const req = tx.objectStore('sessions').getAll();
29
+ req.onsuccess = () => {
30
+ const l = this.getElement('sessionList');
31
+ if (!l) return;
32
+ l.innerHTML = '';
33
+
34
+ if(!req.result || req.result.length === 0) {
35
+ l.innerHTML = '<div style="color:#666;text-align:center;padding:10px">No projects found.</div>';
36
+ const editBtn = this.getElement('dashEditBtn');
37
+ if (editBtn) editBtn.style.display = 'none';
38
+ return;
39
+ }
40
+
41
+ const editBtn = this.getElement('dashEditBtn');
42
+ if (editBtn) editBtn.style.display = 'block';
43
+
44
+ req.result.sort((a,b) => b.lastMod - a.lastMod).forEach(s => {
45
+ const isMine = s.ownerId === this.liveSync.userId;
46
+ const badge = isMine ? '<span class="owner-badge">Owner</span>' : `<span class="other-badge">Shared</span>`;
47
+ const cloudIcon = s.isCloudBackedUp ? '<i class="bi bi-cloud-check-fill" style="color:var(--success); margin-left:6px;" title="Backed up to Cloud"></i>' : '';
48
+
49
+ const item = document.createElement('div');
50
+ item.className = 'session-item';
51
+ item.id = `sess_${s.id}`;
52
+ item.onclick = (e) => {
53
+ if (this.state.isMultiSelect) {
54
+ e.stopPropagation();
55
+ this.toggleSessionSelection(s.id);
56
+ } else {
57
+ this.switchProject(s.ownerId, s.id);
58
+ }
59
+ };
60
+
61
+ item.innerHTML = `
62
+ <input type="checkbox" class="session-checkbox" onclick="event.stopPropagation()" onchange="window.location.hash.includes('${s.id}') ? null : this.checked = !this.checked">
63
+ <div>
64
+ <div style="font-weight:600; color:white;">${s.name} ${badge} ${cloudIcon}</div>
65
+ <div style="font-size:0.7rem; color:#666; font-family:monospace;">${s.id}</div>
66
+ </div>
67
+ <div style="font-size:0.7rem; color:#888;">${s.pageCount} pgs</div>
68
+ `;
69
+
70
+ // Re-bind checkbox change properly since innerHTML kills listeners
71
+ const cb = item.querySelector('.session-checkbox');
72
+ if (cb) cb.onchange = () => this.toggleSessionSelection(s.id);
73
+
74
+ l.appendChild(item);
75
+ });
76
+ this.updateMultiSelectUI();
77
+ };
78
+ },
79
+
80
+ toggleMultiSelect() {
81
+ this.state.isMultiSelect = !this.state.isMultiSelect;
82
+ const list = this.getElement('sessionList');
83
+ const bar = this.getElement('multiDeleteBar');
84
+ const btn = this.getElement('dashEditBtn');
85
+
86
+ if (list) list.classList.toggle('active-multi', this.state.isMultiSelect);
87
+ if (bar) bar.classList.toggle('show', this.state.isMultiSelect);
88
+ if (btn) btn.innerHTML = this.state.isMultiSelect ? '<i class="bi bi-x-circle"></i> Cancel' : '<i class="bi bi-pencil-square"></i> Edit';
89
+
90
+ if (!this.state.isMultiSelect) {
91
+ this.state.selectedSessions.clear();
92
+ this.updateMultiSelectUI();
93
+ }
94
+ },
95
+
96
+ toggleSessionSelection(id) {
97
+ if (this.state.selectedSessions.has(id)) this.state.selectedSessions.delete(id);
98
+ else this.state.selectedSessions.add(id);
99
+ this.updateMultiSelectUI();
100
+ },
101
+
102
+ selectAllSessions() {
103
+ const tx = this.db.transaction('sessions', 'readonly');
104
+ const req = tx.objectStore('sessions').getAll();
105
+ req.onsuccess = () => {
106
+ req.result.forEach(s => this.state.selectedSessions.add(s.id));
107
+ this.updateMultiSelectUI();
108
+ };
109
+ },
110
+
111
+ updateMultiSelectUI() {
112
+ const count = this.state.selectedSessions.size;
113
+ const countEl = this.getElement('multiDeleteCount');
114
+ if (countEl) countEl.innerText = `${count} selected`;
115
+
116
+ // Update Checkboxes and classes
117
+ const list = this.getElement('sessionList');
118
+ if(!list) return;
119
+ const items = list.querySelectorAll('.session-item');
120
+ items.forEach(el => {
121
+ const idStr = el.id.replace('sess_', '');
122
+ const isSelected = this.state.selectedSessions.has(idStr) || (!isNaN(idStr) && this.state.selectedSessions.has(Number(idStr)));
123
+
124
+ el.classList.toggle('selected', isSelected);
125
+ const cb = el.querySelector('.session-checkbox');
126
+ if (cb) cb.checked = isSelected;
127
+ });
128
+ },
129
+
130
+ async deleteSelectedSessions() {
131
+ const count = this.state.selectedSessions.size;
132
+ if (count === 0) return;
133
+ if (!confirm(`Permanently delete ${count} project(s) and ALL their drawing data? This cannot be undone.`)) return;
134
+
135
+ this.ui.toggleLoader(true, "Deleting...");
136
+
137
+ const deletePromises = Array.from(this.state.selectedSessions).map(async (id) => {
138
+ if (this.registry) this.registry.delete(id);
139
+ return new Promise((resolve) => {
140
+ // 1. Delete Pages
141
+ const pagesTx = this.db.transaction('pages', 'readwrite');
142
+ const pagesStore = pagesTx.objectStore('pages');
143
+ const index = pagesStore.index('sessionId');
144
+ const pagesReq = index.getAll(id);
145
+
146
+ pagesReq.onsuccess = () => {
147
+ pagesReq.result.forEach(pg => pagesStore.delete(pg.id));
148
+
149
+ // 2. Delete Session Metadata
150
+ const sessTx = this.db.transaction('sessions', 'readwrite');
151
+ sessTx.objectStore('sessions').delete(id);
152
+ sessTx.oncomplete = () => resolve();
153
+ };
154
+ });
155
+ });
156
+
157
+ await Promise.all(deletePromises);
158
+
159
+ const deletedActive = this.state.selectedSessions.has(this.state.sessionId);
160
+
161
+ this.state.isMultiSelect = false;
162
+ this.state.selectedSessions.clear();
163
+
164
+ const editBtn = this.getElement('dashEditBtn');
165
+ if (editBtn) editBtn.innerHTML = '<i class="bi bi-pencil-square"></i> Edit';
166
+
167
+ const list = this.getElement('sessionList');
168
+ if (list) list.classList.remove('active-multi');
169
+
170
+ const bar = this.getElement('multiDeleteBar');
171
+ if (bar) bar.classList.remove('show');
172
+
173
+ if (deletedActive) {
174
+ window.location.hash = '';
175
+ location.reload();
176
+ } else {
177
+ await this.loadSessionList();
178
+ this.ui.toggleLoader(false);
179
+ }
180
+ },
181
+
182
+ async switchProject(ownerId, projectId) {
183
+ this.ui.hideDashboard();
184
+ window.location.hash = `/color_rm/${ownerId}/${projectId}`;
185
+ location.reload();
186
+ },
187
+
188
+ async loadSessionPages(id) {
189
+ return new Promise((resolve, reject) => {
190
+ const q = this.db.transaction('pages').objectStore('pages').index('sessionId').getAll(id);
191
+ q.onsuccess = () => {
192
+ this.state.images = q.result.sort((a,b)=>a.pageIndex-b.pageIndex);
193
+
194
+ // Retroactively assign IDs to legacy items
195
+ this.state.images.forEach(img => {
196
+ if (img.history) {
197
+ img.history.forEach(item => {
198
+ if (!item.id) item.id = Date.now() + '_' + Math.random();
199
+ });
200
+ }
201
+ });
202
+
203
+ console.log(`Loaded ${this.state.images.length} pages from DB.`);
204
+ const pageTotal = this.getElement('pageTotal');
205
+ if (pageTotal) pageTotal.innerText = '/ ' + this.state.images.length;
206
+
207
+ if(this.state.activeSideTab === 'pages') this.renderPageSidebar();
208
+ if(this.state.images.length > 0 && !this.cache.currentImg) this.loadPage(0);
209
+ resolve();
210
+ };
211
+ q.onerror = (e) => reject(e);
212
+ });
213
+ },
214
+
215
+ async importBaseFile(blob) {
216
+ // Simulates a file input event to reuse existing handleImport logic
217
+ const file = new File([blob], "base_document_blob", { type: blob.type });
218
+ await this.handleImport({ target: { files: [file] } }, true); // Pass true to skip upload
219
+ },
220
+
221
+ async computeFileHash(file) {
222
+ try {
223
+ const buffer = await file.arrayBuffer();
224
+ const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
225
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
226
+ return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
227
+ } catch (e) {
228
+ console.error("Hash calculation failed", e);
229
+ return null;
230
+ }
231
+ },
232
+
233
+ async handleImport(e, skipUpload = false) {
234
+ const files = e.target.files;
235
+ if(!files || !files.length) return;
236
+
237
+ // Deduplication Check
238
+ let fileHash = null;
239
+ if (!skipUpload && files[0].type.includes('pdf')) {
240
+ try {
241
+ fileHash = await this.computeFileHash(files[0]);
242
+ if (fileHash) {
243
+ const sessions = await new Promise(r => {
244
+ const tx = this.db.transaction('sessions', 'readonly');
245
+ const req = tx.objectStore('sessions').getAll();
246
+ req.onsuccess = () => r(req.result);
247
+ req.onerror = () => r([]);
248
+ });
249
+ const existing = sessions.find(s => s.fileHash === fileHash);
250
+ if (existing) {
251
+ if (confirm(`This PDF already exists as "${existing.name}". Load it instead?`)) {
252
+ this.switchProject(existing.ownerId || this.liveSync?.userId || 'local', existing.id);
253
+ return;
254
+ }
255
+ }
256
+ }
257
+ } catch (err) { console.error("Hash check error:", err); }
258
+ }
259
+
260
+ this.isUploading = true; // Set flag
261
+
262
+ // --- CRITICAL: FORCE UNIQUE PROJECT FOR EVERY NEW UPLOAD ---
263
+ const localUserId = this.liveSync?.userId || 'local';
264
+ if (!skipUpload) {
265
+ const newProjectId = `proj_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
266
+ console.log("ColorRM: Forcing new unique project key for upload:", newProjectId);
267
+ await this.createNewProject(false, newProjectId, localUserId);
268
+ } else if (!this.state.sessionId) {
269
+ // Sync case: only create if missing (Legacy support)
270
+ await this.createNewProject(false, this.state.sessionId, this.liveSync?.ownerId || localUserId);
271
+ }
272
+
273
+ this.ui.hideDashboard();
274
+ this.ui.toggleLoader(true, "Initializing...");
275
+
276
+ const nameInput = this.getElement('newProjectName');
277
+ let pName = (nameInput && nameInput.value.trim());
278
+
279
+ // Priority: 1. Manual Input, 2. Existing State, 3. File Name, 4. Fallback
280
+ if (!pName) {
281
+ if (this.state.projectName && this.state.projectName !== "Untitled" && !files[0].name.includes("base_document_blob")) {
282
+ pName = this.state.projectName;
283
+ } else {
284
+ pName = files[0].name.replace(/\\.[^/.]+$/, "");
285
+ // If it's the dummy blob name, try to use existing state name or fallback
286
+ if (pName.includes("base_document_blob")) {
287
+ pName = (this.state.projectName && this.state.projectName !== "Untitled") ? this.state.projectName : "Untitled Project";
288
+ }
289
+ }
290
+ }
291
+ if(!pName || pName === "Untitled") pName = "Untitled Project";
292
+
293
+ // --- Sync to Server ---
294
+ if (!skipUpload && this.state.sessionId) {
295
+ console.log('ColorRM Sync: Uploading base file to server for ID:', this.state.sessionId);
296
+ this.ui.toggleLoader(true, "Uploading to server...");
297
+ try {
298
+ const uploadRes = await fetch(window.Config?.apiUrl(`/api/color_rm/upload/${this.state.sessionId}`) || `/api/color_rm/upload/${this.state.sessionId}`, {
299
+ method: 'POST',
300
+ body: files[0],
301
+ headers: {
302
+ 'Content-Type': files[0].type,
303
+ 'x-project-name': encodeURIComponent(pName)
304
+ }
305
+ });
306
+ if (uploadRes.ok) {
307
+ console.log('ColorRM Sync: Base file upload successful.');
308
+ } else {
309
+ const errTxt = await uploadRes.text();
310
+ console.error('ColorRM Sync: Upload failed:', errTxt);
311
+ alert(`Upload Failed: ${errTxt}\nCollaborators won't see the document background.`);
312
+ }
313
+ } catch (err) {
314
+ console.error('ColorRM Sync: Error uploading base file:', err);
315
+ alert("Network Error: Could not upload base file to server. Collaboration will be limited.");
316
+ }
317
+ }
318
+ // -----------------------
319
+
320
+ this.state.projectName = pName;
321
+ this.state.baseFileName = files[0].name;
322
+ const titleEl = this.getElement('headerTitle');
323
+ if (titleEl) titleEl.innerText = pName;
324
+
325
+ // Ensure ownerId is set before saving
326
+ if (!this.state.ownerId) this.state.ownerId = this.liveSync?.userId || 'local';
327
+
328
+ const session = await this.dbGet('sessions', this.state.sessionId);
329
+ if(session) {
330
+ session.name = pName;
331
+ session.baseFileName = this.state.baseFileName;
332
+ session.ownerId = this.state.ownerId;
333
+ if (fileHash) session.fileHash = fileHash;
334
+ await this.dbPut('sessions', session);
335
+ } else {
336
+ // Fallback create
337
+ await this.dbPut('sessions', {
338
+ id: this.state.sessionId,
339
+ name: pName,
340
+ baseFileName: this.state.baseFileName,
341
+ pageCount: 0,
342
+ lastMod: Date.now(),
343
+ idx:0,
344
+ bookmarks: [],
345
+ clipboardBox: [],
346
+ ownerId: this.state.ownerId,
347
+ fileHash: fileHash
348
+ });
349
+ }
350
+
351
+ const processQueue = Array.from(files);
352
+ let idx = 0; // Reset for new project
353
+ const BATCH_SIZE = 5;
354
+
355
+ // Update UI immediately
356
+ if (titleEl) titleEl.innerText = pName;
357
+ this.state.images = [];
358
+
359
+ // Wrap processing in a promise to await completion
360
+ await new Promise((resolve) => {
361
+ const processNext = async () => {
362
+ if(processQueue.length === 0) {
363
+ // Update storage with final page count
364
+ const session = await this.dbGet('sessions', this.state.sessionId);
365
+ if (session) {
366
+ session.pageCount = idx;
367
+ await this.dbPut('sessions', session);
368
+ // Sync to cloud registry so it appears on other devices
369
+ if (this.registry) this.registry.upsert(session);
370
+ }
371
+
372
+ // Final reload to ensure everything is synced
373
+ await this.loadSessionPages(this.state.sessionId);
374
+
375
+ // Signal readiness to Liveblocks
376
+ if (this.liveSync && !this.liveSync.isInitializing) {
377
+ this.liveSync.updateMetadata({
378
+ name: this.state.projectName,
379
+ pageCount: idx
380
+ });
381
+ }
382
+
383
+ this.isUploading = false; // Reset flag
384
+ this.ui.toggleLoader(false);
385
+ resolve();
386
+ return;
387
+ }
388
+
389
+ const f = processQueue.shift();
390
+ if(f.type.includes('pdf')) {
391
+ try {
392
+ const d = await f.arrayBuffer();
393
+ const pdf = await pdfjsLib.getDocument(d).promise;
394
+ for(let i=1; i<=pdf.numPages; i+=BATCH_SIZE) {
395
+ const batch = [];
396
+ for(let j=0; j<BATCH_SIZE && (i+j)<=pdf.numPages; j++) {
397
+ const pNum = i+j;
398
+ batch.push(pdf.getPage(pNum).then(async page => {
399
+ const v = page.getViewport({scale:1.5});
400
+ const cvs = document.createElement('canvas'); cvs.width=v.width; cvs.height=v.height;
401
+ await page.render({canvasContext:cvs.getContext('2d'), viewport:v}).promise;
402
+ const b = await new Promise(r=>cvs.toBlob(r, 'image/jpeg', 0.8));
403
+ const pageObj = { id:`${this.state.sessionId}_${idx+j}`, sessionId:this.state.sessionId, pageIndex:idx+j, blob:b, history:[] };
404
+ await this.dbPut('pages', pageObj);
405
+ return pageObj;
406
+ }));
407
+ }
408
+ const results = await Promise.all(batch);
409
+
410
+ // INCREMENTAL UPDATE: Add results to state and update UI
411
+ this.state.images.push(...results);
412
+ this.state.images.sort((a,b) => a.pageIndex - b.pageIndex);
413
+
414
+ if (this.state.images.length > 0 && !this.cache.currentImg) {
415
+ await this.loadPage(0, false); // Load first page as soon as it's ready
416
+ }
417
+
418
+ if(this.state.activeSideTab === 'pages') this.renderPageSidebar();
419
+ const pt = this.getElement('pageTotal');
420
+ if (pt) pt.innerText = '/ ' + this.state.images.length;
421
+
422
+ idx += results.length;
423
+ this.ui.updateProgress(((i/pdf.numPages)*100), `Processing Page ${i}/${pdf.numPages}`);
424
+ await new Promise(r => setTimeout(r, 0));
425
+ }
426
+ } catch(e) { console.error(e); alert("Failed to load PDF"); }
427
+ } else {
428
+ const pageObj = { id:`${this.state.sessionId}_${idx}`, sessionId:this.state.sessionId, pageIndex:idx, blob:f, history:[] };
429
+ await this.dbPut('pages', pageObj);
430
+ this.state.images.push(pageObj);
431
+ if (this.state.images.length === 1) await this.loadPage(0, false);
432
+ if(this.state.activeSideTab === 'pages') this.renderPageSidebar();
433
+ idx++;
434
+ }
435
+ processNext();
436
+ };
437
+ processNext();
438
+ });
439
+ },
440
+
441
+ async createNewProject(openPicker = true, forceId = null, forceOwnerId = null) {
442
+ // Determine IDs (One PDF -> One Project Key in User Room)
443
+ const regUser = this.registry ? this.registry.getUsername() : null;
444
+ const ownerId = forceOwnerId || regUser || this.liveSync?.userId || 'local';
445
+ const projectId = forceId || `proj_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
446
+
447
+ // --- IMMEDIATE UI & URL UPDATE ---
448
+ this.state.ownerId = ownerId;
449
+ this.state.sessionId = projectId;
450
+ this.ui.hideDashboard();
451
+
452
+ // Only update URL hash for main app
453
+ if (this.config.isMain) {
454
+ window.location.hash = `/color_rm/${ownerId}/${projectId}`;
455
+ }
456
+
457
+ const nameInput = this.getElement('newProjectName');
458
+ const name = (nameInput && nameInput.value) || "Untitled";
459
+ this.state.projectName = name;
460
+ const titleEl = this.getElement('headerTitle');
461
+ if (titleEl) titleEl.innerText = name;
462
+
463
+ // Clear local state for fresh project
464
+ this.state.images = [];
465
+ this.state.idx = 0;
466
+ this.state.bookmarks = [];
467
+ this.state.clipboardBox = [];
468
+ const pt = this.getElement('pageTotal');
469
+ if (pt) pt.innerText = '/ 0';
470
+ if(this.state.activeSideTab === 'pages') this.renderPageSidebar();
471
+
472
+ const c = this.getElement('canvas');
473
+ if(c) c.getContext('2d').clearRect(0,0,c.width,c.height);
474
+ // ----------------------------
475
+
476
+ this.ui.setSyncStatus('new');
477
+
478
+ if(openPicker) {
479
+ const fileIn = this.getElement('fileIn');
480
+ if (fileIn) fileIn.click();
481
+ }
482
+
483
+ // Initialize LiveSync with the Owner's Room and this Project Key
484
+ if (this.liveSync) {
485
+ await this.liveSync.init(ownerId, projectId);
486
+ }
487
+ },
488
+
489
+ async reuploadBaseFile() {
490
+ if (this.state.images.length > 0 && this.state.images[0].blob) {
491
+ this.ui.showToast("Re-uploading base...");
492
+ try {
493
+ await fetch(window.Config?.apiUrl(`/api/color_rm/upload/${this.state.sessionId}`) || `/api/color_rm/upload/${this.state.sessionId}`, {
494
+ method: 'POST',
495
+ body: this.state.images[0].blob,
496
+ headers: { 'Content-Type': this.state.images[0].blob.type }
497
+ });
498
+ this.ui.showToast("Base file restored!");
499
+ } catch(e) {
500
+ this.ui.showToast("Restore failed");
501
+ }
502
+ } else {
503
+ alert("No local file to upload.");
504
+ }
505
+ }
506
+ };
public/scripts/modules/ColorRmStorage.js ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const ColorRmStorage = {
2
+ async dbPut(s, v) { return new Promise(r=>{const t=this.db.transaction(s,'readwrite'); t.objectStore(s).put(v); t.oncomplete=()=>r()}); },
3
+
4
+ async dbGet(s, k) { return new Promise(r=>{const q=this.db.transaction(s,'readonly').objectStore(s).get(k);q.onsuccess=()=>r(q.result)}); },
5
+
6
+ async saveSessionState() {
7
+ if(!this.state.sessionId || (this.liveSync && this.liveSync.isInitializing) || this.isUploading) return;
8
+
9
+ // Save Locally
10
+ const s = await this.dbGet('sessions', this.state.sessionId);
11
+ if(s) {
12
+ s.lastMod = Date.now();
13
+ s.name = this.state.projectName;
14
+ s.state = {
15
+ idx: this.state.idx,
16
+ colors: this.state.colors,
17
+ previewOn: this.state.previewOn,
18
+ strict: this.state.strict,
19
+ bg: this.state.bg,
20
+ penColor: this.state.penColor,
21
+ penSize: this.state.penSize,
22
+ eraserSize: this.state.eraserSize,
23
+ textSize: this.state.textSize,
24
+ shapeType: this.state.shapeType,
25
+ shapeBorder: this.state.shapeBorder,
26
+ shapeFill: this.state.shapeFill,
27
+ shapeWidth: this.state.shapeWidth,
28
+ bookmarks: this.state.bookmarks,
29
+ clipboardBox: this.state.clipboardBox,
30
+ showCursors: this.state.showCursors
31
+ };
32
+ this.dbPut('sessions', s);
33
+ if (this.registry) this.registry.upsert(s);
34
+ }
35
+
36
+ // Save Remotely (Metadata)
37
+ if (this.liveSync && !this.liveSync.isInitializing) {
38
+ this.liveSync.updateMetadata({
39
+ name: this.state.projectName,
40
+ baseFileName: this.state.baseFileName,
41
+ idx: this.state.idx,
42
+ pageCount: this.state.images.length,
43
+ pageLocked: this.state.pageLocked,
44
+ ownerId: this.state.ownerId
45
+ });
46
+ }
47
+ },
48
+
49
+ async saveCurrentImg(skipRemoteSync = false) {
50
+ // Invalidate cache immediately since history changed in memory
51
+ if (this.invalidateCache) this.invalidateCache();
52
+
53
+ if(this.state.sessionId) {
54
+ await this.dbPut('pages', this.state.images[this.state.idx]);
55
+ if (!skipRemoteSync && this.liveSync && !this.liveSync.isInitializing) {
56
+ this.liveSync.setHistory(this.state.idx, this.state.images[this.state.idx].history);
57
+ }
58
+ }
59
+ },
60
+
61
+ // Debounced save - call this instead of saveCurrentImg for frequent updates
62
+ scheduleSave(skipRemoteSync = false) {
63
+ if (this.saveTimeout) clearTimeout(this.saveTimeout);
64
+ this.saveTimeout = setTimeout(() => {
65
+ this.saveCurrentImg(skipRemoteSync);
66
+ }, 300); // Save 300ms after last change
67
+ },
68
+
69
+ saveBlobNative(blob, filename) {
70
+ if (window.AndroidNative) {
71
+ // For large files, process in chunks to avoid OOM
72
+ const CHUNK_SIZE = 512 * 1024; // 512KB chunks
73
+
74
+ if (blob.size > CHUNK_SIZE * 2) {
75
+ // Large file: use chunked approach
76
+ this.ui.showToast("Saving large file...");
77
+ this.saveBlobNativeChunked(blob, filename);
78
+ } else {
79
+ // Small file: use direct approach
80
+ const reader = new FileReader();
81
+ reader.onloadend = () => {
82
+ const base64 = reader.result.split(',')[1];
83
+ window.AndroidNative.saveBlob(base64, filename, blob.type);
84
+ this.ui.showToast("Saved to Downloads");
85
+ };
86
+ reader.onerror = () => {
87
+ console.error("FileReader error");
88
+ this.ui.showToast("Save failed");
89
+ };
90
+ reader.readAsDataURL(blob);
91
+ }
92
+ return true;
93
+ }
94
+ return false;
95
+ },
96
+
97
+ // Chunked saving for large blobs on Android
98
+ async saveBlobNativeChunked(blob, filename) {
99
+ try {
100
+ // Convert blob to base64 in chunks to avoid memory spike
101
+ const arrayBuffer = await blob.arrayBuffer();
102
+ const bytes = new Uint8Array(arrayBuffer);
103
+
104
+ // Convert to base64 in chunks
105
+ let base64 = '';
106
+ const chunkSize = 32768; // Process 32KB at a time
107
+ for (let i = 0; i < bytes.length; i += chunkSize) {
108
+ const chunk = bytes.slice(i, Math.min(i + chunkSize, bytes.length));
109
+ base64 += btoa(String.fromCharCode.apply(null, chunk));
110
+
111
+ // Yield to UI every few chunks
112
+ if (i % (chunkSize * 10) === 0) {
113
+ await new Promise(r => setTimeout(r, 0));
114
+ }
115
+ }
116
+
117
+ window.AndroidNative.saveBlob(base64, filename, blob.type);
118
+ this.ui.showToast("Saved to Downloads");
119
+ } catch (e) {
120
+ console.error("Chunked save failed:", e);
121
+ this.ui.showToast("Save failed: " + e.message);
122
+ }
123
+ },
124
+
125
+ async saveImage() {
126
+ const cvs = this.getElement('canvas');
127
+ cvs.toBlob(blob => {
128
+ if (this.saveBlobNative(blob, 'Page.png')) return;
129
+ const a=document.createElement('a'); a.download='Page.png'; a.href=URL.createObjectURL(blob); a.click();
130
+ });
131
+ },
132
+
133
+ // Compact history by removing soft-deleted items
134
+ compactHistory(pageIdx = null) {
135
+ const idx = pageIdx !== null ? pageIdx : this.state.idx;
136
+ const img = this.state.images[idx];
137
+ if (!img || !img.history) return 0;
138
+
139
+ const before = img.history.length;
140
+ img.history = img.history.filter(st => !st.deleted);
141
+ const removed = before - img.history.length;
142
+
143
+ if (removed > 0) {
144
+ console.log(`Compacted history: removed ${removed} deleted items`);
145
+ // Clear selection since indices changed
146
+ this.state.selection = [];
147
+ this.invalidateCache();
148
+ this.saveCurrentImg();
149
+ }
150
+
151
+ return removed;
152
+ },
153
+
154
+ // Compact all pages
155
+ compactAllHistory() {
156
+ let totalRemoved = 0;
157
+ this.state.images.forEach((_, idx) => {
158
+ totalRemoved += this.compactHistory(idx);
159
+ });
160
+ if (totalRemoved > 0) {
161
+ this.ui.showToast(`Cleaned up ${totalRemoved} items`);
162
+ }
163
+ return totalRemoved;
164
+ },
165
+
166
+ // Auto-compact if history is getting large
167
+ checkAutoCompact() {
168
+ const img = this.state.images[this.state.idx];
169
+ if (!img || !img.history) return;
170
+
171
+ const deletedCount = img.history.filter(st => st.deleted).length;
172
+ const totalCount = img.history.length;
173
+
174
+ // Auto-compact if more than 100 deleted items or >30% are deleted
175
+ if (deletedCount > 100 || (totalCount > 50 && deletedCount / totalCount > 0.3)) {
176
+ console.log('Auto-compacting history...');
177
+ this.compactHistory();
178
+ }
179
+ }
180
+ };
public/scripts/modules/ColorRmUI.js ADDED
@@ -0,0 +1,418 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const ColorRmUI = {
2
+ setupUI() {
3
+ // Use getElement to support scoped lookup or fallback
4
+ const wheelEl = this.getElement("iroWheel");
5
+
6
+ // Only initialize color picker if the element exists
7
+ if (wheelEl && window.iro) {
8
+ this.iroP = new iro.ColorPicker(wheelEl, {width:180, color:"#fff"});
9
+
10
+ this.iroP.on('input:start', () => { this.state.isLivePreview = true; });
11
+ this.iroP.on('input:end', () => { this.state.isLivePreview = false; this.render(); this.saveSessionState(); });
12
+ this.iroP.on('color:change', c => {
13
+ const mode = this.state.pickerMode;
14
+ if(mode==='remove') requestAnimationFrame(() => this.render(c.hexString));
15
+ else if(mode==='pen') this.setPenColor(c.hexString);
16
+ else if(mode==='shapeBorder') { this.state.shapeBorder=c.hexString; this.render(); }
17
+ else if(mode==='shapeFill') { this.state.shapeFill=c.hexString; this.render(); }
18
+ else if(mode==='selectionStroke' || mode==='selectionFill') {
19
+ const img = this.state.images[this.state.idx];
20
+ this.state.selection.forEach(idx => {
21
+ const st = img.history[idx];
22
+ if(mode==='selectionStroke') {
23
+ if(st.tool==='pen') st.color = c.hexString;
24
+ if(st.tool==='shape') st.border = c.hexString;
25
+ if(st.tool==='text') st.color = c.hexString;
26
+ } else {
27
+ if(st.tool==='shape') st.fill = c.hexString;
28
+ }
29
+ });
30
+ this.render();
31
+ }
32
+ });
33
+ }
34
+
35
+ const fileIn = this.getElement('fileIn');
36
+ if (fileIn) fileIn.onchange = (e) => this.handleImport(e);
37
+
38
+ const pickerBtn = this.getElement('openColorPicker');
39
+ if(pickerBtn) pickerBtn.onclick = () => this.openPicker('remove');
40
+
41
+ const eyeBtn = this.getElement('eyedropperBtn');
42
+ if (eyeBtn) {
43
+ eyeBtn.onclick = () => {
44
+ this.state.eyedropperMode = !this.state.eyedropperMode;
45
+ if(this.state.eyedropperMode) {
46
+ eyeBtn.style.background = 'var(--primary)';
47
+ eyeBtn.style.color = 'white';
48
+ this.ui.showToast('Tap on image to pick color');
49
+ } else {
50
+ eyeBtn.style.background = '';
51
+ eyeBtn.style.color = '';
52
+ }
53
+ };
54
+ }
55
+
56
+ const closePicker = this.getElement('closePicker');
57
+ if(closePicker) {
58
+ closePicker.onclick = () => {
59
+ this.getElement('floatingPicker').style.display='none';
60
+ if(this.state.selection.length) this.saveCurrentImg();
61
+ this.state.isLivePreview=false; this.render();
62
+ };
63
+ }
64
+
65
+ const pickerAction = this.getElement('pickerActionBtn');
66
+ if(pickerAction) {
67
+ pickerAction.onclick = () => {
68
+ const hex = this.iroP.color.hexString;
69
+
70
+ // Save to custom swatches history (max 14)
71
+ this.state.customSwatches = this.state.customSwatches.filter(c => c !== hex);
72
+ this.state.customSwatches.unshift(hex);
73
+ if(this.state.customSwatches.length > 14) this.state.customSwatches.pop();
74
+ localStorage.setItem('crm_custom_colors', JSON.stringify(this.state.customSwatches));
75
+
76
+ if(this.state.pickerMode==='remove') {
77
+ const i = parseInt(hex.slice(1), 16);
78
+ this.state.colors.push({hex, lab:this.rgbToLab((i>>16)&255,(i>>8)&255,i&255)});
79
+ this.renderSwatches();
80
+ this.saveSessionState();
81
+ if (this.liveSync) this.liveSync.updateColors(this.state.colors);
82
+ } else {
83
+ this.renderCustomSwatches();
84
+ }
85
+ this.getElement('floatingPicker').style.display='none';
86
+ this.render(); this.saveSessionState();
87
+ if(this.state.selection.length) this.saveCurrentImg();
88
+ };
89
+ }
90
+
91
+ const pickerNone = this.getElement('pickerNoneBtn');
92
+ if(pickerNone) {
93
+ pickerNone.onclick = () => {
94
+ const mode = this.state.pickerMode;
95
+ if(mode==='selectionFill') {
96
+ const img = this.state.images[this.state.idx];
97
+ this.state.selection.forEach(i => { if(img.history[i].tool==='shape') img.history[i].fill='transparent'; });
98
+ this.render(); this.saveCurrentImg();
99
+ } else if (mode==='shapeFill') this.state.shapeFill = 'transparent';
100
+ this.getElement('floatingPicker').style.display='none';
101
+ this.saveSessionState();
102
+ };
103
+ }
104
+
105
+ const pi = this.getElement('pageInput');
106
+ if(pi) {
107
+ pi.onchange = () => {
108
+ let v = parseInt(pi.value);
109
+ if(isNaN(v) || v < 1 || v > this.state.images.length) { pi.value = this.state.idx + 1; } else { this.loadPage(v - 1); }
110
+ };
111
+ pi.onfocus = () => { pi.style.borderBottomColor = 'var(--primary)'; };
112
+ pi.onblur = () => { pi.style.borderBottomColor = 'transparent'; };
113
+ pi.onkeydown = (e) => { e.stopPropagation(); };
114
+ }
115
+
116
+ const brushSize = this.getElement('brushSize');
117
+ if(brushSize) {
118
+ brushSize.oninput = e => {
119
+ const v = parseInt(e.target.value);
120
+ if(this.state.selection.length > 0) {
121
+ const img = this.state.images[this.state.idx];
122
+ this.state.selection.forEach(idx => {
123
+ const st = img.history[idx];
124
+ if(st.tool === 'pen' || st.tool === 'eraser') st.size = v;
125
+ else if(st.tool === 'shape') st.width = v;
126
+ else if(st.tool === 'text') st.size = v;
127
+ });
128
+ this.render();
129
+ } else {
130
+ if(this.state.tool==='eraser') this.state.eraserSize=v;
131
+ else if(this.state.tool==='shape') this.state.shapeWidth=v;
132
+ else if(this.state.tool==='text') this.state.textSize=v;
133
+ else this.state.penSize=v;
134
+ }
135
+ this.saveSessionState();
136
+ };
137
+ }
138
+
139
+ const strictRange = this.getElement('strictRange');
140
+ if(strictRange) {
141
+ strictRange.oninput = e => { this.state.strict=e.target.value; this.render(); };
142
+ strictRange.onchange = () => this.saveSessionState();
143
+ }
144
+
145
+ const previewToggle = this.getElement('previewToggle');
146
+ if(previewToggle) {
147
+ previewToggle.onchange = e => { this.state.previewOn=e.target.checked; this.render(); this.saveSessionState(); };
148
+ }
149
+
150
+ const cursorToggle = this.getElement('cursorToggle');
151
+ if(cursorToggle) {
152
+ cursorToggle.onchange = e => {
153
+ this.state.showCursors=e.target.checked;
154
+ if(this.liveSync && this.liveSync.renderCursors) this.liveSync.renderCursors();
155
+ this.saveSessionState();
156
+ };
157
+ }
158
+
159
+ // --- Bind Tool Buttons Programmatically (for Scoped Instances) ---
160
+ ['None','Lasso','Pen','Shape','Text','Eraser','Capture','Hand'].forEach(toolName => {
161
+ const id = 'tool' + toolName;
162
+ const btn = this.getElement(id);
163
+ if (btn) {
164
+ // Remove inline onclick if present to avoid conflicts (optional)
165
+ btn.onclick = () => this.setTool(toolName.toLowerCase());
166
+ }
167
+ });
168
+
169
+ const undoBtn = this.getElement('undoBtn');
170
+ if (undoBtn) undoBtn.onclick = () => this.undo();
171
+
172
+ const redoBtn = this.getElement('redoBtn');
173
+ if (redoBtn) redoBtn.onclick = () => this.redo();
174
+
175
+ const prevPageBtn = this.getElement('prevPageBtn');
176
+ if (prevPageBtn) prevPageBtn.onclick = () => this.loadPage(this.state.idx - 1);
177
+
178
+ const nextPageBtn = this.getElement('nextPageBtn');
179
+ if (nextPageBtn) nextPageBtn.onclick = () => this.loadPage(this.state.idx + 1);
180
+
181
+ const zoomBtn = this.getElement('zoomBtn');
182
+ if (zoomBtn) zoomBtn.onclick = () => this.resetZoom();
183
+
184
+ this.renderCustomSwatches();
185
+ },
186
+
187
+ setEraserMode(checked) { this.state.eraserType = checked ? 'stroke' : 'standard'; },
188
+ setPenColor(c){ this.state.penColor=c; },
189
+ setShapeType(t){
190
+ this.state.shapeType=t;
191
+ ['rectangle','circle','line','arrow'].forEach(s=>{
192
+ const el = this.getElement('sh_'+s);
193
+ if(el) el.classList.toggle('active', s===t);
194
+ });
195
+ },
196
+ openPicker(m){
197
+ this.state.pickerMode=m;
198
+ const pb = this.getElement('pickerNoneBtn');
199
+ if(pb) pb.style.display = (m==='shapeFill'||m==='selectionFill') ? 'block' : 'none';
200
+ this.renderCustomSwatches();
201
+ const fp = this.getElement('floatingPicker');
202
+ if(fp) fp.style.display='flex';
203
+ },
204
+
205
+ switchSideTab(tab) {
206
+ this.state.activeSideTab = tab;
207
+ const tabs = ['tools', 'pages', 'box', 'debug'];
208
+ tabs.forEach(t => {
209
+ const tabEl = this.getElement('tab' + t.charAt(0).toUpperCase() + t.slice(1));
210
+ if (tabEl) tabEl.className = `sb-tab ${tab===t?'active':''}`;
211
+ const panelEl = this.getElement('panel' + t.charAt(0).toUpperCase() + t.slice(1));
212
+ if (panelEl) panelEl.style.display = tab===t ? 'block' : 'none';
213
+ });
214
+
215
+ if(tab === 'pages') this.renderPageSidebar();
216
+ if(tab === 'box') this.renderBox();
217
+ if(tab === 'debug') this.renderDebug();
218
+ },
219
+
220
+ renderDebug() {
221
+ if (this.state.activeSideTab !== 'debug') return;
222
+
223
+ const debugRoomId = this.getElement('debugRoomId');
224
+ if (debugRoomId) debugRoomId.innerText = `room_${this.liveSync.ownerId}`;
225
+
226
+ const debugUserId = this.getElement('debugUserId');
227
+ if (debugUserId) debugUserId.innerText = this.liveSync.userId || "None";
228
+
229
+ const debugStatus = this.getElement('debugStatus');
230
+ if (debugStatus) {
231
+ debugStatus.innerText = this.liveSync.room ? this.liveSync.room.getStorageStatus() : "Disconnected";
232
+ debugStatus.style.color = (this.liveSync.room && this.liveSync.room.getStorageStatus() === 'synchronized') ? 'var(--success)' : 'var(--primary)';
233
+ }
234
+
235
+ const debugPageIdx = this.getElement('debugPageIdx');
236
+ if (debugPageIdx) debugPageIdx.innerText = this.state.idx + 1;
237
+
238
+ const debugPageCount = this.getElement('debugPageCount');
239
+ if (debugPageCount) debugPageCount.innerText = this.state.images.length;
240
+
241
+ const currentImg = this.state.images[this.state.idx];
242
+ const debugHistoryCount = this.getElement('debugHistoryCount');
243
+ if (debugHistoryCount) debugHistoryCount.innerText = currentImg ? (currentImg.history || []).length : 0;
244
+
245
+ // LiveMap Trace (Refactored for User-Owned Room Model)
246
+ const mapEl = this.getElement('debugLiveMap');
247
+ const keyEl = this.getElement('debugKeyCheck');
248
+
249
+ if (this.liveSync.root && this.liveSync.projectId) {
250
+ const projects = this.liveSync.root.get("projects");
251
+ const project = projects.get(this.liveSync.projectId);
252
+
253
+ if (keyEl) {
254
+ keyEl.innerHTML = `
255
+ <div>In Root.projects: <span style="color:${projects.has(this.liveSync.projectId) ? 'var(--success)' : '#ef4444'}">${projects.has(this.liveSync.projectId)}</span></div>
256
+ <div>Local projId: <span style="color:var(--primary)">${this.liveSync.projectId}</span></div>
257
+ `;
258
+ }
259
+
260
+ if (project) {
261
+ const meta = project.get("metadata").toObject();
262
+ const debugRemoteCount = this.getElement('debugRemoteCount');
263
+ if (debugRemoteCount) debugRemoteCount.innerText = meta.pageCount;
264
+
265
+ const debugRemoteOwner = this.getElement('debugRemoteOwner');
266
+ if (debugRemoteOwner) debugRemoteOwner.innerText = meta.ownerId;
267
+
268
+ const ph = project.get("pagesHistory");
269
+ if (ph && mapEl) {
270
+ let html = `<b>Project: ${this.liveSync.projectId}</b><br>`;
271
+ html += "pagesHistory Keys:<br>";
272
+ ph.forEach((val, key) => {
273
+ html += `• pg ${key}: ${val.length} items<br>`;
274
+ });
275
+ mapEl.innerHTML = html;
276
+ }
277
+ } else if (mapEl) {
278
+ mapEl.innerHTML = "Waiting for project data...";
279
+ }
280
+ } else if (mapEl) {
281
+ mapEl.innerHTML = "LiveSync not connected.";
282
+ }
283
+ },
284
+
285
+ renderPageSidebar() {
286
+ const el = this.getElement('sbPageList');
287
+ if (!el) return;
288
+
289
+ // Revoke old blob URLs to prevent memory leaks
290
+ if (this.pageThumbnailUrls) {
291
+ this.pageThumbnailUrls.forEach(url => URL.revokeObjectURL(url));
292
+ }
293
+ this.pageThumbnailUrls = [];
294
+
295
+ el.innerHTML = '';
296
+ this.state.images.forEach((img, i) => {
297
+ const d = document.createElement('div');
298
+ d.className = `sb-page-item ${i === this.state.idx ? 'active' : ''}`;
299
+ d.onclick = () => this.loadPage(i);
300
+
301
+ const im = new Image();
302
+ const url = URL.createObjectURL(img.blob);
303
+ this.pageThumbnailUrls.push(url);
304
+ im.src = url;
305
+
306
+ d.appendChild(im);
307
+ const n = document.createElement('div');
308
+ n.className = 'sb-page-num'; n.innerText = i + 1;
309
+ d.appendChild(n);
310
+ el.appendChild(d);
311
+ });
312
+ },
313
+
314
+ resetZoom() {
315
+ this.state.zoom = 1;
316
+ this.state.pan = { x: 0, y: 0 };
317
+ this.render();
318
+ },
319
+
320
+ togglePageLock() {
321
+ if (this.state.ownerId !== this.liveSync.userId) return;
322
+ this.state.pageLocked = !this.state.pageLocked;
323
+ this.updateLockUI();
324
+ this.saveSessionState();
325
+ },
326
+
327
+ updateLockUI() {
328
+ const btn = this.getElement('lockBtn');
329
+ const ctrl = this.getElement('presenterControls');
330
+ if (this.liveSync && this.state.ownerId === this.liveSync.userId) {
331
+ if (ctrl) ctrl.style.display = 'block';
332
+ if (btn) {
333
+ btn.className = this.state.pageLocked ? "btn btn-primary" : "btn";
334
+ btn.innerHTML = this.state.pageLocked ? '<i class="bi bi-lock-fill"></i> Presenter Lock: ON' : '<i class="bi bi-unlock"></i> Presenter Lock: OFF';
335
+ }
336
+ } else {
337
+ if (ctrl) ctrl.style.display = 'none';
338
+ }
339
+ },
340
+
341
+ // --- Bookmarks Feature ---
342
+ initBookmark() {
343
+ this.ui.showInput("New Bookmark", "Bookmark Name", (name) => {
344
+ if(!this.state.bookmarks) this.state.bookmarks = [];
345
+ this.state.bookmarks.push({ id: Date.now(), pageIdx: this.state.idx, name: name });
346
+ this.renderBookmarks();
347
+ this.saveSessionState();
348
+ if (this.liveSync) this.liveSync.updateBookmarks(this.state.bookmarks);
349
+ });
350
+ },
351
+
352
+ removeBookmark(id) {
353
+ this.state.bookmarks = this.state.bookmarks.filter(b => b.id !== id);
354
+ this.renderBookmarks();
355
+ this.saveSessionState();
356
+ if (this.liveSync) this.liveSync.updateBookmarks(this.state.bookmarks);
357
+ },
358
+
359
+ renderBookmarks() {
360
+ const el = this.getElement('bookmarkList');
361
+ if (!el) return;
362
+ el.innerHTML = '';
363
+ if(!this.state.bookmarks || this.state.bookmarks.length === 0) {
364
+ el.innerHTML = '<div style="color:#666; font-size:0.8rem; text-align:center; padding:10px;">No bookmarks yet.</div>';
365
+ return;
366
+ }
367
+ this.state.bookmarks.sort((a,b) => a.pageIdx - b.pageIdx).forEach(b => {
368
+ const div = document.createElement('div');
369
+ div.className = 'bm-item';
370
+ if(b.pageIdx === this.state.idx) div.style.borderColor = 'var(--primary)';
371
+ div.innerHTML = `<span><i class="bi bi-bookmark"></i> ${b.name} <span style="color:#666; font-size:0.7em">(Pg ${b.pageIdx+1})</span></span>`;
372
+ div.onclick = () => this.loadPage(b.pageIdx);
373
+
374
+ const del = document.createElement('button');
375
+ del.className = 'bm-del';
376
+ del.innerHTML = '<i class="bi bi-x"></i>';
377
+ del.onclick = (e) => { e.stopPropagation(); this.removeBookmark(b.id); };
378
+
379
+ div.appendChild(del);
380
+ el.appendChild(div);
381
+ });
382
+ },
383
+
384
+ renderSwatches() {
385
+ const c = this.getElement('swatches');
386
+ if (!c) return;
387
+ c.innerHTML='';
388
+ this.state.colors.forEach((col) => {
389
+ const d = document.createElement('div'); d.className='swatch'; d.style.background=col.hex;
390
+ d.onclick=()=>{
391
+ this.state.colors = this.state.colors.filter(c => c.hex !== col.hex);
392
+ this.renderSwatches(); // Re-render swatches after removal
393
+ this.render();
394
+ this.saveSessionState();
395
+ if (this.liveSync) this.liveSync.updateColors(this.state.colors);
396
+ };
397
+ c.appendChild(d);
398
+ });
399
+ },
400
+
401
+ renderCustomSwatches() {
402
+ const c = this.getElement('customSwatches');
403
+ if (!c) return;
404
+ c.innerHTML = '';
405
+ this.state.customSwatches.forEach(color => {
406
+ const d = document.createElement('div');
407
+ d.className = 'color-dot';
408
+ d.style.background = color;
409
+ d.title = color;
410
+ d.onclick = () => {
411
+ this.setPenColor(color);
412
+ // Also update picker color if it's open
413
+ if (this.iroP) this.iroP.color.set(color);
414
+ };
415
+ c.appendChild(d);
416
+ });
417
+ }
418
+ };