Jaimodiji commited on
Commit
8a5bdc3
·
verified ·
1 Parent(s): 2798672

Upload folder using huggingface_hub

Browse files
color_rm_performance_plan.md ADDED
@@ -0,0 +1,369 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ColorRM Performance Optimization Plan
2
+
3
+ ## Problem Summary
4
+ The ColorRM app becomes unstable on Android (Capacitor) when:
5
+ - **~500 history items** are present in a session
6
+ - **~90 on-page items** (box/clipboard items)
7
+
8
+ ## Root Cause Analysis
9
+
10
+ ### 1. **Render Loop Bottleneck** (`ColorRmApp.js:316-325`)
11
+ ```javascript
12
+ currentImg.history.forEach((st, idx) => {
13
+ if (st.deleted) return;
14
+ this.renderObject(ctx, st, dx, dy);
15
+ });
16
+ ```
17
+ - **Issue**: Every `render()` call iterates through ALL history items
18
+ - **Impact**: At 500 items, each frame processes 500 objects
19
+ - **Frequency**: Called on every mouse move, touch event, state change
20
+
21
+ ### 2. **Soft Deletion Never Cleans Up** (`ColorRmApp.js:779, 1168`)
22
+ ```javascript
23
+ st.deleted = true;
24
+ ```
25
+ - **Issue**: Deleted items remain in the array forever
26
+ - **Impact**: Array grows unbounded, iteration cost increases
27
+ - **Example**: After erasing 400 strokes, still iterating through 500 items
28
+
29
+ ### 3. **Full History Sync on Every Change** (`LiveSync.js:309-314`)
30
+ ```javascript
31
+ setHistory(pageIdx, history) {
32
+ pagesHistory.set(pageIdx.toString(), new LiveList(history || []));
33
+ }
34
+ ```
35
+ - **Issue**: Creates NEW LiveList with full array on every stroke
36
+ - **Impact**: Massive memory churn, GC pressure
37
+
38
+ ### 4. **No Rendering Cache/Layers**
39
+ - **Issue**: No separation between static content and active drawing
40
+ - **Impact**: Redrawing 500+ strokes when only painting 1 new stroke
41
+
42
+ ### 5. **Box Items Stored as Base64 DataURLs** (`ColorRmApp.js:1499`)
43
+ ```javascript
44
+ this.state.clipboardBox.push({
45
+ src: tmp.toDataURL(), // Full base64 string
46
+ ...
47
+ });
48
+ ```
49
+ - **Issue**: 90 full-page screenshots as base64 strings
50
+ - **Impact**: Each item can be 1-5MB, totaling 100-450MB in memory
51
+
52
+ ### 6. **IndexedDB Write Blocking** (`ColorRmApp.js:1212`)
53
+ ```javascript
54
+ await this.dbPut('pages', this.state.images[this.state.idx]);
55
+ ```
56
+ - **Issue**: Writes full page object (with 500 history items) to DB
57
+ - **Impact**: UI freezes during write operations
58
+
59
+ ### 7. **Thumbnail Blob URL Leaks** (`ColorRmApp.js:1346`)
60
+ ```javascript
61
+ const im = new Image(); im.src = URL.createObjectURL(img.blob);
62
+ ```
63
+ - **Issue**: Blob URLs created but never revoked
64
+ - **Impact**: Memory leak proportional to navigation frequency
65
+
66
+ ---
67
+
68
+ ## Implementation Plan
69
+
70
+ ### Phase 1: Immediate Performance Fixes (Critical)
71
+
72
+ #### 1.1 Add Offscreen Canvas Caching
73
+ **Files**: `ColorRmApp.js`
74
+ **Goal**: Cache committed strokes to avoid re-rendering
75
+
76
+ ```javascript
77
+ // Add to state
78
+ this.cache = {
79
+ currentImg: null,
80
+ lab: null,
81
+ committedCanvas: null, // NEW: cached static strokes
82
+ dirtyRange: { start: 0, end: 0 } // Track what needs re-caching
83
+ };
84
+ ```
85
+
86
+ **Implementation**:
87
+ - Create offscreen canvas that holds all committed strokes
88
+ - Only re-render to offscreen when history changes
89
+ - Main `render()` just draws: background → cached canvas → active stroke
90
+
91
+ #### 1.2 Implement History Compaction
92
+ **Files**: `ColorRmApp.js`
93
+ **Goal**: Remove soft-deleted items periodically
94
+
95
+ ```javascript
96
+ compactHistory() {
97
+ const img = this.state.images[this.state.idx];
98
+ if (!img.history) return;
99
+
100
+ const before = img.history.length;
101
+ img.history = img.history.filter(st => !st.deleted);
102
+
103
+ // Update selection indices
104
+ this.state.selection = [];
105
+
106
+ if (before !== img.history.length) {
107
+ this.invalidateCache();
108
+ this.saveCurrentImg();
109
+ }
110
+ }
111
+ ```
112
+
113
+ **Trigger Points**:
114
+ - On page change
115
+ - Every 50 strokes
116
+ - On export
117
+ - Manual button in debug panel
118
+
119
+ #### 1.3 Throttle Render Calls
120
+ **Files**: `ColorRmApp.js`
121
+ **Goal**: Limit render frequency to 60fps max
122
+
123
+ ```javascript
124
+ requestRender() {
125
+ if (this.renderPending) return;
126
+ this.renderPending = true;
127
+ requestAnimationFrame(() => {
128
+ this.render();
129
+ this.renderPending = false;
130
+ });
131
+ }
132
+ ```
133
+
134
+ #### 1.4 Debounce IndexedDB Writes
135
+ **Files**: `ColorRmApp.js`
136
+ **Goal**: Batch writes instead of per-stroke
137
+
138
+ ```javascript
139
+ scheduleSave() {
140
+ if (this.saveTimeout) clearTimeout(this.saveTimeout);
141
+ this.saveTimeout = setTimeout(() => {
142
+ this.saveCurrentImg();
143
+ }, 500); // Save 500ms after last change
144
+ }
145
+ ```
146
+
147
+ ---
148
+
149
+ ### Phase 2: Memory Optimization
150
+
151
+ #### 2.1 Convert Box Items to Blobs
152
+ **Files**: `ColorRmApp.js`
153
+ **Goal**: Store box items as Blobs instead of base64
154
+
155
+ **Before**:
156
+ ```javascript
157
+ this.state.clipboardBox.push({ src: tmp.toDataURL(), ... });
158
+ ```
159
+
160
+ **After**:
161
+ ```javascript
162
+ tmp.toBlob(blob => {
163
+ this.state.clipboardBox.push({
164
+ blob: blob, // ~10x smaller in memory
165
+ url: null, // Lazy-create URL when rendering
166
+ ...
167
+ });
168
+ }, 'image/jpeg', 0.8);
169
+ ```
170
+
171
+ **Rendering Update**:
172
+ ```javascript
173
+ renderBox() {
174
+ item.url = item.url || URL.createObjectURL(item.blob);
175
+ // Use item.url for display
176
+ }
177
+ ```
178
+
179
+ #### 2.2 Implement Blob URL Cleanup
180
+ **Files**: `ColorRmApp.js`
181
+ **Goal**: Revoke blob URLs when not visible
182
+
183
+ ```javascript
184
+ renderPageSidebar() {
185
+ // Revoke old URLs
186
+ if (this.pageThumbnailUrls) {
187
+ this.pageThumbnailUrls.forEach(url => URL.revokeObjectURL(url));
188
+ }
189
+ this.pageThumbnailUrls = [];
190
+
191
+ // ... create new URLs and track them
192
+ const url = URL.createObjectURL(img.blob);
193
+ this.pageThumbnailUrls.push(url);
194
+ }
195
+ ```
196
+
197
+ #### 2.3 Lazy-Load Page History
198
+ **Files**: `ColorRmApp.js`, `LiveSync.js`
199
+ **Goal**: Only load current page's history into memory
200
+
201
+ ```javascript
202
+ async loadPage(i) {
203
+ // Clear previous page's non-essential data to free memory
204
+ if (this.state.idx !== i && this.state.images[this.state.idx]) {
205
+ // Keep history but clear any cached rendering
206
+ delete this.state.images[this.state.idx]._cachedRender;
207
+ }
208
+ // ... rest of loadPage
209
+ }
210
+ ```
211
+
212
+ ---
213
+
214
+ ### Phase 3: LiveSync Optimization
215
+
216
+ #### 3.1 Incremental History Updates
217
+ **Files**: `LiveSync.js`
218
+ **Goal**: Push individual strokes instead of full history
219
+
220
+ **Current** (expensive):
221
+ ```javascript
222
+ setHistory(pageIdx, history) {
223
+ pagesHistory.set(pageIdx.toString(), new LiveList(history || []));
224
+ }
225
+ ```
226
+
227
+ **Optimized** (incremental):
228
+ ```javascript
229
+ // For single stroke addition (already exists but underused)
230
+ addStroke(pageIdx, stroke) {
231
+ // ... push single item
232
+ }
233
+
234
+ // For bulk updates (compaction, delete)
235
+ syncHistoryDeltas(pageIdx, operations) {
236
+ // Only sync the changes, not full array
237
+ }
238
+ ```
239
+
240
+ #### 3.2 Throttle LiveSync Updates
241
+ **Files**: `LiveSync.js`
242
+ **Goal**: Batch presence/cursor updates
243
+
244
+ ```javascript
245
+ updateCursor(pt) {
246
+ this.pendingCursor = pt;
247
+ if (!this.cursorThrottle) {
248
+ this.cursorThrottle = setTimeout(() => {
249
+ this.room.updatePresence({ cursor: this.pendingCursor });
250
+ this.cursorThrottle = null;
251
+ }, 50); // 20fps max for cursor updates
252
+ }
253
+ }
254
+ ```
255
+
256
+ ---
257
+
258
+ ### Phase 4: Android-Specific Optimizations
259
+
260
+ #### 4.1 Reduce Touch Event Frequency
261
+ **Files**: `ColorRmApp.js`
262
+ **Goal**: Skip intermediate points during fast drawing
263
+
264
+ ```javascript
265
+ const onPointerMove = e => {
266
+ // Skip if too soon since last point (for touch devices)
267
+ const now = performance.now();
268
+ if (this.lastPointTime && (now - this.lastPointTime) < 8) {
269
+ return; // Skip points faster than 120fps
270
+ }
271
+ this.lastPointTime = now;
272
+ // ... rest of handler
273
+ };
274
+ ```
275
+
276
+ #### 4.2 Use Passive Event Listeners Where Possible
277
+ **Files**: `ColorRmApp.js`
278
+ **Goal**: Improve scroll/touch responsiveness
279
+
280
+ Already using `{ passive: false }` where needed, but ensure non-blocking handlers use:
281
+ ```javascript
282
+ element.addEventListener('touchmove', handler, { passive: true });
283
+ ```
284
+
285
+ #### 4.3 Limit Maximum History Items
286
+ **Files**: `ColorRmApp.js`
287
+ **Goal**: Prevent runaway growth
288
+
289
+ ```javascript
290
+ const MAX_HISTORY_ITEMS = 1000; // Per page
291
+
292
+ saveCurrentImg() {
293
+ const img = this.state.images[this.state.idx];
294
+
295
+ // Auto-compact if too many items
296
+ if (img.history.length > MAX_HISTORY_ITEMS) {
297
+ this.compactHistory();
298
+ }
299
+
300
+ // ... rest of save
301
+ }
302
+ ```
303
+
304
+ ---
305
+
306
+ ### Phase 5: UI/UX Improvements
307
+
308
+ #### 5.1 Add Performance Warning
309
+ **Files**: `ColorRmApp.js`, `UI.js`
310
+ **Goal**: Warn user before problems occur
311
+
312
+ ```javascript
313
+ renderDebug() {
314
+ const histCount = currentImg?.history?.length || 0;
315
+ const boxCount = this.state.clipboardBox?.length || 0;
316
+
317
+ if (histCount > 300 || boxCount > 50) {
318
+ this.ui.showToast("⚠️ High object count. Consider compacting.");
319
+ }
320
+ }
321
+ ```
322
+
323
+ #### 5.2 Add Manual Compact Button
324
+ **Files**: `color_rm.html`, `ColorRmApp.js`
325
+ **Goal**: Let user manually trigger cleanup
326
+
327
+ In debug/trace panel:
328
+ ```html
329
+ <button onclick="window.App.compactHistory()">Compact History</button>
330
+ <button onclick="window.App.clearDeletedItems()">Clear Deleted</button>
331
+ ```
332
+
333
+ ---
334
+
335
+ ## Implementation Priority
336
+
337
+ | Priority | Task | Impact | Effort |
338
+ |----------|------|--------|--------|
339
+ | 🔴 P0 | Offscreen canvas caching | High | Medium |
340
+ | 🔴 P0 | History compaction | High | Low |
341
+ | 🔴 P0 | Throttle render calls | High | Low |
342
+ | 🟠 P1 | Debounce DB writes | Medium | Low |
343
+ | 🟠 P1 | Box items as blobs | High | Medium |
344
+ | 🟠 P1 | Blob URL cleanup | Medium | Low |
345
+ | 🟡 P2 | Incremental LiveSync | Medium | Medium |
346
+ | 🟡 P2 | Touch event throttling | Medium | Low |
347
+ | 🟢 P3 | Performance warnings | Low | Low |
348
+ | 🟢 P3 | Manual compact button | Low | Low |
349
+
350
+ ---
351
+
352
+ ## Testing Plan
353
+
354
+ 1. **Load test**: Create session with 600+ strokes, verify stability
355
+ 2. **Box test**: Add 100+ items to box, verify memory usage
356
+ 3. **Navigation test**: Rapid page switching, verify no memory leak
357
+ 4. **Android test**: Run on low-end Android device (2GB RAM)
358
+ 5. **Sync test**: Verify LiveSync still works with optimizations
359
+
360
+ ---
361
+
362
+ ## Expected Outcomes
363
+
364
+ | Metric | Before | After |
365
+ |--------|--------|-------|
366
+ | Render time (500 items) | ~50ms | ~5ms |
367
+ | Memory usage (90 box items) | ~400MB | ~40MB |
368
+ | UI responsiveness | Laggy | Smooth |
369
+ | Crash threshold | ~500 items | >2000 items |
public/color_rm.html CHANGED
@@ -708,6 +708,9 @@
708
  <button class="btn btn-outline" style="justify-content:center; font-size:0.7rem; padding:10px;" onclick="window.App.render()">Redraw</button>
709
  <button class="btn btn-outline" style="justify-content:center; font-size:0.7rem; padding:10px;" onclick="window.LiveSync.syncHistory(); window.App.render();">Sync Now</button>
710
  </div>
 
 
 
711
  <button class="btn btn-danger" style="width:100%; justify-content:center; margin-top:8px; font-size:0.7rem; padding:10px;" onclick="location.reload()">Force Hard Reload</button>
712
  </div>
713
  </div>
 
708
  <button class="btn btn-outline" style="justify-content:center; font-size:0.7rem; padding:10px;" onclick="window.App.render()">Redraw</button>
709
  <button class="btn btn-outline" style="justify-content:center; font-size:0.7rem; padding:10px;" onclick="window.LiveSync.syncHistory(); window.App.render();">Sync Now</button>
710
  </div>
711
+ <button class="btn" style="width:100%; justify-content:center; margin-top:8px; font-size:0.7rem; padding:10px; border-color:#22c55e; color:#22c55e;" onclick="window.App.compactAllHistory()">
712
+ <i class="bi bi-arrow-repeat"></i> Compact History
713
+ </button>
714
  <button class="btn btn-danger" style="width:100%; justify-content:center; margin-top:8px; font-size:0.7rem; padding:10px;" onclick="location.reload()">Force Hard Reload</button>
715
  </div>
716
  </div>
public/scripts/ColorRmApp.js CHANGED
@@ -25,8 +25,20 @@ export class ColorRmApp {
25
  zoom: 1, pan: { x: 0, y: 0 }
26
  };
27
 
28
- this.cache = { currentImg: null, lab: null };
 
 
 
 
 
 
 
 
29
  this.db = null;
 
 
 
 
30
  this.ui = null;
31
  this.liveSync = null;
32
  this.registry = null;
@@ -204,6 +216,14 @@ export class ColorRmApp {
204
  async loadPage(i, broadcast = true) {
205
  if(i<0 || i>=this.state.images.length) return;
206
 
 
 
 
 
 
 
 
 
207
  if (this.liveSync) {
208
  const project = this.liveSync.getProject();
209
  if (project) {
@@ -234,8 +254,15 @@ export class ColorRmApp {
234
  this.renderBookmarks();
235
 
236
  if(!item.history) item.history = [];
 
 
 
 
 
 
237
  const img = new Image();
238
- img.src = URL.createObjectURL(item.blob);
 
239
  return new Promise((resolve) => {
240
  img.onload = () => {
241
  this.cache.currentImg = img;
@@ -265,6 +292,53 @@ export class ColorRmApp {
265
  });
266
  }
267
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
268
  render() {
269
  if(!this.cache.currentImg) return;
270
  const c = this.getElement('canvas');
@@ -313,18 +387,30 @@ export class ColorRmApp {
313
  }
314
 
315
  const currentImg = this.state.images[this.state.idx];
316
- if (currentImg && currentImg.history) {
317
- currentImg.history.forEach((st, idx) => {
318
- if (st.deleted) return;
319
- let dx=0, dy=0;
320
- if(this.state.selection.includes(idx) && this.dragOffset) {
321
- dx = this.dragOffset.x; dy = this.dragOffset.y;
 
 
 
 
 
 
 
 
 
 
 
 
322
  }
323
  this.renderObject(ctx, st, dx, dy);
324
  });
325
  }
326
 
327
- // Active stroke
328
  if (this.isDragging && this.currentStroke && this.currentStroke.length > 1 && ['pen','eraser'].includes(this.state.tool)) {
329
  ctx.save();
330
  ctx.lineCap='round'; ctx.lineJoin='round';
@@ -778,7 +864,7 @@ export class ColorRmApp {
778
  }
779
  if (hit) { st.deleted = true; st.lastMod = Date.now(); changed = true; }
780
  }
781
- if (changed) { this.saveCurrentImg(); this.render(); }
782
  return;
783
  }
784
 
@@ -1173,6 +1259,7 @@ export class ColorRmApp {
1173
  const tb = this.getElement('contextToolbar');
1174
  if(tb) tb.style.display = 'none';
1175
 
 
1176
  this.saveCurrentImg();
1177
  this.render();
1178
  }
@@ -1214,6 +1301,64 @@ export class ColorRmApp {
1214
  this.liveSync.setHistory(this.state.idx, this.state.images[this.state.idx].history);
1215
  }
1216
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1217
  }
1218
 
1219
  saveBlobNative(blob, filename) {
@@ -1338,12 +1483,24 @@ export class ColorRmApp {
1338
  renderPageSidebar() {
1339
  const el = this.getElement('sbPageList');
1340
  if (!el) return;
 
 
 
 
 
 
 
1341
  el.innerHTML = '';
1342
  this.state.images.forEach((img, i) => {
1343
  const d = document.createElement('div');
1344
  d.className = `sb-page-item ${i === this.state.idx ? 'active' : ''}`;
1345
  d.onclick = () => this.loadPage(i);
1346
- const im = new Image(); im.src = URL.createObjectURL(img.blob);
 
 
 
 
 
1347
  d.appendChild(im);
1348
  const n = document.createElement('div');
1349
  n.className = 'sb-page-num'; n.innerText = i + 1;
@@ -1483,29 +1640,49 @@ export class ColorRmApp {
1483
  }
1484
 
1485
  // --- The Clipboard Box Feature ---
1486
- addToBox(x, y, w, h, src=null, pageIdx=null) {
1487
- let finalSrc = src;
1488
- if(!finalSrc) {
1489
- const cvs = this.getElement('canvas');
1490
- const ctx = cvs.getContext('2d');
1491
- const id = ctx.getImageData(x, y, w, h);
1492
- const tmp = document.createElement('canvas');
1493
- tmp.width = w; tmp.height = h;
1494
- tmp.getContext('2d').putImageData(id, 0, 0);
1495
- finalSrc = tmp.toDataURL();
1496
- }
1497
-
1498
- if(!this.state.clipboardBox) this.state.clipboardBox = [];
1499
- this.state.clipboardBox.push({
1500
- id: Date.now() + Math.random(),
1501
- src: finalSrc,
1502
- w: w, h: h,
1503
- pageIdx: (pageIdx !== null) ? pageIdx : this.state.idx
1504
- });
1505
 
1506
- this.ui.showToast("Added to Box!");
1507
- this.saveSessionState();
1508
- if(this.state.activeSideTab === 'box') this.renderBox();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1509
  }
1510
 
1511
  captureFullPage() {
@@ -1568,7 +1745,9 @@ export class ColorRmApp {
1568
  });
1569
  }
1570
 
1571
- this.addToBox(0, 0, cvs.width, cvs.height, cvs.toDataURL(), idx);
 
 
1572
  await new Promise(r => setTimeout(r, 0));
1573
  }
1574
 
@@ -1579,6 +1758,13 @@ export class ColorRmApp {
1579
  renderBox() {
1580
  const el = this.getElement('boxList');
1581
  if (!el) return;
 
 
 
 
 
 
 
1582
  el.innerHTML = '';
1583
  const countEl = this.getElement('boxCount');
1584
  if (countEl) countEl.innerText = (this.state.clipboardBox || []).length;
@@ -1591,13 +1777,27 @@ export class ColorRmApp {
1591
  this.state.clipboardBox.forEach((item, idx) => {
1592
  const div = document.createElement('div');
1593
  div.className = 'box-item';
1594
- const im = new Image(); im.src = item.src;
 
 
 
 
 
 
 
 
 
 
1595
  div.appendChild(im);
1596
 
1597
  const btn = document.createElement('button');
1598
  btn.className = 'box-del';
1599
  btn.innerHTML = '<i class="bi bi-trash"></i>';
1600
  btn.onclick = () => {
 
 
 
 
1601
  this.state.clipboardBox.splice(idx, 1);
1602
  this.saveSessionState();
1603
  this.renderBox();
@@ -1609,6 +1809,11 @@ export class ColorRmApp {
1609
 
1610
  clearBox() {
1611
  if(confirm("Clear all items in Box?")) {
 
 
 
 
 
1612
  this.state.clipboardBox = [];
1613
  this.saveSessionState();
1614
  this.renderBox();
@@ -1721,8 +1926,18 @@ export class ColorRmApp {
1721
  else { imgY = currentY; labelY = currentY + finalH + 40; }
1722
  }
1723
 
1724
- const img = new Image(); img.src = item.src;
 
 
 
 
 
 
1725
  await new Promise(r => img.onload = r);
 
 
 
 
1726
 
1727
  ctx.drawImage(img, x, imgY, effectiveImgW, finalH);
1728
 
 
25
  zoom: 1, pan: { x: 0, y: 0 }
26
  };
27
 
28
+ this.cache = {
29
+ currentImg: null,
30
+ lab: null,
31
+ // Offscreen canvas for caching committed strokes
32
+ committedCanvas: null,
33
+ committedCtx: null,
34
+ lastHistoryLength: 0, // Track when to invalidate cache
35
+ isDirty: true // Flag to rebuild cache
36
+ };
37
  this.db = null;
38
+
39
+ // Performance flags
40
+ this.renderPending = false;
41
+ this.saveTimeout = null;
42
  this.ui = null;
43
  this.liveSync = null;
44
  this.registry = null;
 
216
  async loadPage(i, broadcast = true) {
217
  if(i<0 || i>=this.state.images.length) return;
218
 
219
+ // Auto-compact current page before switching (if leaving a page)
220
+ if (this.state.idx !== i && this.state.images[this.state.idx]) {
221
+ this.checkAutoCompact();
222
+ }
223
+
224
+ // Invalidate cache when loading new page
225
+ this.invalidateCache();
226
+
227
  if (this.liveSync) {
228
  const project = this.liveSync.getProject();
229
  if (project) {
 
254
  this.renderBookmarks();
255
 
256
  if(!item.history) item.history = [];
257
+
258
+ // Revoke old page blob URL to prevent memory leak
259
+ if (this.currentPageBlobUrl) {
260
+ URL.revokeObjectURL(this.currentPageBlobUrl);
261
+ }
262
+
263
  const img = new Image();
264
+ this.currentPageBlobUrl = URL.createObjectURL(item.blob);
265
+ img.src = this.currentPageBlobUrl;
266
  return new Promise((resolve) => {
267
  img.onload = () => {
268
  this.cache.currentImg = img;
 
292
  });
293
  }
294
 
295
+ // Invalidate the cached canvas (call when history changes)
296
+ invalidateCache() {
297
+ this.cache.isDirty = true;
298
+ }
299
+
300
+ // Request a render on next animation frame (throttled to 60fps)
301
+ requestRender() {
302
+ if (this.renderPending) return;
303
+ this.renderPending = true;
304
+ requestAnimationFrame(() => {
305
+ this.render();
306
+ this.renderPending = false;
307
+ });
308
+ }
309
+
310
+ // Build the cached canvas with all committed strokes
311
+ buildCommittedCache(ctx, currentImg) {
312
+ if (!this.cache.isDirty && this.cache.committedCanvas) {
313
+ return; // Cache is valid
314
+ }
315
+
316
+ const activeHistory = currentImg?.history?.filter(st => !st.deleted) || [];
317
+
318
+ // Create or resize offscreen canvas
319
+ if (!this.cache.committedCanvas ||
320
+ this.cache.committedCanvas.width !== this.state.viewW ||
321
+ this.cache.committedCanvas.height !== this.state.viewH) {
322
+ this.cache.committedCanvas = document.createElement('canvas');
323
+ this.cache.committedCanvas.width = this.state.viewW;
324
+ this.cache.committedCanvas.height = this.state.viewH;
325
+ this.cache.committedCtx = this.cache.committedCanvas.getContext('2d');
326
+ }
327
+
328
+ const cacheCtx = this.cache.committedCtx;
329
+ cacheCtx.clearRect(0, 0, this.state.viewW, this.state.viewH);
330
+
331
+ // Draw all non-selected, committed strokes to cache
332
+ activeHistory.forEach((st, idx) => {
333
+ // Skip items being dragged (they'll be drawn live)
334
+ if (this.state.selection.includes(idx)) return;
335
+ this.renderObject(cacheCtx, st, 0, 0);
336
+ });
337
+
338
+ this.cache.isDirty = false;
339
+ this.cache.lastHistoryLength = currentImg?.history?.length || 0;
340
+ }
341
+
342
  render() {
343
  if(!this.cache.currentImg) return;
344
  const c = this.getElement('canvas');
 
387
  }
388
 
389
  const currentImg = this.state.images[this.state.idx];
390
+
391
+ // Build cached canvas if needed (only rebuilds when dirty)
392
+ this.buildCommittedCache(ctx, currentImg);
393
+
394
+ // Draw the cached committed strokes
395
+ if (this.cache.committedCanvas) {
396
+ ctx.drawImage(this.cache.committedCanvas, 0, 0);
397
+ }
398
+
399
+ // Draw selected items with drag offset (these are live, not cached)
400
+ if (currentImg && currentImg.history && this.state.selection.length > 0) {
401
+ this.state.selection.forEach(idx => {
402
+ const st = currentImg.history[idx];
403
+ if (!st || st.deleted) return;
404
+ let dx = 0, dy = 0;
405
+ if (this.dragOffset) {
406
+ dx = this.dragOffset.x;
407
+ dy = this.dragOffset.y;
408
  }
409
  this.renderObject(ctx, st, dx, dy);
410
  });
411
  }
412
 
413
+ // Active stroke (being drawn right now)
414
  if (this.isDragging && this.currentStroke && this.currentStroke.length > 1 && ['pen','eraser'].includes(this.state.tool)) {
415
  ctx.save();
416
  ctx.lineCap='round'; ctx.lineJoin='round';
 
864
  }
865
  if (hit) { st.deleted = true; st.lastMod = Date.now(); changed = true; }
866
  }
867
+ if (changed) { this.invalidateCache(); this.scheduleSave(); this.render(); }
868
  return;
869
  }
870
 
 
1259
  const tb = this.getElement('contextToolbar');
1260
  if(tb) tb.style.display = 'none';
1261
 
1262
+ this.invalidateCache();
1263
  this.saveCurrentImg();
1264
  this.render();
1265
  }
 
1301
  this.liveSync.setHistory(this.state.idx, this.state.images[this.state.idx].history);
1302
  }
1303
  }
1304
+ // Invalidate cache since history changed
1305
+ this.invalidateCache();
1306
+ }
1307
+
1308
+ // Debounced save - call this instead of saveCurrentImg for frequent updates
1309
+ scheduleSave(skipRemoteSync = false) {
1310
+ if (this.saveTimeout) clearTimeout(this.saveTimeout);
1311
+ this.saveTimeout = setTimeout(() => {
1312
+ this.saveCurrentImg(skipRemoteSync);
1313
+ }, 300); // Save 300ms after last change
1314
+ }
1315
+
1316
+ // Compact history by removing soft-deleted items
1317
+ compactHistory(pageIdx = null) {
1318
+ const idx = pageIdx !== null ? pageIdx : this.state.idx;
1319
+ const img = this.state.images[idx];
1320
+ if (!img || !img.history) return 0;
1321
+
1322
+ const before = img.history.length;
1323
+ img.history = img.history.filter(st => !st.deleted);
1324
+ const removed = before - img.history.length;
1325
+
1326
+ if (removed > 0) {
1327
+ console.log(`Compacted history: removed ${removed} deleted items`);
1328
+ // Clear selection since indices changed
1329
+ this.state.selection = [];
1330
+ this.invalidateCache();
1331
+ this.saveCurrentImg();
1332
+ }
1333
+
1334
+ return removed;
1335
+ }
1336
+
1337
+ // Compact all pages
1338
+ compactAllHistory() {
1339
+ let totalRemoved = 0;
1340
+ this.state.images.forEach((_, idx) => {
1341
+ totalRemoved += this.compactHistory(idx);
1342
+ });
1343
+ if (totalRemoved > 0) {
1344
+ this.ui.showToast(`Cleaned up ${totalRemoved} items`);
1345
+ }
1346
+ return totalRemoved;
1347
+ }
1348
+
1349
+ // Auto-compact if history is getting large
1350
+ checkAutoCompact() {
1351
+ const img = this.state.images[this.state.idx];
1352
+ if (!img || !img.history) return;
1353
+
1354
+ const deletedCount = img.history.filter(st => st.deleted).length;
1355
+ const totalCount = img.history.length;
1356
+
1357
+ // Auto-compact if more than 100 deleted items or >30% are deleted
1358
+ if (deletedCount > 100 || (totalCount > 50 && deletedCount / totalCount > 0.3)) {
1359
+ console.log('Auto-compacting history...');
1360
+ this.compactHistory();
1361
+ }
1362
  }
1363
 
1364
  saveBlobNative(blob, filename) {
 
1483
  renderPageSidebar() {
1484
  const el = this.getElement('sbPageList');
1485
  if (!el) return;
1486
+
1487
+ // Revoke old blob URLs to prevent memory leaks
1488
+ if (this.pageThumbnailUrls) {
1489
+ this.pageThumbnailUrls.forEach(url => URL.revokeObjectURL(url));
1490
+ }
1491
+ this.pageThumbnailUrls = [];
1492
+
1493
  el.innerHTML = '';
1494
  this.state.images.forEach((img, i) => {
1495
  const d = document.createElement('div');
1496
  d.className = `sb-page-item ${i === this.state.idx ? 'active' : ''}`;
1497
  d.onclick = () => this.loadPage(i);
1498
+
1499
+ const im = new Image();
1500
+ const url = URL.createObjectURL(img.blob);
1501
+ this.pageThumbnailUrls.push(url);
1502
+ im.src = url;
1503
+
1504
  d.appendChild(im);
1505
  const n = document.createElement('div');
1506
  n.className = 'sb-page-num'; n.innerText = i + 1;
 
1640
  }
1641
 
1642
  // --- The Clipboard Box Feature ---
1643
+ // Now uses Blobs instead of base64 for ~10x memory savings
1644
+ addToBox(x, y, w, h, srcOrBlob=null, pageIdx=null) {
1645
+ const createItem = (blob) => {
1646
+ if(!this.state.clipboardBox) this.state.clipboardBox = [];
1647
+ this.state.clipboardBox.push({
1648
+ id: Date.now() + Math.random(),
1649
+ blob: blob, // Store as Blob, not base64
1650
+ blobUrl: null, // Lazy-create URL when rendering
1651
+ w: w, h: h,
1652
+ pageIdx: (pageIdx !== null) ? pageIdx : this.state.idx
1653
+ });
 
 
 
 
 
 
 
 
1654
 
1655
+ this.ui.showToast("Added to Box!");
1656
+ this.saveSessionState();
1657
+ if(this.state.activeSideTab === 'box') this.renderBox();
1658
+ };
1659
+
1660
+ // If a Blob was passed directly
1661
+ if (srcOrBlob instanceof Blob) {
1662
+ createItem(srcOrBlob);
1663
+ return;
1664
+ }
1665
+
1666
+ // If a base64 dataURL was passed (legacy support), convert to Blob
1667
+ if (srcOrBlob && typeof srcOrBlob === 'string' && srcOrBlob.startsWith('data:')) {
1668
+ fetch(srcOrBlob)
1669
+ .then(res => res.blob())
1670
+ .then(blob => createItem(blob));
1671
+ return;
1672
+ }
1673
+
1674
+ // Capture from canvas
1675
+ const cvs = this.getElement('canvas');
1676
+ const ctx = cvs.getContext('2d');
1677
+ const id = ctx.getImageData(x, y, w, h);
1678
+ const tmp = document.createElement('canvas');
1679
+ tmp.width = w; tmp.height = h;
1680
+ tmp.getContext('2d').putImageData(id, 0, 0);
1681
+
1682
+ // Use toBlob instead of toDataURL
1683
+ tmp.toBlob(blob => {
1684
+ createItem(blob);
1685
+ }, 'image/jpeg', 0.85);
1686
  }
1687
 
1688
  captureFullPage() {
 
1745
  });
1746
  }
1747
 
1748
+ // Use toBlob instead of toDataURL for memory efficiency
1749
+ const blob = await new Promise(r => cvs.toBlob(r, 'image/jpeg', 0.85));
1750
+ this.addToBox(0, 0, cvs.width, cvs.height, blob, idx);
1751
  await new Promise(r => setTimeout(r, 0));
1752
  }
1753
 
 
1758
  renderBox() {
1759
  const el = this.getElement('boxList');
1760
  if (!el) return;
1761
+
1762
+ // Revoke old blob URLs to prevent memory leaks
1763
+ if (this.boxBlobUrls) {
1764
+ this.boxBlobUrls.forEach(url => URL.revokeObjectURL(url));
1765
+ }
1766
+ this.boxBlobUrls = [];
1767
+
1768
  el.innerHTML = '';
1769
  const countEl = this.getElement('boxCount');
1770
  if (countEl) countEl.innerText = (this.state.clipboardBox || []).length;
 
1777
  this.state.clipboardBox.forEach((item, idx) => {
1778
  const div = document.createElement('div');
1779
  div.className = 'box-item';
1780
+ const im = new Image();
1781
+
1782
+ // Support both new Blob format and legacy base64 src format
1783
+ if (item.blob) {
1784
+ const url = URL.createObjectURL(item.blob);
1785
+ this.boxBlobUrls.push(url);
1786
+ im.src = url;
1787
+ } else if (item.src) {
1788
+ im.src = item.src; // Legacy base64 support
1789
+ }
1790
+
1791
  div.appendChild(im);
1792
 
1793
  const btn = document.createElement('button');
1794
  btn.className = 'box-del';
1795
  btn.innerHTML = '<i class="bi bi-trash"></i>';
1796
  btn.onclick = () => {
1797
+ // Revoke the URL for this item if it has one
1798
+ if (item.blob && item.blobUrl) {
1799
+ URL.revokeObjectURL(item.blobUrl);
1800
+ }
1801
  this.state.clipboardBox.splice(idx, 1);
1802
  this.saveSessionState();
1803
  this.renderBox();
 
1809
 
1810
  clearBox() {
1811
  if(confirm("Clear all items in Box?")) {
1812
+ // Revoke all blob URLs
1813
+ if (this.boxBlobUrls) {
1814
+ this.boxBlobUrls.forEach(url => URL.revokeObjectURL(url));
1815
+ this.boxBlobUrls = [];
1816
+ }
1817
  this.state.clipboardBox = [];
1818
  this.saveSessionState();
1819
  this.renderBox();
 
1926
  else { imgY = currentY; labelY = currentY + finalH + 40; }
1927
  }
1928
 
1929
+ const img = new Image();
1930
+ // Support both Blob and legacy base64 src formats
1931
+ if (item.blob) {
1932
+ img.src = URL.createObjectURL(item.blob);
1933
+ } else if (item.src) {
1934
+ img.src = item.src;
1935
+ }
1936
  await new Promise(r => img.onload = r);
1937
+ // Revoke blob URL after loading
1938
+ if (item.blob) {
1939
+ URL.revokeObjectURL(img.src);
1940
+ }
1941
 
1942
  ctx.drawImage(img, x, imgY, effectiveImgW, finalH);
1943