This commit is contained in:
root
2026-09-20 14:16:41 +02:00
parent 0798933b05
commit b6d1909d7f
56 changed files with 3752 additions and 1989 deletions
+73 -30
View File
@@ -31,50 +31,89 @@ export function describe(bytes, i) {
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. Returns { el, inspector } where inspector is the element
* that shows the decoded value of the hovered/selected byte.
* 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, { rows: maxRows = Infinity } = {}) {
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;
const cells = new Map();
let cells = new Map();
let perRow = 0;
let ratio = 0; // width of one character as a fraction of the font size
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;
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' }, ' '));
}
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' }, ' '));
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.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),
),
);
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'));
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);
@@ -87,6 +126,10 @@ export function hexView(bytes, { rows: maxRows = Infinity } = {}) {
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 };
}