@orz/ui-hex 0.0.0-stage → 0.1.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.
Files changed (84) hide show
  1. package/LICENSE +29 -0
  2. package/README.md +536 -2
  3. package/dist/column-fit.d.ts +16 -0
  4. package/dist/column-fit.d.ts.map +1 -0
  5. package/dist/column-fit.js +22 -0
  6. package/dist/column-fit.js.map +1 -0
  7. package/dist/decode.d.ts +11 -0
  8. package/dist/decode.d.ts.map +1 -0
  9. package/dist/decode.js +63 -0
  10. package/dist/decode.js.map +1 -0
  11. package/dist/document.d.ts +75 -0
  12. package/dist/document.d.ts.map +1 -0
  13. package/dist/document.js +523 -0
  14. package/dist/document.js.map +1 -0
  15. package/dist/editing.d.ts +66 -0
  16. package/dist/editing.d.ts.map +1 -0
  17. package/dist/editing.js +202 -0
  18. package/dist/editing.js.map +1 -0
  19. package/dist/encoding-picker.d.ts +6 -0
  20. package/dist/encoding-picker.d.ts.map +1 -0
  21. package/dist/encoding-picker.js +84 -0
  22. package/dist/encoding-picker.js.map +1 -0
  23. package/dist/encodings.d.ts +19 -0
  24. package/dist/encodings.d.ts.map +1 -0
  25. package/dist/encodings.js +106 -0
  26. package/dist/encodings.js.map +1 -0
  27. package/dist/format.d.ts +29 -0
  28. package/dist/format.d.ts.map +1 -0
  29. package/dist/format.js +73 -0
  30. package/dist/format.js.map +1 -0
  31. package/dist/hex-editor.d.ts +27 -0
  32. package/dist/hex-editor.d.ts.map +1 -0
  33. package/dist/hex-editor.js +270 -0
  34. package/dist/hex-editor.js.map +1 -0
  35. package/dist/hex-grid.d.ts +102 -0
  36. package/dist/hex-grid.d.ts.map +1 -0
  37. package/dist/hex-grid.js +860 -0
  38. package/dist/hex-grid.js.map +1 -0
  39. package/dist/hex-inspector.d.ts +12 -0
  40. package/dist/hex-inspector.d.ts.map +1 -0
  41. package/dist/hex-inspector.js +68 -0
  42. package/dist/hex-inspector.js.map +1 -0
  43. package/dist/hex-paste.d.ts +26 -0
  44. package/dist/hex-paste.d.ts.map +1 -0
  45. package/dist/hex-paste.js +42 -0
  46. package/dist/hex-paste.js.map +1 -0
  47. package/dist/hex-view.d.ts +4 -0
  48. package/dist/hex-view.d.ts.map +1 -0
  49. package/dist/hex-view.js +6 -0
  50. package/dist/hex-view.js.map +1 -0
  51. package/dist/index.d.ts +11 -0
  52. package/dist/index.d.ts.map +1 -0
  53. package/dist/index.js +10 -0
  54. package/dist/index.js.map +1 -0
  55. package/dist/navigation.d.ts +25 -0
  56. package/dist/navigation.d.ts.map +1 -0
  57. package/dist/navigation.js +100 -0
  58. package/dist/navigation.js.map +1 -0
  59. package/dist/page-cache.d.ts +45 -0
  60. package/dist/page-cache.d.ts.map +1 -0
  61. package/dist/page-cache.js +334 -0
  62. package/dist/page-cache.js.map +1 -0
  63. package/dist/source.d.ts +22 -0
  64. package/dist/source.d.ts.map +1 -0
  65. package/dist/source.js +31 -0
  66. package/dist/source.js.map +1 -0
  67. package/dist/text-decoding-multibyte.d.ts +17 -0
  68. package/dist/text-decoding-multibyte.d.ts.map +1 -0
  69. package/dist/text-decoding-multibyte.js +448 -0
  70. package/dist/text-decoding-multibyte.js.map +1 -0
  71. package/dist/text-decoding.d.ts +45 -0
  72. package/dist/text-decoding.d.ts.map +1 -0
  73. package/dist/text-decoding.js +106 -0
  74. package/dist/text-decoding.js.map +1 -0
  75. package/dist/types.d.ts +15 -0
  76. package/dist/types.d.ts.map +1 -0
  77. package/dist/types.js +10 -0
  78. package/dist/types.js.map +1 -0
  79. package/dist/use-hex-document-state.d.ts +10 -0
  80. package/dist/use-hex-document-state.d.ts.map +1 -0
  81. package/dist/use-hex-document-state.js +22 -0
  82. package/dist/use-hex-document-state.js.map +1 -0
  83. package/package.json +53 -3
  84. package/styles.css +456 -0
@@ -0,0 +1,860 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { clampTopRow, scrollFromTopRow, scrollGeometry, topRowFromScroll, } from "@orz/ui/lib/scroll-map";
3
+ import { memo, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, } from "react";
4
+ import { fitColumns, rowWidth } from "./column-fit.js";
5
+ import { EncodingPicker } from "./encoding-picker.js";
6
+ import { encodingLabel, getTextDecoding, HEX_ENCODINGS, } from "./encodings.js";
7
+ import { addressDigits, copyText, formatAddress, formatOffset, formatSize, hexByte, MAX_COPY_BYTES, } from "./format.js";
8
+ import { HEX_BYTES_TYPE, hexDigits } from "./hex-paste.js";
9
+ import { navigate } from "./navigation.js";
10
+ import { createPageCache } from "./page-cache.js";
11
+ import { glyphCells, } from "./text-decoding.js";
12
+ import { sameSelection, } from "./types.js";
13
+ // happy-dom and a detached tree lay out at 0; 1.375rem at a 16px root.
14
+ const FALLBACK_ROW_HEIGHT = 22;
15
+ const AUTO_SCROLL_STEP_PX = 20;
16
+ const AUTO_SCROLL_MAX_ROWS = 4;
17
+ export function useLatest(value) {
18
+ const ref = useRef(value);
19
+ ref.current = value;
20
+ return ref;
21
+ }
22
+ /** Controlled when `value` is not `undefined`, like a radix primitive. */
23
+ function useControllable(value, defaultValue, onChange, same) {
24
+ const [inner, setInner] = useState(defaultValue);
25
+ const controlled = value !== undefined;
26
+ const current = controlled ? value : inner;
27
+ const currentRef = useLatest(current);
28
+ const onChangeRef = useLatest(onChange);
29
+ const set = useCallback((next) => {
30
+ if (same(currentRef.current, next))
31
+ return;
32
+ if (!controlled) {
33
+ // Later calls in the same event see this one before the re-render.
34
+ currentRef.current = next;
35
+ setInner(next);
36
+ }
37
+ onChangeRef.current?.(next);
38
+ }, [controlled, currentRef, onChangeRef, same]);
39
+ return [current, set, currentRef];
40
+ }
41
+ export function useControllableSelection(value, defaultValue, onChange) {
42
+ return useControllable(value, defaultValue ?? null, onChange, sameSelection);
43
+ }
44
+ function useElementSize(ref, read) {
45
+ const [value, setValue] = useState(0);
46
+ const readRef = useLatest(read);
47
+ useLayoutEffect(() => {
48
+ const el = ref.current;
49
+ if (!el)
50
+ return;
51
+ const update = () => setValue(readRef.current(el));
52
+ update();
53
+ if (typeof ResizeObserver === "undefined")
54
+ return;
55
+ const observer = new ResizeObserver(update);
56
+ observer.observe(el);
57
+ return () => observer.disconnect();
58
+ }, [ref, readRef]);
59
+ return value;
60
+ }
61
+ function hitFromElement(el, grid) {
62
+ const cell = el?.closest?.("[data-offset]");
63
+ if (!cell || !grid.contains(cell))
64
+ return null;
65
+ const offset = Number(cell.getAttribute("data-offset"));
66
+ if (!Number.isFinite(offset))
67
+ return null;
68
+ const pane = cell.getAttribute("data-pane") === "text" ? "text" : "hex";
69
+ return { offset, pane };
70
+ }
71
+ export function HexGrid({ source, selection: selectionProp, defaultSelection, onSelectionChange, columns: columnsProp = 16, maxColumns = 16, groupSize = 8, baseAddress = 0, rows, resizable = false, showAddresses = true, showColumnLabels = true, showText = true, showStatusBar = true, uppercase = true, fadeZeroBytes = true, controlCodes = "abbreviations", encoding: encodingProp, defaultEncoding = "ascii", onEncodingChange, showEncodingPicker = true, onCopy, onReadError, ref, className, "aria-label": ariaLabel = "Hex view", editing, }) {
72
+ const [selection, setSelection, selectionRef] = useControllableSelection(selectionProp, defaultSelection, onSelectionChange);
73
+ const [encoding, setEncoding] = useControllable(encodingProp, defaultEncoding, onEncodingChange, Object.is);
74
+ const decoding = useMemo(() => getTextDecoding(encoding), [encoding]);
75
+ // An id this runtime lacks renders ASCII, and the header says so.
76
+ const shownEncoding = HEX_ENCODINGS.some((info) => info.id === encoding)
77
+ ? encoding
78
+ : "ascii";
79
+ const [readError, setReadError] = useState(null);
80
+ const onReadErrorRef = useLatest(onReadError);
81
+ const cache = useMemo(() => {
82
+ const created = createPageCache(source, {
83
+ onError: (error, offset) => {
84
+ setReadError({ cache: created, offset });
85
+ onReadErrorRef.current?.(error, offset);
86
+ },
87
+ });
88
+ return created;
89
+ }, [source, onReadErrorRef]);
90
+ // Shown while that page is still failed: a retry or a change clears it.
91
+ const readErrorOffset = readError?.cache === cache &&
92
+ cache.status(Math.floor(readError.offset / cache.pageSize)) === "error"
93
+ ? readError.offset
94
+ : null;
95
+ useSyncExternalStore(cache.subscribe, cache.version, cache.version);
96
+ const size = source.size;
97
+ const rootRef = useRef(null);
98
+ const scrollerRef = useRef(null);
99
+ const probeRef = useRef(null);
100
+ const measureRef = useRef(null);
101
+ const liveRef = useRef(null);
102
+ const headerRef = useRef(null);
103
+ const autoColumns = columnsProp === "auto";
104
+ const fit = useColumnFit(autoColumns, maxColumns, groupSize, rootRef, scrollerRef, measureRef);
105
+ const columns = autoColumns ? (fit?.columns ?? maxColumns) : columnsProp;
106
+ const rowHeight = useElementSize(probeRef, (el) => el.getBoundingClientRect().height) ||
107
+ FALLBACK_ROW_HEIGHT;
108
+ const headerHeight = showColumnLabels ? rowHeight : 0;
109
+ const scrollerHeight = useElementSize(scrollerRef, (el) => el.clientHeight);
110
+ // A horizontal scrollbar takes its height out of the rows; a fixed `rows`
111
+ // height grows by it so that all of them still show.
112
+ const scrollbarHeight = useElementSize(scrollerRef, (el) => el.offsetHeight - el.clientHeight);
113
+ const viewportHeight = scrollerHeight > headerHeight
114
+ ? scrollerHeight - headerHeight
115
+ : (rows ?? 0) * rowHeight;
116
+ const appendCell = editing?.appendCell ?? false;
117
+ // The highest offset the caret reaches: the append cell, when there is one.
118
+ const maxOffset = appendCell ? size : size - 1;
119
+ const totalRows = Math.ceil((maxOffset + 1) / columns);
120
+ const geometry = useMemo(() => scrollGeometry(totalRows, rowHeight, viewportHeight), [totalRows, rowHeight, viewportHeight]);
121
+ const geometryRef = useLatest(geometry);
122
+ const [topRowState, setTopRowState] = useState(0);
123
+ // A new row width keeps the byte at the top left in the top row.
124
+ const [topColumns, setTopColumns] = useState(columns);
125
+ if (topColumns !== columns) {
126
+ setTopColumns(columns);
127
+ setTopRowState(Math.floor((topRowState * topColumns) / columns));
128
+ }
129
+ const topRow = clampTopRow(topRowState, geometry);
130
+ const topRowRef = useLatest(topRow);
131
+ const lastWritten = useRef(-1);
132
+ const wheelRows = useRef(0);
133
+ const setTop = useCallback((row) => {
134
+ const g = geometryRef.current;
135
+ const next = clampTopRow(row, g);
136
+ topRowRef.current = next;
137
+ setTopRowState(next);
138
+ const el = scrollerRef.current;
139
+ if (el) {
140
+ const scrollTop = scrollFromTopRow(next, g);
141
+ lastWritten.current = scrollTop;
142
+ el.scrollTop = scrollTop;
143
+ }
144
+ }, [geometryRef, topRowRef]);
145
+ useEffect(() => {
146
+ const el = scrollerRef.current;
147
+ if (!el)
148
+ return;
149
+ const onScroll = () => {
150
+ const g = geometryRef.current;
151
+ const scrollTop = el.scrollTop;
152
+ if (g.scaled && Math.abs(scrollTop - lastWritten.current) <= 1)
153
+ return;
154
+ lastWritten.current = scrollTop;
155
+ const next = topRowFromScroll(scrollTop, g);
156
+ topRowRef.current = next;
157
+ setTopRowState(next);
158
+ };
159
+ el.addEventListener("scroll", onScroll, { passive: true });
160
+ return () => el.removeEventListener("scroll", onScroll);
161
+ }, [geometryRef, topRowRef]);
162
+ // Scaled, one wheel notch would otherwise cross megabytes of rows.
163
+ useEffect(() => {
164
+ const el = scrollerRef.current;
165
+ if (!el || !geometry.scaled)
166
+ return;
167
+ const onWheel = (event) => {
168
+ if (event.ctrlKey || event.deltaY === 0)
169
+ return;
170
+ event.preventDefault();
171
+ const g = geometryRef.current;
172
+ const unit = event.deltaMode === 1
173
+ ? g.rowHeight
174
+ : event.deltaMode === 2
175
+ ? g.viewportRows * g.rowHeight
176
+ : 1;
177
+ // Trackpads send a few pixels at a time; they add up to whole rows.
178
+ wheelRows.current += (event.deltaY * unit) / g.rowHeight;
179
+ const step = Math.trunc(wheelRows.current);
180
+ wheelRows.current -= step;
181
+ if (step)
182
+ setTop(topRowRef.current + step);
183
+ if (event.deltaX)
184
+ el.scrollLeft += event.deltaX * unit;
185
+ };
186
+ el.addEventListener("wheel", onWheel, { passive: false });
187
+ return () => el.removeEventListener("wheel", onWheel);
188
+ }, [geometry.scaled, geometryRef, topRowRef, setTop]);
189
+ // A resize, a size change or a switch between 1:1 and scaled moves the
190
+ // mapping under the top row; the element keeps its old scrollTop.
191
+ useLayoutEffect(() => {
192
+ const el = scrollerRef.current;
193
+ if (!el)
194
+ return;
195
+ if (topRowFromScroll(el.scrollTop, geometry) === topRowRef.current)
196
+ return;
197
+ const scrollTop = scrollFromTopRow(topRowRef.current, geometry);
198
+ lastWritten.current = scrollTop;
199
+ el.scrollTop = scrollTop;
200
+ }, [geometry, topRowRef]);
201
+ const lastRow = Math.min(totalRows - 1, topRow + geometry.viewportRows - 1);
202
+ const { lookbehind, lookahead } = decoding;
203
+ const firstPage = Math.floor(Math.max(0, topRow * columns - lookbehind) / cache.pageSize);
204
+ const lastPage = Math.floor(Math.max(0, Math.min(size, (lastRow + 1) * columns + lookahead) - 1) /
205
+ cache.pageSize);
206
+ const hasRows = lastRow >= topRow;
207
+ // Keyed on pages, not rows: a failed page is retried when it comes back
208
+ // into view, not on every row scrolled.
209
+ useEffect(() => {
210
+ if (!hasRows)
211
+ return;
212
+ cache.retain(firstPage, lastPage);
213
+ cache.request(firstPage, lastPage);
214
+ }, [cache, firstPage, lastPage, hasRows]);
215
+ const scrollToOffset = useCallback((offset, align = "nearest") => {
216
+ const g = geometryRef.current;
217
+ const row = Math.floor(Math.max(0, offset) / columns);
218
+ const visibleRows = g.viewportRows;
219
+ const top = topRowRef.current;
220
+ if (align === "start")
221
+ setTop(row);
222
+ else if (align === "center")
223
+ setTop(row - Math.floor((visibleRows - 1) / 2));
224
+ else if (row < top)
225
+ setTop(row);
226
+ else if (row + 1 > top + visibleRows)
227
+ setTop(row + 1 - visibleRows);
228
+ }, [columns, geometryRef, topRowRef, setTop]);
229
+ useImperativeHandle(ref, () => ({
230
+ scrollToOffset,
231
+ focus: () => rootRef.current?.focus({ preventScroll: true }),
232
+ }), [scrollToOffset]);
233
+ // Pointer
234
+ // The hovered byte; its glyph's range is derived as rows render, so an
235
+ // encoding switch or an edit under a resting pointer cannot leave a stale one.
236
+ const [hoverOffset, setHover] = useState(-1);
237
+ const [dragging, setDragging] = useState(false);
238
+ const drag = useRef(null);
239
+ const cellAt = useCallback((event) => {
240
+ const grid = scrollerRef.current;
241
+ if (!grid)
242
+ return null;
243
+ const direct = hitFromElement(event.target, grid);
244
+ if (direct)
245
+ return direct;
246
+ // Pointer capture retargets every move to the grid itself.
247
+ if (event.target === grid &&
248
+ typeof document.elementFromPoint === "function") {
249
+ return hitFromElement(document.elementFromPoint(event.clientX, event.clientY), grid);
250
+ }
251
+ return null;
252
+ }, []);
253
+ const extendTo = useCallback((hit) => {
254
+ const current = selectionRef.current;
255
+ setSelection({
256
+ anchor: current ? current.anchor : hit.offset,
257
+ focus: hit.offset,
258
+ pane: hit.pane,
259
+ });
260
+ }, [selectionRef, setSelection]);
261
+ const autoScroll = useCallback(() => {
262
+ const state = drag.current;
263
+ const grid = scrollerRef.current;
264
+ if (!state || !grid)
265
+ return;
266
+ const rect = grid.getBoundingClientRect();
267
+ const top = rect.top + (showColumnLabels ? geometryRef.current.rowHeight : 0);
268
+ const bottom = top + geometryRef.current.viewportRows * geometryRef.current.rowHeight;
269
+ const outside = state.y < top ? state.y - top : state.y > bottom ? state.y - bottom : 0;
270
+ if (outside === 0) {
271
+ state.frame = 0;
272
+ return;
273
+ }
274
+ const step = Math.min(AUTO_SCROLL_MAX_ROWS, Math.ceil(Math.abs(outside) / AUTO_SCROLL_STEP_PX));
275
+ setTop(topRowRef.current + Math.sign(outside) * step);
276
+ const y = outside < 0 ? top + 1 : bottom - 1;
277
+ const hit = hitFromElement(document.elementFromPoint?.(state.x, y), grid);
278
+ if (hit)
279
+ extendTo(hit);
280
+ state.frame = requestAnimationFrame(autoScroll);
281
+ }, [showColumnLabels, geometryRef, topRowRef, setTop, extendTo]);
282
+ const endDrag = useCallback(() => {
283
+ const state = drag.current;
284
+ if (!state)
285
+ return;
286
+ if (state.frame)
287
+ cancelAnimationFrame(state.frame);
288
+ drag.current = null;
289
+ setDragging(false);
290
+ }, []);
291
+ useEffect(() => endDrag, [endDrag]);
292
+ const onPointerDown = (event) => {
293
+ if (event.button !== 0)
294
+ return;
295
+ const hit = cellAt(event);
296
+ if (!hit)
297
+ return;
298
+ event.preventDefault();
299
+ rootRef.current?.focus({ preventScroll: true });
300
+ editing?.onCaretMove();
301
+ const current = selectionRef.current;
302
+ if (event.shiftKey && current) {
303
+ setSelection({
304
+ anchor: current.anchor,
305
+ focus: hit.offset,
306
+ pane: hit.pane,
307
+ });
308
+ }
309
+ else {
310
+ setSelection({ anchor: hit.offset, focus: hit.offset, pane: hit.pane });
311
+ }
312
+ try {
313
+ event.currentTarget.setPointerCapture(event.pointerId);
314
+ }
315
+ catch {
316
+ // A synthetic or already-released pointer cannot be captured.
317
+ }
318
+ drag.current = {
319
+ pointerId: event.pointerId,
320
+ x: event.clientX,
321
+ y: event.clientY,
322
+ frame: 0,
323
+ };
324
+ setDragging(true);
325
+ setHover(-1);
326
+ };
327
+ const onPointerMove = (event) => {
328
+ const state = drag.current;
329
+ if (!state) {
330
+ setHover(cellAt(event)?.offset ?? -1);
331
+ return;
332
+ }
333
+ state.x = event.clientX;
334
+ state.y = event.clientY;
335
+ const hit = cellAt(event);
336
+ if (hit)
337
+ extendTo(hit);
338
+ if (!state.frame)
339
+ state.frame = requestAnimationFrame(autoScroll);
340
+ };
341
+ const onPointerUp = (event) => {
342
+ if (!drag.current)
343
+ return;
344
+ try {
345
+ event.currentTarget.releasePointerCapture(event.pointerId);
346
+ }
347
+ catch {
348
+ // Already released.
349
+ }
350
+ endDrag();
351
+ };
352
+ // Keyboard
353
+ /** The cell `offset` shows, decoded from its row's window as the row renders it. */
354
+ const decodedCell = useCallback((offset) => {
355
+ const rowStart = offset - (offset % columns);
356
+ const length = Math.min(columns, size - rowStart);
357
+ if (offset - rowStart >= length)
358
+ return undefined;
359
+ const ws = Math.max(0, rowStart - decoding.lookbehind);
360
+ const we = Math.min(size, rowStart + length + decoding.lookahead);
361
+ const window = cache.get(ws, we - ws);
362
+ if (!window)
363
+ return undefined;
364
+ return decoding.decodeRow(window, { start: rowStart, before: rowStart - ws, length }, "abbreviations")[offset - rowStart];
365
+ }, [cache, columns, decoding, size]);
366
+ const announce = useCallback((sel) => {
367
+ const live = liveRef.current;
368
+ if (!live)
369
+ return;
370
+ if (typeof sel === "string") {
371
+ live.textContent = sel;
372
+ return;
373
+ }
374
+ if (!sel) {
375
+ live.textContent = "No selection";
376
+ return;
377
+ }
378
+ const start = Math.min(sel.anchor, sel.focus);
379
+ const end = Math.max(sel.anchor, sel.focus);
380
+ if (start !== end) {
381
+ live.textContent = `${formatOffset(baseAddress + start)}–${formatOffset(baseAddress + end)}, ${end - start + 1} bytes`;
382
+ return;
383
+ }
384
+ const byte = cache.get(sel.focus, 1)?.[0];
385
+ let text = formatOffset(baseAddress + sel.focus);
386
+ if (byte !== undefined) {
387
+ let cell = decodedCell(sel.focus);
388
+ if (cell?.kind === "continuation" && cell.lead !== undefined) {
389
+ cell = decodedCell(cell.lead);
390
+ }
391
+ // UI-5 spoke a control's abbreviation too, whatever `controlCodes` draws.
392
+ const glyph = cell?.kind === "char" || cell?.kind === "control"
393
+ ? cell.text
394
+ : undefined;
395
+ text += `: ${hexByte(byte, uppercase)}${glyph ? ` (${glyph})` : ""}`;
396
+ }
397
+ live.textContent = text;
398
+ }, [baseAddress, cache, decodedCell, uppercase]);
399
+ // The one place keys are handled; editing hooks its input in here.
400
+ const handleKey = (event) => {
401
+ // Keys from the encoding picker, its portalled list included, bubble here
402
+ // through React; they must not move the caret.
403
+ if (event.target !== event.currentTarget)
404
+ return;
405
+ if (editing?.onKey(event, context))
406
+ return;
407
+ const current = selectionRef.current;
408
+ const g = geometryRef.current;
409
+ const pageRows = g.viewportRows;
410
+ const next = navigate(current, {
411
+ key: event.key,
412
+ shift: event.shiftKey,
413
+ mod: event.ctrlKey || event.metaKey,
414
+ }, {
415
+ columns,
416
+ size,
417
+ pageRows,
418
+ maxOffset,
419
+ firstVisible: Math.min(maxOffset, topRowRef.current * columns),
420
+ pane: current?.pane ?? "hex",
421
+ });
422
+ if (next === undefined)
423
+ return;
424
+ event.preventDefault();
425
+ if (next === current)
426
+ return;
427
+ editing?.onCaretMove();
428
+ setSelection(next);
429
+ if (next)
430
+ scrollToOffset(next.focus, "nearest");
431
+ announce(next);
432
+ };
433
+ // Copy
434
+ const onCopyRef = useLatest(onCopy);
435
+ const copy = useCallback((event, onCopied) => {
436
+ const sel = selectionRef.current;
437
+ if (!sel)
438
+ return;
439
+ const start = Math.min(sel.anchor, sel.focus);
440
+ // An editor's append cell holds no byte to copy.
441
+ const end = Math.min(Math.max(sel.anchor, sel.focus), source.size - 1);
442
+ if (end < start)
443
+ return;
444
+ const length = end - start + 1;
445
+ const report = (result, copied) => {
446
+ onCopyRef.current?.(result);
447
+ if (result.ok && copied)
448
+ onCopied?.(copied);
449
+ };
450
+ event.preventDefault();
451
+ if (length > MAX_COPY_BYTES) {
452
+ report({ ok: false, reason: "too-large" });
453
+ return;
454
+ }
455
+ const textPane = sel.pane === "text";
456
+ const from = textPane ? Math.max(0, start - decoding.lookbehind) : start;
457
+ const to = textPane
458
+ ? Math.min(source.size, end + 1 + decoding.lookahead)
459
+ : end + 1;
460
+ const copied = (read) => ({
461
+ bytes: read.subarray(start - from, start - from + length),
462
+ clipboard: {
463
+ text: copyText(read, sel.pane, uppercase, decoding, start - from, start, length),
464
+ },
465
+ });
466
+ const bytes = cache.get(from, to - from);
467
+ if (bytes && event.clipboardData) {
468
+ const out = copied(bytes);
469
+ event.clipboardData.setData("text/plain", out.clipboard.text);
470
+ // The hex pane's text already pastes back exactly.
471
+ if (textPane) {
472
+ out.clipboard.bytes = hexDigits(out.bytes);
473
+ event.clipboardData.setData(HEX_BYTES_TYPE, out.clipboard.bytes);
474
+ }
475
+ report({ ok: true, bytes: length }, out);
476
+ return;
477
+ }
478
+ const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard;
479
+ if (!clipboard?.writeText) {
480
+ report({ ok: false, reason: "clipboard-unavailable" });
481
+ return;
482
+ }
483
+ // writeText carries no second type, so the exact bytes stay behind.
484
+ cache.readRange(from, to - from).then((read) => {
485
+ const out = copied(read);
486
+ return clipboard.writeText(out.clipboard.text).then(() => report({ ok: true, bytes: length }, out), () => report({ ok: false, reason: "clipboard-unavailable" }));
487
+ }, () => report({ ok: false, reason: "read-failed" }));
488
+ }, [cache, decoding, onCopyRef, selectionRef, source, uppercase]);
489
+ const context = {
490
+ cache,
491
+ decoding,
492
+ reveal: (offset) => scrollToOffset(offset, "nearest"),
493
+ announce,
494
+ copy,
495
+ focus: () => rootRef.current?.focus({ preventScroll: true }),
496
+ };
497
+ if (editing)
498
+ editing.contextRef.current = context;
499
+ const contextRef = useLatest(context);
500
+ const editingRef = useLatest(editing);
501
+ const isEditor = editing !== undefined;
502
+ const writable = isEditor && !editing.readOnly;
503
+ // Warms the encoder up before the first keystroke needs it.
504
+ useEffect(() => {
505
+ if (!writable || !decoding.prepareEncode)
506
+ return;
507
+ const prepare = () => decoding.prepareEncode?.();
508
+ if (typeof requestIdleCallback === "function") {
509
+ const id = requestIdleCallback(prepare);
510
+ return () => cancelIdleCallback(id);
511
+ }
512
+ const id = setTimeout(prepare, 0);
513
+ return () => clearTimeout(id);
514
+ }, [decoding, writable]);
515
+ // Chromium fires `copy` at the body, not at a focused non-editable element,
516
+ // when nothing is selected in the document; listening on the document
517
+ // catches both, and the menu's Copy too. Cut and paste arrive the same way.
518
+ useEffect(() => {
519
+ const ours = (event) => {
520
+ const root = rootRef.current;
521
+ if (!root || event.defaultPrevented)
522
+ return false;
523
+ // Copying a selected encoding label stays native.
524
+ if (headerRef.current?.contains(event.target))
525
+ return false;
526
+ const active = document.activeElement;
527
+ return active === root || root.contains(event.target);
528
+ };
529
+ const onDocumentCopy = (event) => {
530
+ if (ours(event))
531
+ copy(event);
532
+ };
533
+ const onDocumentCut = (event) => {
534
+ if (ours(event))
535
+ editingRef.current?.onCut(event, contextRef.current);
536
+ };
537
+ const onDocumentPaste = (event) => {
538
+ if (ours(event))
539
+ editingRef.current?.onPaste(event, contextRef.current);
540
+ };
541
+ document.addEventListener("copy", onDocumentCopy);
542
+ if (isEditor) {
543
+ document.addEventListener("cut", onDocumentCut);
544
+ document.addEventListener("paste", onDocumentPaste);
545
+ }
546
+ return () => {
547
+ document.removeEventListener("copy", onDocumentCopy);
548
+ document.removeEventListener("cut", onDocumentCut);
549
+ document.removeEventListener("paste", onDocumentPaste);
550
+ };
551
+ }, [copy, isEditor, editingRef, contextRef]);
552
+ // Render
553
+ const selStart = selection ? Math.min(selection.anchor, selection.focus) : -1;
554
+ const selEnd = selection ? Math.max(selection.anchor, selection.focus) : -1;
555
+ // The append cell takes the caret but is never part of the highlighted range.
556
+ const selLast = Math.min(selEnd, size - 1);
557
+ const caret = selection ? selection.focus : -1;
558
+ // The nibble counts only in the hex pane; from the text pane the hex twin
559
+ // keeps the whole-byte underline.
560
+ const caretNibble = editing
561
+ ? selection?.pane === "text"
562
+ ? undefined
563
+ : (selection?.nibble ?? 0)
564
+ : undefined;
565
+ const digits = addressDigits(baseAddress + Math.max(0, (totalRows - 1) * columns));
566
+ const glyphRange = (offset) => {
567
+ const cell = showText && offset < size ? decodedCell(offset) : undefined;
568
+ const lead = cell?.kind === "continuation" ? cell.lead : offset;
569
+ if (lead === undefined)
570
+ return { start: offset, end: offset };
571
+ // The lead's own cell knows the glyph's length, even when the glyph
572
+ // crosses more than one row end.
573
+ const length = (lead === offset ? cell : decodedCell(lead))?.length ?? 1;
574
+ return length > 1
575
+ ? { start: lead, end: Math.min(size - 1, lead + length - 1) }
576
+ : { start: offset, end: offset };
577
+ };
578
+ const hover = hoverOffset >= 0 ? glyphRange(hoverOffset) : null;
579
+ const rowElements = [];
580
+ for (let row = topRow; row <= lastRow; row++) {
581
+ const rowStart = row * columns;
582
+ const length = Math.min(columns, size - rowStart);
583
+ const windowStart = Math.max(0, rowStart - lookbehind);
584
+ const windowEnd = Math.min(size, rowStart + length + lookahead);
585
+ const windowBytes = cache.get(windowStart, windowEnd - windowStart);
586
+ // The hex pane needs only the row's own bytes: a failed or loading page
587
+ // in the decoder's lookbehind or lookahead holds back the text pane alone.
588
+ const own = windowBytes ?? cache.get(rowStart, length);
589
+ // A copy: a sync source may change its buffer in place, and the row's
590
+ // memo compares the bytes it last rendered with these.
591
+ const bytes = own?.slice();
592
+ const state = own
593
+ ? "ready"
594
+ : rangeState(cache, rowStart, rowStart + length);
595
+ const textState = windowBytes
596
+ ? "ready"
597
+ : rangeState(cache, windowStart, windowEnd);
598
+ const rowEnd = rowStart + length - 1;
599
+ const append = appendCell && size - rowStart < columns;
600
+ const cellEnd = append ? rowEnd + 1 : rowEnd;
601
+ const inSel = selStart <= rowEnd && selLast >= rowStart && selStart >= 0;
602
+ const hovered = hover !== null && hover.start <= cellEnd && hover.end >= rowStart;
603
+ let modified = "";
604
+ if (editing && length > 0) {
605
+ modified = editing
606
+ .modifiedRanges(rowStart, length)
607
+ .map(([from, to]) => `${Math.max(from, rowStart) - rowStart}-${Math.min(to, rowStart + length) - rowStart}`)
608
+ .join(",");
609
+ }
610
+ rowElements.push(_jsx(HexRow, { rowStart: rowStart, length: length, append: append, modified: modified, bytes: bytes, before: windowBytes ? rowStart - windowStart : 0, state: state, textState: textState, selFrom: inSel ? Math.max(selStart, rowStart) : -1, selTo: inSel ? Math.min(selLast, rowEnd) : -1, hoverFrom: hovered ? Math.max(hover.start, rowStart) : -1, hoverTo: hovered ? Math.min(hover.end, cellEnd) : -1, caret: caret >= rowStart && caret <= cellEnd ? caret : -1, caretNibble: caretNibble, columns: columns, groupSize: groupSize, address: showAddresses
611
+ ? formatAddress(baseAddress + rowStart, digits, uppercase)
612
+ : undefined, uppercase: uppercase, fadeZeroBytes: fadeZeroBytes, controlCodes: controlCodes, showText: showText, decoding: decoding, encoding: encoding }, row));
613
+ }
614
+ const resize = resizable === true ? "both" : resizable || undefined;
615
+ const rowsHeight = rows === undefined
616
+ ? undefined
617
+ : `calc(var(--hex-row-height) * ${rows + (showColumnLabels ? 1 : 0)} + ${scrollbarHeight}px)`;
618
+ const pending = cache.pending();
619
+ let statusLeft = "";
620
+ if (selection) {
621
+ statusLeft =
622
+ selStart >= selLast
623
+ ? formatOffset(baseAddress + caret)
624
+ : `${formatOffset(baseAddress + selStart)}–${formatOffset(baseAddress + selLast)} · ${selLast - selStart + 1} bytes`;
625
+ }
626
+ return (_jsxs("div", { ref: rootRef, className: className ? `orz-hex ${className}` : "orz-hex", style: fit
627
+ ? { "--hex-auto-width": `${fit.width}px` }
628
+ : undefined, "data-active-pane": selection?.pane ?? "hex", "data-columns-auto": autoColumns ? "" : undefined, "data-resizable": resize, "data-dragging": dragging ? "" : undefined, "data-fill": rows === undefined ? "" : undefined, "data-mode": editing?.mode,
629
+ // A custom keyboard widget over a virtualised range: an ARIA grid
630
+ // could not stay truthful past the rendered rows.
631
+ role: "application",
632
+ // biome-ignore lint/a11y/noNoninteractiveTabindex: the view itself is the focus target
633
+ tabIndex: 0, "aria-label": ariaLabel, "aria-roledescription": editing ? "hex editor" : "hex view", onKeyDown: handleKey, children: [_jsx("div", { ref: probeRef, className: "orz-hex-probe", "aria-hidden": true }), autoColumns && (_jsxs("div", { ref: measureRef, className: "orz-hex-row orz-hex-measure", "aria-hidden": true, children: [showAddresses && (_jsx("span", { className: "orz-hex-addr", "data-measure": "addr", children: formatAddress(baseAddress, digits, uppercase) })), _jsx("span", { className: "orz-hex-cell", "data-measure": "hex", children: _jsx("span", { className: "orz-hex-cell-content", children: "00" }) }), _jsx("span", { className: "orz-hex-cell", "data-measure": "group-end", "data-group-end": "", children: _jsx("span", { className: "orz-hex-cell-content", children: "00" }) }), showText && (_jsxs(_Fragment, { children: [_jsx("span", { className: "orz-hex-pane-gap", "data-measure": "gap" }), _jsx("span", { className: "orz-hex-cell orz-hex-text-cell", "data-measure": "text" })] }))] })), _jsxs("div", { ref: scrollerRef, className: "orz-hex-scroller", style: rowsHeight === undefined
634
+ ? undefined
635
+ : // A handle that drags the height lets the rows follow it.
636
+ resize === "both" || resize === "vertical"
637
+ ? { flex: `1 1 ${rowsHeight}` }
638
+ : { height: rowsHeight }, onPointerDown: onPointerDown, onPointerMove: onPointerMove, onPointerUp: onPointerUp, onPointerCancel: onPointerUp, onLostPointerCapture: endDrag, onPointerLeave: () => {
639
+ if (!drag.current)
640
+ setHover(-1);
641
+ }, children: [showColumnLabels && (_jsxs("div", { ref: headerRef, className: "orz-hex-row orz-hex-header", children: [_jsxs("span", { className: "orz-hex-header-labels", "aria-hidden": true, children: [showAddresses && (_jsx("span", { className: "orz-hex-addr", style: { width: `${digits}ch` } })), Array.from({ length: columns }, (_, i) => (_jsx("span", { className: "orz-hex-cell", "data-group-end": isGroupEnd(i, columns, groupSize) ? "" : undefined, children: _jsx("span", { className: "orz-hex-cell-content", children: hexByte(i, uppercase) }) }, i))), showText && _jsx("span", { className: "orz-hex-pane-gap" })] }), showText && (_jsx("span", { className: "orz-hex-text-label", style: { width: `calc(var(--hex-text-cell) * ${columns})` }, children: showEncodingPicker ? (_jsx(EncodingPicker, { value: shownEncoding, onChange: setEncoding })) : (encodingLabel(shownEncoding)) }))] })), _jsx("div", { className: "orz-hex-spacer", style: { height: `${geometry.spacerHeight}px` }, children: _jsx("div", { className: "orz-hex-layer", style: { top: headerHeight }, children: rowElements }) })] }), showStatusBar && (_jsxs("div", { className: "orz-hex-status", children: [_jsxs("span", { children: [statusLeft, pending
642
+ ? `${statusLeft ? " · " : ""}Loading…`
643
+ : readErrorOffset !== null
644
+ ? `${statusLeft ? " · " : ""}Read failed at ${formatOffset(baseAddress + readErrorOffset)}`
645
+ : ""] }), editing?.statusExtra ? (_jsxs("span", { className: "orz-hex-status-end", children: [editing.statusExtra, _jsx("span", { children: formatSize(size) })] })) : (_jsx("span", { children: formatSize(size) }))] })), _jsx("div", { ref: liveRef, className: "orz-hex-live", "aria-live": "polite" })] }));
646
+ }
647
+ /**
648
+ * `columns="auto"`: the root is as wide as `maxColumns` need, capped by its
649
+ * max-width, and the columns are the most that fit the width it gets.
650
+ */
651
+ function useColumnFit(enabled, maxColumns, groupSize, rootRef, scrollerRef, measureRef) {
652
+ const [fit, setFit] = useState(null);
653
+ useLayoutEffect(() => {
654
+ const root = rootRef.current;
655
+ const scroller = scrollerRef.current;
656
+ const measure = measureRef.current;
657
+ if (!enabled || !root || !scroller || !measure)
658
+ return;
659
+ const width = (part) => measure.querySelector(`[data-measure="${part}"]`)?.getBoundingClientRect()
660
+ .width ?? 0;
661
+ const update = () => {
662
+ const hex = width("hex");
663
+ // happy-dom and a detached tree lay out at 0.
664
+ if (!hex)
665
+ return;
666
+ const metrics = {
667
+ fixed: width("addr") + width("gap"),
668
+ column: hex + width("text"),
669
+ groupGap: width("group-end") - hex,
670
+ groupSize,
671
+ };
672
+ const style = getComputedStyle(scroller);
673
+ const padding = Number.parseFloat(style.paddingLeft) +
674
+ Number.parseFloat(style.paddingRight);
675
+ // The border, the scrollbar gutter and the padding.
676
+ const chrome = root.getBoundingClientRect().width - scroller.clientWidth + padding;
677
+ const next = {
678
+ columns: fitColumns(scroller.clientWidth - padding, maxColumns, metrics),
679
+ width: Math.ceil(rowWidth(maxColumns, metrics) + chrome),
680
+ };
681
+ setFit((prev) => prev?.columns === next.columns && prev.width === next.width
682
+ ? prev
683
+ : next);
684
+ };
685
+ update();
686
+ if (typeof ResizeObserver === "undefined")
687
+ return;
688
+ const observer = new ResizeObserver(update);
689
+ observer.observe(root);
690
+ observer.observe(measure);
691
+ return () => observer.disconnect();
692
+ }, [enabled, maxColumns, groupSize, rootRef, scrollerRef, measureRef]);
693
+ return enabled ? fit : null;
694
+ }
695
+ function rangeState(cache, from, to) {
696
+ const p0 = Math.floor(from / cache.pageSize);
697
+ const p1 = Math.floor(Math.max(from, to - 1) / cache.pageSize);
698
+ for (let p = p0; p <= p1; p++) {
699
+ if (cache.status(p) === "error")
700
+ return "error";
701
+ }
702
+ return "loading";
703
+ }
704
+ function isGroupEnd(column, columns, groupSize) {
705
+ return (groupSize > 0 && column < columns - 1 && (column + 1) % groupSize === 0);
706
+ }
707
+ const flag = (on) => (on ? "" : undefined);
708
+ function parseModified(modified) {
709
+ const out = [];
710
+ if (!modified)
711
+ return out;
712
+ for (const pair of modified.split(",")) {
713
+ const [from, to] = pair.split("-").map(Number);
714
+ for (let i = from; i < to; i++)
715
+ out[i] = true;
716
+ }
717
+ return out;
718
+ }
719
+ /** Per row byte, the multi-byte glyph it belongs to, for hovering a glyph whole. */
720
+ function glyphSpans(cells, rowStart) {
721
+ const out = new Array(cells.length);
722
+ for (let i = 0; i < cells.length; i++) {
723
+ const cell = cells[i];
724
+ if (cell.kind !== "continuation") {
725
+ const length = cell.length ?? 1;
726
+ if (length > 1)
727
+ out[i] = [rowStart + i, length];
728
+ continue;
729
+ }
730
+ const lead = cell.lead ?? rowStart + i;
731
+ if (lead >= rowStart) {
732
+ out[i] = out[lead - rowStart];
733
+ continue;
734
+ }
735
+ // The lead sits on an earlier row: the glyph ends with its last
736
+ // continuation here.
737
+ let end = i;
738
+ while (end + 1 < cells.length &&
739
+ cells[end + 1]?.kind === "continuation" &&
740
+ cells[end + 1]?.lead === lead) {
741
+ end++;
742
+ }
743
+ const span = [lead, rowStart + end - lead + 1];
744
+ for (let k = i; k <= end; k++)
745
+ out[k] = span;
746
+ i = end;
747
+ }
748
+ return out;
749
+ }
750
+ /** Decodes once per change of the row's bytes or encoding, not on every selection or hover repaint. */
751
+ function useDecodedRow(bytes, row, decoding, encoding, controlCodes) {
752
+ const last = useRef(undefined);
753
+ if (!bytes)
754
+ return undefined;
755
+ const prev = last.current;
756
+ if (prev &&
757
+ prev.encoding === encoding &&
758
+ prev.controlCodes === controlCodes &&
759
+ prev.rowStart === row.rowStart &&
760
+ prev.before === row.before &&
761
+ prev.length === row.length &&
762
+ sameBytes(prev.bytes, bytes)) {
763
+ return prev;
764
+ }
765
+ const cells = decoding.decodeRow(bytes, { start: row.rowStart, before: row.before, length: row.length }, controlCodes);
766
+ const next = {
767
+ ...row,
768
+ bytes,
769
+ encoding,
770
+ controlCodes,
771
+ cells,
772
+ spans: glyphSpans(cells, row.rowStart),
773
+ };
774
+ last.current = next;
775
+ return next;
776
+ }
777
+ function HexRowImpl({ rowStart, length, append, modified, bytes, before, state, textState, selFrom, selTo, hoverFrom, hoverTo, caret, caretNibble, columns, groupSize, address, uppercase, fadeZeroBytes, controlCodes, showText, decoding, encoding, }) {
778
+ const decoded = useDecodedRow(showText && textState === "ready" ? bytes : undefined, { rowStart, before, length }, decoding, encoding, controlCodes);
779
+ const text = decoded?.cells;
780
+ const edited = parseModified(modified);
781
+ const hexCells = [];
782
+ const textCells = [];
783
+ for (let i = 0; i < columns; i++) {
784
+ const offset = rowStart + i;
785
+ const groupEnd = flag(isGroupEnd(i, columns, groupSize));
786
+ const hov = flag(offset >= hoverFrom && offset <= hoverTo);
787
+ if (append && i === length) {
788
+ const car = offset === caret;
789
+ hexCells.push(_jsx("span", { className: "orz-hex-cell", "data-offset": offset, "data-pane": "hex", "data-append": "", "data-hover": hov, "data-caret": car ? "0" : undefined, "data-group-end": groupEnd, children: _jsx("span", { className: "orz-hex-cell-content" }) }, i));
790
+ if (showText) {
791
+ textCells.push(_jsx("span", { className: "orz-hex-cell orz-hex-text-cell", "data-offset": offset, "data-pane": "text", "data-append": "", "data-hover": hov, "data-caret": flag(car), children: _jsx("span", { className: "orz-hex-cell-content" }) }, i));
792
+ }
793
+ continue;
794
+ }
795
+ if (i >= length) {
796
+ hexCells.push(_jsx("span", { className: "orz-hex-cell", "data-empty": "", "data-group-end": groupEnd, children: _jsx("span", { className: "orz-hex-cell-content" }) }, i));
797
+ if (showText) {
798
+ textCells.push(_jsx("span", { className: "orz-hex-cell orz-hex-text-cell", "data-empty": "", children: _jsx("span", { className: "orz-hex-cell-content" }) }, i));
799
+ }
800
+ continue;
801
+ }
802
+ const sel = flag(selFrom >= 0 && offset >= selFrom && offset <= selTo);
803
+ const car = flag(offset === caret);
804
+ const mod = flag(edited[i] === true);
805
+ const glyph = decoded?.spans[i];
806
+ const byte = bytes?.[before + i];
807
+ const digits = byte === undefined
808
+ ? state === "error"
809
+ ? "??"
810
+ : "··"
811
+ : hexByte(byte, uppercase);
812
+ hexCells.push(_jsx("span", { className: "orz-hex-cell", "data-offset": offset, "data-pane": "hex", "data-sel": sel, "data-hover": hov, "data-caret": car === undefined ? car : (caretNibble ?? ""), "data-modified": mod, "data-group-end": groupEnd, "data-faded": flag(fadeZeroBytes && byte === 0), "data-state": byte === undefined ? state : undefined, "data-glyph": glyph?.[0], "data-glyph-length": glyph?.[1], children: _jsx("span", { className: "orz-hex-cell-content", children: caretNibble === undefined ? (digits) : (_jsxs(_Fragment, { children: [_jsx("span", { className: "orz-hex-nibble", children: digits[0] }), _jsx("span", { className: "orz-hex-nibble", children: digits[1] })] })) }) }, i));
813
+ if (!showText)
814
+ continue;
815
+ const cell = text?.[i];
816
+ let box;
817
+ if (cell?.kind === "char" && cell.text) {
818
+ const width = glyphCells(cell.text);
819
+ const glyphLength = cell.length ?? 1;
820
+ if (glyphLength > 1 || width > 1) {
821
+ // A glyph crossing the row end is drawn here only, squeezed into
822
+ // the bytes this row has.
823
+ const span = Math.min(glyphLength, length - i);
824
+ box = { span, squeeze: Math.min(1, span / width) };
825
+ }
826
+ }
827
+ textCells.push(_jsxs("span", { className: "orz-hex-cell orz-hex-text-cell", "data-offset": offset, "data-pane": "text", "data-sel": sel, "data-hover": hov, "data-caret": car, "data-modified": mod, "data-kind": cell?.kind, "data-faded": flag(cell?.kind === "control" || cell?.kind === "dot"), "data-state": cell ? undefined : textState, "data-glyph": glyph?.[0], "data-glyph-length": glyph?.[1], children: [_jsx("span", { className: cell?.kind === "control"
828
+ ? "orz-hex-cell-content orz-hex-control"
829
+ : "orz-hex-cell-content", children: box ? null : cell?.kind === "control" && cell.text ? (_jsxs(_Fragment, { children: [_jsx("b", { children: cell.text[0] }), _jsx("b", { children: cell.text[1] })] })) : cell?.kind === "dot" ? (".") : ((cell?.text ?? "")) }), box && (_jsx("span", { className: "orz-hex-glyph", style: {
830
+ "--hex-glyph-span": box.span,
831
+ "--hex-glyph-squeeze": box.squeeze,
832
+ }, children: _jsx("span", { children: cell?.text }) }))] }, i));
833
+ }
834
+ return (_jsxs("div", { className: "orz-hex-row", children: [address !== undefined && _jsx("span", { className: "orz-hex-addr", children: address }), hexCells, showText && (_jsxs(_Fragment, { children: [_jsx("span", { className: "orz-hex-pane-gap" }), textCells] }))] }));
835
+ }
836
+ function sameBytes(a, b) {
837
+ if (a === b)
838
+ return true;
839
+ if (!a || !b || a.length !== b.length)
840
+ return false;
841
+ for (let i = 0; i < a.length; i++)
842
+ if (a[i] !== b[i])
843
+ return false;
844
+ return true;
845
+ }
846
+ // A sync source hands out a fresh subarray on every render; equal bytes
847
+ // must still skip the row.
848
+ const HexRow = memo(HexRowImpl, (prev, next) => {
849
+ for (const key of Object.keys(next)) {
850
+ if (key === "bytes") {
851
+ if (!sameBytes(prev.bytes, next.bytes))
852
+ return false;
853
+ }
854
+ else if (prev[key] !== next[key]) {
855
+ return false;
856
+ }
857
+ }
858
+ return true;
859
+ });
860
+ //# sourceMappingURL=hex-grid.js.map