// 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); }