Files
2026-09-19 20:21:47 +02:00

104 lines
3.9 KiB
JavaScript

// Hex dump with an inspector: hover or click a byte to decode it.
import { h, pad } from './dom.js';
const hex2 = (b) => pad(b.toString(16).toUpperCase(), 2);
const printable = (b) => (b >= 0x20 && b < 0x7f ? String.fromCharCode(b) : '.');
/** Decode the bytes at offset i as little-endian integers. */
export function inspect(bytes, i) {
const b = (k) => (i + k < bytes.length ? bytes[i + k] : null);
const have = (n) => i + n <= bytes.length;
const out = { offset: i, u8: bytes[i] };
if (have(2)) {
const v = b(0) | (b(1) << 8);
out.u16 = v;
out.i16 = (v << 16) >> 16;
}
if (have(4)) {
const v = (b(0) | (b(1) << 8) | (b(2) << 16) | (b(3) << 24));
out.i32 = v;
out.u32 = v >>> 0;
}
return out;
}
export function describe(bytes, i) {
const d = inspect(bytes, i);
const parts = [`OFFSET 0x${pad(d.offset.toString(16).toUpperCase(), 4)} (${d.offset})`, `U8 ${d.u8}`];
if (d.i16 !== undefined) parts.push(`I16 ${d.i16}`);
if (d.i32 !== undefined) parts.push(`I32 ${d.i32}`, `U32 ${d.u32}`);
return parts.join(' | ');
}
/**
* Render a hex dump. Returns { el, inspector } where inspector is the element
* that shows the decoded value of the hovered/selected byte.
*/
export function hexView(bytes, { rows: maxRows = Infinity } = {}) {
const inspector = h('div', { class: 'hx-inspector', 'aria-live': 'polite' }, 'HOVER OR CLICK A BYTE TO DECODE IT');
const body = h('div', { class: 'hx-body', tabindex: '0', role: 'group', 'aria-label': 'Hex dump' });
let pinned = null;
const cells = new Map();
const rowCount = Math.min(Math.ceil(bytes.length / 16), maxRows);
for (let r = 0; r < rowCount; r++) {
const start = r * 16;
const hexCells = [];
const ascCells = [];
for (let k = 0; k < 16; k++) {
const i = start + k;
if (i >= bytes.length) {
hexCells.push(h('span', { class: 'hx-b pad' }, ' '));
continue;
}
const v = bytes[i];
const cls = v === 0 ? 'hx-b z' : 'hx-b';
const a = h('span', { class: `${cls} a`, 'data-i': i }, printable(v));
const x = h('span', { class: cls, 'data-i': i }, hex2(v));
cells.set(i, [x, a]);
hexCells.push(x);
ascCells.push(a);
if (k === 7) hexCells.push(h('span', { class: 'hx-gap' }, ' '));
}
body.append(
h('div', { class: 'hx-row' },
h('span', { class: 'hx-off' }, pad(start.toString(16).toUpperCase(), 4)),
h('span', { class: 'hx-hex' }, hexCells),
h('span', { class: 'hx-asc' }, ascCells),
),
);
}
const select = (i, pin) => {
if (pin) {
if (pinned !== null) cells.get(pinned)?.forEach((c) => c.classList.remove('sel'));
pinned = pinned === i ? null : i;
if (pinned !== null) cells.get(pinned).forEach((c) => c.classList.add('sel'));
}
const shown = pinned ?? i;
inspector.textContent = shown === null ? 'HOVER OR CLICK A BYTE TO DECODE IT' : describe(bytes, shown);
};
const target = (e) => {
const t = e.target.closest('[data-i]');
return t ? Number(t.dataset.i) : null;
};
body.addEventListener('mouseover', (e) => { const i = target(e); if (i !== null && pinned === null) select(i, false); });
body.addEventListener('mouseleave', () => { if (pinned === null) select(null, false); });
body.addEventListener('click', (e) => { const i = target(e); if (i !== null) select(i, true); });
return { el: h('div', { class: 'hex' }, body, inspector), inspector };
}
/** Parse hex text ("de ad 0xBE, ef") into bytes; throws on bad input. */
export function parseHex(text) {
const cleaned = text.replace(/0x/gi, ' ').replace(/[,\s]+/g, ' ').trim();
if (!cleaned) return new Uint8Array(0);
const out = [];
for (const tok of cleaned.split(' ')) {
if (!/^[0-9a-fA-F]+$/.test(tok) || tok.length % 2) throw new Error(`"${tok}" is not whole bytes of hex`);
for (let i = 0; i < tok.length; i += 2) out.push(parseInt(tok.slice(i, i + 2), 16));
}
return Uint8Array.from(out);
}