Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- color_rm_performance_plan.md +369 -0
- public/color_rm.html +3 -0
- public/scripts/ColorRmApp.js +251 -36
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 = {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 1487 |
-
|
| 1488 |
-
|
| 1489 |
-
|
| 1490 |
-
|
| 1491 |
-
|
| 1492 |
-
|
| 1493 |
-
|
| 1494 |
-
|
| 1495 |
-
|
| 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 |
-
|
| 1507 |
-
|
| 1508 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1509 |
}
|
| 1510 |
|
| 1511 |
captureFullPage() {
|
|
@@ -1568,7 +1745,9 @@ export class ColorRmApp {
|
|
| 1568 |
});
|
| 1569 |
}
|
| 1570 |
|
| 1571 |
-
|
|
|
|
|
|
|
| 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();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|