// 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(' | '); } // Row layout, in character cells: offset, gap, hex, gap, ASCII. const BYTES_PER_ROW = [16, 8, 4]; // widest first const GAP = 1.2; const rowChars = (n) => 4 + GAP * 2 + n * 3 + (n === 16 ? 1 : 0) + n; /** * Render a hex dump that always fits its container: it picks 16, 8 or 4 bytes * per row and sizes the font so a whole row fits without sideways scrolling, and * re-fits whenever the container changes size (rotation, window resize). * Returns { el, inspector }; the inspector shows the decoded hovered/selected byte. * * Options: maxBytes (show only the first N bytes), maxFont / minFont (px). */ export function hexView(bytes, { maxBytes = Infinity, maxFont = 15, minFont = 9.5 } = {}) { 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' }); const total = Math.min(bytes.length, maxBytes); let pinned = null; let cells = new Map(); let perRow = 0; let ratio = 0; // width of one character as a fraction of the font size function build(n) { perRow = n; cells = new Map(); const rows = []; for (let start = 0; start < total; start += n) { const hexCells = []; const ascCells = []; for (let k = 0; k < n; k++) { const i = start + k; if (i >= total) { 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 (n === 16 && k === 7) hexCells.push(h('span', { class: 'hx-gap' }, ' ')); } rows.push( 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), ), ); } body.replaceChildren(...rows); if (pinned !== null) cells.get(pinned)?.forEach((c) => c.classList.add('sel')); } function measure() { if (ratio) return ratio; const probe = h('span', { class: 'hx-probe' }, 'M'.repeat(20)); body.append(probe); ratio = probe.getBoundingClientRect().width / 20 / 100; probe.remove(); return ratio || 0.6; } function layout() { const cs = getComputedStyle(body); const avail = body.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight); if (!(avail > 0)) return; // not laid out yet const r = measure(); const fontFor = (n) => avail / (rowChars(n) * r); // The widest layout that still gives a readable font; keep the current one a // little longer than that, so a scrollbar appearing cannot make it flap. let n = BYTES_PER_ROW.find((c) => fontFor(c) >= minFont - (c === perRow ? 0.6 : 0)) ?? 4; body.style.fontSize = `${Math.min(maxFont, fontFor(n)).toFixed(2)}px`; if (n !== perRow) build(n); } 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); }); build(16); if (typeof ResizeObserver !== 'undefined') new ResizeObserver(layout).observe(body); // fires once attached, before first paint else window.addEventListener('resize', layout); 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); }