Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- client/utils/exportUtils.ts +1 -1
- public/color_rm.html +3 -3
- public/scripts/ColorRmApp.js +0 -0
- public/scripts/LiveSync.js +112 -12
- public/scripts/modules/ColorRmBox.js +351 -0
- public/scripts/modules/ColorRmInput.js +525 -0
- public/scripts/modules/ColorRmRenderer.js +299 -0
- public/scripts/modules/ColorRmSession.js +506 -0
- public/scripts/modules/ColorRmStorage.js +180 -0
- public/scripts/modules/ColorRmUI.js +418 -0
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:
|
| 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:
|
| 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 |
-
<
|
|
|
|
| 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 |
-
|
|
|
|
| 154 |
|
| 155 |
if (!this.app.state.showCursors) return;
|
| 156 |
|
| 157 |
if (!this.room) return;
|
| 158 |
|
| 159 |
-
|
| 160 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)
|
|
|
|
|
|
|
|
|
|
| 170 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
const div = document.createElement('div');
|
|
|
|
| 172 |
div.className = 'remote-cursor';
|
| 173 |
|
| 174 |
-
// Map canvas coordinates to screen coordinates
|
| 175 |
-
|
| 176 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
};
|