esp32tool 1.4.1 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,833 @@
1
+ /**
2
+ * ESP32Tool Flash Hex Editor
3
+ *
4
+ * A full-screen hex editor for viewing and editing flash memory content.
5
+ * Features:
6
+ * - Dual-pane display: HEX (left) + ASCII (right)
7
+ * - Inline editing in both HEX and ASCII panes
8
+ * - Search by HEX bytes or ASCII string
9
+ * - Go-to-address navigation
10
+ * - Virtual scrolling for large datasets (up to 16 MB)
11
+ * - Modified bytes highlighted, write-back only on explicit button press
12
+ */
13
+
14
+ export class HexEditor {
15
+ /**
16
+ * @param {HTMLElement} container - The container element (#hexeditor-container)
17
+ */
18
+ constructor(container) {
19
+ this.container = container;
20
+ /** @type {Uint8Array|null} */
21
+ this.data = null;
22
+ /** @type {Uint8Array|null} Original snapshot for diff */
23
+ this.originalData = null;
24
+ this.baseAddress = 0;
25
+ this.bytesPerRow = 16;
26
+ this.rowHeight = 20;
27
+ this.selectedOffset = -1;
28
+ this.editingPane = null; // 'hex' or 'ascii'
29
+ this.editBuffer = ''; // partial hex nibble during hex editing
30
+
31
+ // Search state
32
+ this.searchMatches = []; // array of byte offsets
33
+ this.currentMatchIdx = -1;
34
+ this._searchMatchLength = 0;
35
+ this._searchAbort = null; // AbortController for cancelling in-progress search
36
+
37
+ // Callbacks
38
+ this.onClose = null;
39
+ /** @type {((data: Uint8Array, modified: Set<number>) => Promise<void>)|null} */
40
+ this.onWriteFlash = null;
41
+
42
+ /** @type {Set<number>} byte offsets that were modified */
43
+ this.modifiedOffsets = new Set();
44
+
45
+ // DOM references (set in _buildUI)
46
+ this._viewport = null;
47
+ this._scrollContent = null;
48
+ this._resizeObserver = null;
49
+ this._statusOffset = null;
50
+ this._statusValue = null;
51
+ this._statusModified = null;
52
+ this._searchInput = null;
53
+ this._searchMode = null;
54
+ this._searchInfo = null;
55
+ this._gotoInput = null;
56
+ this._progressOverlay = null;
57
+ this._progressText = null;
58
+ this._progressBarInner = null;
59
+ this._butWrite = null;
60
+
61
+ // Virtual scroll
62
+ this._visibleStart = 0;
63
+ this._visibleCount = 0;
64
+ this._totalRows = 0;
65
+
66
+ this._boundHandleKeyDown = this._handleKeyDown.bind(this);
67
+ }
68
+
69
+ // ──────────────────── Public API ────────────────────
70
+
71
+ /**
72
+ * Open the hex editor with data.
73
+ * @param {Uint8Array} data
74
+ * @param {number} baseAddress - flash start address
75
+ */
76
+ open(data, baseAddress = 0) {
77
+ this.data = new Uint8Array(data);
78
+ this.originalData = new Uint8Array(data);
79
+ this.baseAddress = baseAddress;
80
+ this.modifiedOffsets.clear();
81
+ this.searchMatches = [];
82
+ this.currentMatchIdx = -1;
83
+ this.selectedOffset = 0;
84
+ this.editingPane = null;
85
+ this.editBuffer = '';
86
+
87
+ this._buildUI();
88
+ this.container.classList.remove('hidden');
89
+ document.body.classList.add('hexeditor-active');
90
+ document.addEventListener('keydown', this._boundHandleKeyDown);
91
+
92
+ this._calculateLayout();
93
+ this._render();
94
+ this._updateStatus();
95
+ this._scrollToOffset(0);
96
+
97
+ // Recalculate layout on resize
98
+ this._resizeObserver = new ResizeObserver(() => {
99
+ this._calculateLayout();
100
+ this._render();
101
+ });
102
+ this._resizeObserver.observe(this._viewport);
103
+ }
104
+
105
+ /** Close hex editor */
106
+ close() {
107
+ this.container.classList.add('hidden');
108
+ document.body.classList.remove('hexeditor-active');
109
+ document.removeEventListener('keydown', this._boundHandleKeyDown);
110
+ if (this._resizeObserver) {
111
+ this._resizeObserver.disconnect();
112
+ this._resizeObserver = null;
113
+ }
114
+ if (this._searchAbort) {
115
+ this._searchAbort.abort();
116
+ this._searchAbort = null;
117
+ }
118
+ this.container.innerHTML = '';
119
+ if (this.onClose) this.onClose();
120
+ }
121
+
122
+ /** Show loading overlay */
123
+ showProgress(text, percent) {
124
+ if (this._progressOverlay) {
125
+ this._progressOverlay.classList.remove('hidden');
126
+ this._progressText.textContent = text;
127
+ this._progressBarInner.style.width = percent + '%';
128
+ }
129
+ }
130
+
131
+ /** Hide loading overlay */
132
+ hideProgress() {
133
+ if (this._progressOverlay) {
134
+ this._progressOverlay.classList.add('hidden');
135
+ }
136
+ }
137
+
138
+ /** Check if there are unsaved modifications */
139
+ hasModifications() {
140
+ return this.modifiedOffsets.size > 0;
141
+ }
142
+
143
+ // ──────────────────── UI Build ────────────────────
144
+
145
+ /**
146
+ * Initialize and display a progress overlay.
147
+ * Called before open() so user sees loading feedback during flash read.
148
+ */
149
+ initProgressUI() {
150
+ this._buildProgressUI();
151
+ }
152
+
153
+ /** @private */
154
+ _buildProgressUI() {
155
+ this.container.innerHTML = `
156
+ <div class="hexeditor-body" style="flex:1;">
157
+ <div class="hexeditor-progress-overlay" id="hexedProgress">
158
+ <div class="progress-text" id="hexedProgressText">Initiating flash read...</div>
159
+ <div class="progress-bar-outer">
160
+ <div class="progress-bar-inner" id="hexedProgressBar"></div>
161
+ </div>
162
+ </div>
163
+ </div>
164
+ `;
165
+ this._progressOverlay = this.container.querySelector('#hexedProgress');
166
+ this._progressText = this.container.querySelector('#hexedProgressText');
167
+ this._progressBarInner = this.container.querySelector('#hexedProgressBar');
168
+ }
169
+
170
+ _buildUI() {
171
+ const totalSize = this.data ? this.data.length : 0;
172
+ const sizeStr = totalSize >= 1024 * 1024
173
+ ? (totalSize / (1024 * 1024)).toFixed(1) + ' MB'
174
+ : totalSize >= 1024
175
+ ? (totalSize / 1024).toFixed(1) + ' KB'
176
+ : totalSize + ' B';
177
+
178
+ this.container.innerHTML = `
179
+ <div class="hexeditor-toolbar">
180
+ <h3>Flash Hex Editor</h3>
181
+ <span style="font-size:11px;color:#888;margin-left:8px;">
182
+ Base: 0x${this.baseAddress.toString(16).toUpperCase()} | Size: ${sizeStr}
183
+ </span>
184
+ <span class="spacer"></span>
185
+ <div class="hexeditor-goto">
186
+ <label style="font-size:12px;color:#aaa;">Go to: 0x</label>
187
+ <input id="hexedGoto" type="text" placeholder="address" />
188
+ <button id="hexedGotoBtn">Go</button>
189
+ </div>
190
+ <button id="hexedUndoAll" class="danger">Undo All</button>
191
+ <button id="hexedWrite" class="primary" disabled>Write to Flash</button>
192
+ <button id="hexedClose">Close</button>
193
+ </div>
194
+ <div class="hexeditor-search">
195
+ <label>Search:</label>
196
+ <input id="hexedSearch" type="text" placeholder="hex bytes or text..." />
197
+ <select id="hexedSearchMode">
198
+ <option value="hex">HEX</option>
199
+ <option value="ascii">ASCII</option>
200
+ </select>
201
+ <button id="hexedSearchBtn">Find</button>
202
+ <button id="hexedSearchPrev">◀ Prev</button>
203
+ <button id="hexedSearchNext">Next ▶</button>
204
+ <span id="hexedSearchInfo" class="search-info"></span>
205
+ </div>
206
+ <div class="hexeditor-body">
207
+ <div class="hexeditor-progress-overlay hidden" id="hexedProgress">
208
+ <div class="progress-text" id="hexedProgressText">Loading...</div>
209
+ <div class="progress-bar-outer">
210
+ <div class="progress-bar-inner" id="hexedProgressBar"></div>
211
+ </div>
212
+ </div>
213
+ <div class="hexeditor-viewport" id="hexedViewport">
214
+ <div id="hexedScrollContent"></div>
215
+ </div>
216
+ </div>
217
+ <div class="hexeditor-statusbar">
218
+ <span class="status-item" id="hexedStatusOffset">Offset: -</span>
219
+ <span class="status-item" id="hexedStatusValue">Value: -</span>
220
+ <span class="status-item status-modified" id="hexedStatusModified"></span>
221
+ </div>
222
+ `;
223
+
224
+ // Cache DOM references
225
+ this._viewport = this.container.querySelector('#hexedViewport');
226
+ this._scrollContent = this.container.querySelector('#hexedScrollContent');
227
+ this._statusOffset = this.container.querySelector('#hexedStatusOffset');
228
+ this._statusValue = this.container.querySelector('#hexedStatusValue');
229
+ this._statusModified = this.container.querySelector('#hexedStatusModified');
230
+ this._searchInput = this.container.querySelector('#hexedSearch');
231
+ this._searchMode = this.container.querySelector('#hexedSearchMode');
232
+ this._searchInfo = this.container.querySelector('#hexedSearchInfo');
233
+ this._gotoInput = this.container.querySelector('#hexedGoto');
234
+ this._progressOverlay = this.container.querySelector('#hexedProgress');
235
+ this._progressText = this.container.querySelector('#hexedProgressText');
236
+ this._progressBarInner = this.container.querySelector('#hexedProgressBar');
237
+ this._butWrite = this.container.querySelector('#hexedWrite');
238
+
239
+ // Event listeners
240
+ this.container.querySelector('#hexedClose').addEventListener('click', () => {
241
+ if (this.hasModifications()) {
242
+ if (!confirm('You have unsaved modifications. Close anyway?')) return;
243
+ }
244
+ this.close();
245
+ });
246
+
247
+ this._butWrite.addEventListener('click', () => this._handleWrite());
248
+
249
+ this.container.querySelector('#hexedUndoAll').addEventListener('click', () => {
250
+ if (this.modifiedOffsets.size === 0) return;
251
+ if (!confirm(`Undo all ${this.modifiedOffsets.size} modifications?`)) return;
252
+ for (const offset of this.modifiedOffsets) {
253
+ this.data[offset] = this.originalData[offset];
254
+ }
255
+ this.modifiedOffsets.clear();
256
+ this._render();
257
+ this._updateStatus();
258
+ });
259
+
260
+ this.container.querySelector('#hexedSearchBtn').addEventListener('click', () => this._doSearch());
261
+ this.container.querySelector('#hexedSearchPrev').addEventListener('click', () => this._navigateSearch(-1));
262
+ this.container.querySelector('#hexedSearchNext').addEventListener('click', () => this._navigateSearch(1));
263
+
264
+ this._searchInput.addEventListener('keydown', (e) => {
265
+ if (e.key === 'Enter') {
266
+ e.preventDefault();
267
+ if (this.searchMatches.length > 0) {
268
+ this._navigateSearch(1);
269
+ } else {
270
+ this._doSearch();
271
+ }
272
+ }
273
+ });
274
+
275
+ // Clear search on input change
276
+ this._searchInput.addEventListener('input', () => {
277
+ this.searchMatches = [];
278
+ this.currentMatchIdx = -1;
279
+ this._searchInfo.textContent = '';
280
+ this._render();
281
+ });
282
+
283
+ this.container.querySelector('#hexedGotoBtn').addEventListener('click', () => this._doGoto());
284
+ this._gotoInput.addEventListener('keydown', (e) => {
285
+ if (e.key === 'Enter') { e.preventDefault(); this._doGoto(); }
286
+ });
287
+
288
+ // Virtual scroll
289
+ this._viewport.addEventListener('scroll', () => this._onScroll());
290
+
291
+ // Click handler for cells
292
+ this._scrollContent.addEventListener('mousedown', (e) => this._handleCellClick(e));
293
+ }
294
+
295
+ // ──────────────────── Layout & Virtual Scroll ────────────────────
296
+
297
+ _calculateLayout() {
298
+ if (!this.data) return;
299
+ this._totalRows = Math.ceil(this.data.length / this.bytesPerRow);
300
+ const totalHeight = this._totalRows * this.rowHeight;
301
+
302
+ // Set scroll height
303
+ this._scrollContent.style.height = totalHeight + 'px';
304
+ this._scrollContent.style.position = 'relative';
305
+
306
+ // Calculate visible rows
307
+ const vpHeight = this._viewport.clientHeight;
308
+ this._visibleCount = Math.ceil(vpHeight / this.rowHeight) + 2; // add buffer
309
+ }
310
+
311
+ _onScroll() {
312
+ const scrollTop = this._viewport.scrollTop;
313
+ const newStart = Math.floor(scrollTop / this.rowHeight);
314
+ if (newStart !== this._visibleStart) {
315
+ this._visibleStart = newStart;
316
+ this._render();
317
+ }
318
+ }
319
+
320
+ _scrollToOffset(byteOffset) {
321
+ const row = Math.floor(byteOffset / this.bytesPerRow);
322
+ const vpHeight = this._viewport.clientHeight;
323
+ const targetScroll = row * this.rowHeight - vpHeight / 2 + this.rowHeight / 2;
324
+ this._viewport.scrollTop = Math.max(0, targetScroll);
325
+ this._visibleStart = Math.floor(this._viewport.scrollTop / this.rowHeight);
326
+ this._render();
327
+ }
328
+
329
+ // ──────────────────── Rendering ────────────────────
330
+
331
+ _render() {
332
+ if (!this.data) return;
333
+
334
+ const start = Math.max(0, this._visibleStart);
335
+ const end = Math.min(this._totalRows, start + this._visibleCount);
336
+
337
+ // Build visible rows
338
+ const fragment = document.createDocumentFragment();
339
+
340
+ // Container for positioned rows
341
+ const wrapper = document.createElement('div');
342
+ wrapper.style.position = 'absolute';
343
+ wrapper.style.top = (start * this.rowHeight) + 'px';
344
+ wrapper.style.left = '0';
345
+ wrapper.style.right = '0';
346
+
347
+ for (let row = start; row < end; row++) {
348
+ const rowEl = this._createRow(row);
349
+ wrapper.appendChild(rowEl);
350
+ }
351
+
352
+ // Replace content (keep scroll height div)
353
+ // Remove old rendered wrapper if present
354
+ const oldWrapper = this._scrollContent.querySelector('.hex-rows-wrapper');
355
+ if (oldWrapper) oldWrapper.remove();
356
+ wrapper.className = 'hex-rows-wrapper';
357
+ this._scrollContent.appendChild(wrapper);
358
+ }
359
+
360
+ _createRow(rowIndex) {
361
+ const row = document.createElement('div');
362
+ row.className = 'hexeditor-row';
363
+ const byteStart = rowIndex * this.bytesPerRow;
364
+
365
+ // Highlight row if selected offset is in this row
366
+ if (this.selectedOffset >= byteStart && this.selectedOffset < byteStart + this.bytesPerRow) {
367
+ row.classList.add('highlight-row');
368
+ }
369
+
370
+ // Address
371
+ const addr = document.createElement('span');
372
+ addr.className = 'hexeditor-addr';
373
+ addr.textContent = (this.baseAddress + byteStart).toString(16).toUpperCase().padStart(8, '0');
374
+ row.appendChild(addr);
375
+
376
+ // Hex cells
377
+ const hexDiv = document.createElement('span');
378
+ hexDiv.className = 'hexeditor-hex';
379
+
380
+ // Separator
381
+ const sep = document.createElement('span');
382
+ sep.className = 'hexeditor-sep';
383
+
384
+ // ASCII cells
385
+ const asciiDiv = document.createElement('span');
386
+ asciiDiv.className = 'hexeditor-ascii';
387
+
388
+ const bytesInRow = Math.min(this.bytesPerRow, this.data.length - byteStart);
389
+
390
+ for (let i = 0; i < this.bytesPerRow; i++) {
391
+ const offset = byteStart + i;
392
+
393
+ // Hex cell
394
+ const hexCell = document.createElement('span');
395
+ hexCell.className = 'hex-cell';
396
+ hexCell.dataset.offset = offset;
397
+ hexCell.dataset.pane = 'hex';
398
+
399
+ // ASCII cell
400
+ const asciiCell = document.createElement('span');
401
+ asciiCell.className = 'ascii-cell';
402
+ asciiCell.dataset.offset = offset;
403
+ asciiCell.dataset.pane = 'ascii';
404
+
405
+ if (i < bytesInRow) {
406
+ const byte = this.data[offset];
407
+ const hexStr = byte.toString(16).toUpperCase().padStart(2, '0');
408
+ hexCell.textContent = hexStr;
409
+
410
+ // Color classes
411
+ if (byte === 0x00) hexCell.classList.add('zero');
412
+ else if (byte === 0xFF) hexCell.classList.add('ff');
413
+
414
+ // ASCII char
415
+ if (byte >= 0x20 && byte <= 0x7E) {
416
+ asciiCell.textContent = String.fromCharCode(byte);
417
+ } else {
418
+ asciiCell.textContent = '·';
419
+ asciiCell.classList.add('non-printable');
420
+ }
421
+
422
+ // Modified?
423
+ if (this.modifiedOffsets.has(offset)) {
424
+ hexCell.classList.add('modified');
425
+ asciiCell.classList.add('modified');
426
+ }
427
+
428
+ // Selected?
429
+ if (offset === this.selectedOffset) {
430
+ hexCell.classList.add('selected');
431
+ asciiCell.classList.add('selected');
432
+ }
433
+
434
+ // Search match?
435
+ if (this._isSearchMatch(offset)) {
436
+ hexCell.classList.add('search-match');
437
+ asciiCell.classList.add('search-match');
438
+ }
439
+ if (this._isCurrentSearchMatch(offset)) {
440
+ hexCell.classList.add('search-current');
441
+ asciiCell.classList.add('search-current');
442
+ }
443
+ } else {
444
+ hexCell.textContent = ' ';
445
+ asciiCell.textContent = ' ';
446
+ }
447
+
448
+ hexDiv.appendChild(hexCell);
449
+ asciiDiv.appendChild(asciiCell);
450
+ }
451
+
452
+ row.appendChild(hexDiv);
453
+ row.appendChild(sep);
454
+ row.appendChild(asciiDiv);
455
+ return row;
456
+ }
457
+
458
+ // ──────────────────── Selection & Editing ────────────────────
459
+
460
+ _handleCellClick(e) {
461
+ const cell = e.target.closest('[data-offset]');
462
+ if (!cell) return;
463
+
464
+ const offset = parseInt(cell.dataset.offset);
465
+ if (isNaN(offset) || offset >= this.data.length) return;
466
+
467
+ this.selectedOffset = offset;
468
+ this.editingPane = cell.dataset.pane;
469
+ this.editBuffer = '';
470
+ this._render();
471
+ this._updateStatus();
472
+
473
+ // Focus container for key events
474
+ e.preventDefault();
475
+ }
476
+
477
+ _handleKeyDown(e) {
478
+ // Only handle when hex editor is visible
479
+ if (this.container.classList.contains('hidden')) return;
480
+
481
+ // Don't intercept when focus is in search/goto inputs
482
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
483
+
484
+ const offset = this.selectedOffset;
485
+ if (offset < 0 || !this.data) return;
486
+
487
+ // Navigation keys
488
+ switch (e.key) {
489
+ case 'ArrowRight':
490
+ e.preventDefault();
491
+ this._selectOffset(Math.min(offset + 1, this.data.length - 1));
492
+ return;
493
+ case 'ArrowLeft':
494
+ e.preventDefault();
495
+ this._selectOffset(Math.max(offset - 1, 0));
496
+ return;
497
+ case 'ArrowDown':
498
+ e.preventDefault();
499
+ this._selectOffset(Math.min(offset + this.bytesPerRow, this.data.length - 1));
500
+ return;
501
+ case 'ArrowUp':
502
+ e.preventDefault();
503
+ this._selectOffset(Math.max(offset - this.bytesPerRow, 0));
504
+ return;
505
+ case 'PageDown':
506
+ e.preventDefault();
507
+ this._selectOffset(Math.min(offset + this.bytesPerRow * 16, this.data.length - 1));
508
+ return;
509
+ case 'PageUp':
510
+ e.preventDefault();
511
+ this._selectOffset(Math.max(offset - this.bytesPerRow * 16, 0));
512
+ return;
513
+ case 'Home':
514
+ if (e.ctrlKey || e.metaKey) {
515
+ e.preventDefault();
516
+ this._selectOffset(0);
517
+ }
518
+ return;
519
+ case 'End':
520
+ if (e.ctrlKey || e.metaKey) {
521
+ e.preventDefault();
522
+ this._selectOffset(this.data.length - 1);
523
+ }
524
+ return;
525
+ case 'Tab':
526
+ e.preventDefault();
527
+ this.editingPane = this.editingPane === 'hex' ? 'ascii' : 'hex';
528
+ this.editBuffer = '';
529
+ this._render();
530
+ return;
531
+ }
532
+
533
+ // Editing
534
+ if (this.editingPane === 'hex') {
535
+ this._handleHexEdit(e);
536
+ } else if (this.editingPane === 'ascii') {
537
+ this._handleAsciiEdit(e);
538
+ }
539
+ }
540
+
541
+ _handleHexEdit(e) {
542
+ const char = e.key.toLowerCase();
543
+ if (!/^[0-9a-f]$/.test(char)) return;
544
+ e.preventDefault();
545
+
546
+ this.editBuffer += char;
547
+ if (this.editBuffer.length === 2) {
548
+ const newByte = parseInt(this.editBuffer, 16);
549
+ this._setByte(this.selectedOffset, newByte);
550
+ this.editBuffer = '';
551
+ // Move to next byte
552
+ this._selectOffset(Math.min(this.selectedOffset + 1, this.data.length - 1));
553
+ } else {
554
+ // Show partial edit feedback – re-render to update status
555
+ this._updateStatus();
556
+ }
557
+ }
558
+
559
+ _handleAsciiEdit(e) {
560
+ if (e.key.length !== 1 || e.ctrlKey || e.metaKey || e.altKey) return;
561
+ e.preventDefault();
562
+
563
+ const newByte = e.key.charCodeAt(0);
564
+ if (newByte < 0x20 || newByte > 0x7E) return; // only printable ASCII
565
+
566
+ this._setByte(this.selectedOffset, newByte);
567
+ this._selectOffset(Math.min(this.selectedOffset + 1, this.data.length - 1));
568
+ }
569
+
570
+ _setByte(offset, value) {
571
+ if (offset < 0 || offset >= this.data.length) return;
572
+ if (this.data[offset] === value && !this.modifiedOffsets.has(offset)) return;
573
+
574
+ this.data[offset] = value;
575
+
576
+ // Track modification (compare against original)
577
+ if (value !== this.originalData[offset]) {
578
+ this.modifiedOffsets.add(offset);
579
+ } else {
580
+ this.modifiedOffsets.delete(offset);
581
+ }
582
+
583
+ this._render();
584
+ this._updateStatus();
585
+ }
586
+
587
+ _selectOffset(offset) {
588
+ this.selectedOffset = offset;
589
+ this.editBuffer = '';
590
+
591
+ // Ensure visible
592
+ const row = Math.floor(offset / this.bytesPerRow);
593
+ const vpHeight = this._viewport.clientHeight;
594
+ const rowTop = row * this.rowHeight;
595
+ const scrollTop = this._viewport.scrollTop;
596
+ if (rowTop < scrollTop) {
597
+ this._viewport.scrollTop = rowTop;
598
+ } else if (rowTop + this.rowHeight > scrollTop + vpHeight) {
599
+ this._viewport.scrollTop = rowTop + this.rowHeight - vpHeight;
600
+ }
601
+
602
+ this._visibleStart = Math.floor(this._viewport.scrollTop / this.rowHeight);
603
+ this._render();
604
+ this._updateStatus();
605
+ }
606
+
607
+ // ──────────────────── Search ────────────────────
608
+
609
+ async _doSearch() {
610
+ const query = this._searchInput.value.trim();
611
+ if (!query) return;
612
+
613
+ const mode = this._searchMode.value;
614
+ let searchBytes;
615
+
616
+ if (mode === 'hex') {
617
+ // Parse hex string: allow spaces, commas
618
+ const cleaned = query.replace(/[,\s]/g, '');
619
+ if (!/^[0-9a-fA-F]*$/.test(cleaned) || cleaned.length === 0 || cleaned.length % 2 !== 0) {
620
+ this._searchInfo.textContent = 'Invalid hex (e.g. "48 65 6C 6C 6F")';
621
+ return;
622
+ }
623
+ searchBytes = new Uint8Array(cleaned.length / 2);
624
+ for (let i = 0; i < searchBytes.length; i++) {
625
+ searchBytes[i] = parseInt(cleaned.slice(i * 2, i * 2 + 2), 16);
626
+ }
627
+ } else {
628
+ // ASCII mode
629
+ const encoder = new TextEncoder();
630
+ searchBytes = encoder.encode(query);
631
+ }
632
+
633
+ if (searchBytes.length === 0) return;
634
+
635
+ this._searchMatchLength = searchBytes.length;
636
+
637
+ // Cancel any in-progress search
638
+ if (this._searchAbort) {
639
+ this._searchAbort.abort();
640
+ }
641
+ this._searchAbort = new AbortController();
642
+ const signal = this._searchAbort.signal;
643
+
644
+ this.searchMatches = [];
645
+ this.currentMatchIdx = -1;
646
+ this._searchInfo.textContent = 'Searching...';
647
+ this._render();
648
+
649
+ // Non-blocking chunked search to keep UI responsive
650
+ const matches = await this._chunkedSearch(this.data, searchBytes, signal);
651
+ if (signal.aborted) return; // search was cancelled
652
+
653
+ this.searchMatches = matches;
654
+ this._searchAbort = null;
655
+
656
+ if (this.searchMatches.length > 0) {
657
+ // Jump to first match at or after current selection
658
+ let idx = this.searchMatches.findIndex(m => m >= this.selectedOffset);
659
+ if (idx === -1) idx = 0;
660
+ this.currentMatchIdx = idx;
661
+ this._searchInfo.textContent = `${idx + 1} / ${this.searchMatches.length} matches`;
662
+ this.selectedOffset = this.searchMatches[idx];
663
+ this._scrollToOffset(this.selectedOffset);
664
+ } else {
665
+ this.currentMatchIdx = -1;
666
+ this._searchInfo.textContent = 'No matches found';
667
+ }
668
+
669
+ this._render();
670
+ }
671
+
672
+ /**
673
+ * Non-blocking chunked search to keep UI responsive for large buffers.
674
+ * Processes data in chunks and yields between them via setTimeout.
675
+ * @param {Uint8Array} data
676
+ * @param {Uint8Array} pattern
677
+ * @param {AbortSignal} signal
678
+ * @returns {Promise<number[]>} array of match offsets
679
+ */
680
+ _chunkedSearch(data, pattern, signal) {
681
+ return new Promise((resolve) => {
682
+ const CHUNK = 256 * 1024; // process 256 KB per chunk
683
+ const matches = [];
684
+ const len = data.length - pattern.length;
685
+ let pos = 0;
686
+
687
+ // Build Boyer-Moore-Horspool bad-character table
688
+ const last = pattern.length - 1;
689
+ const badChar = new Int32Array(256).fill(pattern.length);
690
+ for (let i = 0; i < last; i++) {
691
+ badChar[pattern[i]] = last - i;
692
+ }
693
+
694
+ const processChunk = () => {
695
+ if (signal.aborted) { resolve([]); return; }
696
+
697
+ const end = Math.min(pos + CHUNK, len);
698
+ while (pos <= end) {
699
+ let j = last;
700
+ while (j >= 0 && data[pos + j] === pattern[j]) j--;
701
+ if (j < 0) {
702
+ matches.push(pos);
703
+ pos++;
704
+ } else {
705
+ pos += badChar[data[pos + last]] || 1;
706
+ }
707
+ }
708
+
709
+ if (pos <= len) {
710
+ // Update progress feedback
711
+ const pct = Math.floor((pos / (len || 1)) * 100);
712
+ this._searchInfo.textContent = `Searching... ${pct}%`;
713
+ setTimeout(processChunk, 0);
714
+ } else {
715
+ resolve(matches);
716
+ }
717
+ };
718
+
719
+ processChunk();
720
+ });
721
+ }
722
+
723
+ _navigateSearch(direction) {
724
+ if (this.searchMatches.length === 0) return;
725
+
726
+ this.currentMatchIdx += direction;
727
+ if (this.currentMatchIdx >= this.searchMatches.length) this.currentMatchIdx = 0;
728
+ if (this.currentMatchIdx < 0) this.currentMatchIdx = this.searchMatches.length - 1;
729
+
730
+ this.selectedOffset = this.searchMatches[this.currentMatchIdx];
731
+ this._searchInfo.textContent = `${this.currentMatchIdx + 1} / ${this.searchMatches.length} matches`;
732
+ this._scrollToOffset(this.selectedOffset);
733
+ this._render();
734
+ this._updateStatus();
735
+ }
736
+
737
+ _isSearchMatch(offset) {
738
+ if (this.searchMatches.length === 0) return false;
739
+ const len = this._searchMatchLength || 1;
740
+ return this.searchMatches.some(m => offset >= m && offset < m + len);
741
+ }
742
+
743
+ _isCurrentSearchMatch(offset) {
744
+ if (this.currentMatchIdx < 0 || this.currentMatchIdx >= this.searchMatches.length) return false;
745
+ const m = this.searchMatches[this.currentMatchIdx];
746
+ const len = this._searchMatchLength || 1;
747
+ return offset >= m && offset < m + len;
748
+ }
749
+
750
+ // ──────────────────── Go To Address ────────────────────
751
+
752
+ _doGoto() {
753
+ const val = this._gotoInput.value.trim().replace(/^0x/i, '');
754
+ const addr = parseInt(val, 16);
755
+ if (isNaN(addr)) return;
756
+
757
+ // Convert absolute address to offset
758
+ const offset = addr >= this.baseAddress ? addr - this.baseAddress : addr;
759
+ if (offset < 0 || offset >= this.data.length) {
760
+ this._gotoInput.style.borderColor = '#c62828';
761
+ setTimeout(() => { this._gotoInput.style.borderColor = ''; }, 1000);
762
+ return;
763
+ }
764
+
765
+ this._selectOffset(offset);
766
+ this._scrollToOffset(offset);
767
+ }
768
+
769
+ // ──────────────────── Status Bar ────────────────────
770
+
771
+ _updateStatus() {
772
+ if (this.selectedOffset >= 0 && this.selectedOffset < this.data.length) {
773
+ const off = this.selectedOffset;
774
+ const absAddr = this.baseAddress + off;
775
+ const byte = this.data[off];
776
+ const hexStr = byte.toString(16).toUpperCase().padStart(2, '0');
777
+ const dec = byte;
778
+ const bin = byte.toString(2).padStart(8, '0');
779
+ const chr = (byte >= 0x20 && byte <= 0x7E) ? `'${String.fromCharCode(byte)}'` : '-';
780
+
781
+ this._statusOffset.textContent = `Offset: 0x${absAddr.toString(16).toUpperCase().padStart(8, '0')} (${off})`;
782
+
783
+ let valueStr = `Hex: 0x${hexStr} | Dec: ${dec} | Bin: ${bin} | Char: ${chr}`;
784
+ if (this.editBuffer.length > 0) {
785
+ valueStr += ` [typing: ${this.editBuffer}_]`;
786
+ }
787
+ this._statusValue.textContent = valueStr;
788
+ } else {
789
+ this._statusOffset.textContent = 'Offset: -';
790
+ this._statusValue.textContent = 'Value: -';
791
+ }
792
+
793
+ if (this.modifiedOffsets.size > 0) {
794
+ this._statusModified.textContent = `● ${this.modifiedOffsets.size} byte(s) modified`;
795
+ this._butWrite.disabled = false;
796
+ } else {
797
+ this._statusModified.textContent = '';
798
+ this._butWrite.disabled = true;
799
+ }
800
+ }
801
+
802
+ // ──────────────────── Write Flash ────────────────────
803
+
804
+ async _handleWrite() {
805
+ if (this.modifiedOffsets.size === 0) return;
806
+ if (!this.onWriteFlash) {
807
+ alert('Write handler not configured');
808
+ return;
809
+ }
810
+
811
+ const count = this.modifiedOffsets.size;
812
+ if (!confirm(`Write ${count} modified byte(s) to flash?\n\nThis will erase and reprogram affected sectors.`)) {
813
+ return;
814
+ }
815
+
816
+ try {
817
+ this._butWrite.disabled = true;
818
+ this.showProgress('Writing changes to flash...', 0);
819
+ await this.onWriteFlash(this.data, this.modifiedOffsets);
820
+
821
+ // Update original snapshot after successful write
822
+ this.originalData = new Uint8Array(this.data);
823
+ this.modifiedOffsets.clear();
824
+ this._render();
825
+ this._updateStatus();
826
+ this.hideProgress();
827
+ } catch (err) {
828
+ this.hideProgress();
829
+ this._butWrite.disabled = false;
830
+ alert('Write failed: ' + (err.message || err));
831
+ }
832
+ }
833
+ }