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.
- package/README.md +2 -0
- package/apple-touch-icon.png +0 -0
- package/css/style.css +851 -2
- package/dist/cli.js +2 -2
- package/dist/esp_loader.js +19 -13
- package/dist/node-usb-adapter.js +13 -13
- package/dist/util/console-color.js +1 -0
- package/eslint.config.js +9 -0
- package/icons/icon-128.png +0 -0
- package/icons/icon-144.png +0 -0
- package/icons/icon-152.png +0 -0
- package/icons/icon-192.png +0 -0
- package/icons/icon-384.png +0 -0
- package/icons/icon-512.png +0 -0
- package/icons/icon-72.png +0 -0
- package/icons/icon-96.png +0 -0
- package/index.html +8 -0
- package/js/hex-editor.js +833 -0
- package/js/nvs-editor.js +809 -0
- package/js/script.js +306 -28
- package/package.json +4 -4
- package/screenshots/desktop.png +0 -0
- package/screenshots/mobile.png +0 -0
- package/src/cli.ts +2 -2
- package/src/const.ts +0 -2
- package/src/esp_loader.ts +17 -13
- package/src/node-usb-adapter.ts +13 -13
- package/src/util/console-color.ts +1 -0
- package/sw.js +3 -1
package/js/hex-editor.js
ADDED
|
@@ -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
|
+
}
|