feat(HexEditor): implement responsive bytes per row for improved layout adaptability

This commit is contained in:
Jaime Idolpx 2026-06-11 03:34:35 -04:00
parent 156bc5ff2d
commit a276fe20a9

View File

@ -1,7 +1,6 @@
import { useCallback, useEffect, useReducer, useRef, useState } from 'react'; import { useCallback, useEffect, useReducer, useRef, useState } from 'react';
import { Eye, Pencil, Redo2, Save, Search, Undo2, X } from 'lucide-react'; import { Eye, Pencil, Redo2, Save, Search, Undo2, X } from 'lucide-react';
const BYTES_PER_ROW = 8;
const ROW_HEIGHT = 20; // px — matches leading-5 at Tailwind's 16px base const ROW_HEIGHT = 20; // px — matches leading-5 at Tailwind's 16px base
const CHUNK_ROWS = 256; // virtual-scroll overscan: render prev+curr+next windows const CHUNK_ROWS = 256; // virtual-scroll overscan: render prev+curr+next windows
@ -75,12 +74,25 @@ export default function HexEditor({ data, readOnly = false, onSave }: HexEditorP
const [searchType, setSearchType] = useState<'text' | 'hex'>('text'); const [searchType, setSearchType] = useState<'text' | 'hex'>('text');
const [matchIdx, setMatchIdx] = useState(-1); const [matchIdx, setMatchIdx] = useState(-1);
// Responsive columns
const [bytesPerRow, setBytesPerRow] = useState(8);
// Virtual scroll // Virtual scroll
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const searchRef = useRef<HTMLInputElement>(null); const searchRef = useRef<HTMLInputElement>(null);
const [scrollTop, setScrollTop] = useState(0); const [scrollTop, setScrollTop] = useState(0);
const totalRows = Math.ceil(current.length / BYTES_PER_ROW); useEffect(() => {
const el = scrollRef.current;
if (!el) return;
const update = (w: number) => setBytesPerRow(w >= 600 ? 16 : 8);
const obs = new ResizeObserver(entries => update(entries[0].contentRect.width));
obs.observe(el);
update(el.clientWidth);
return () => obs.disconnect();
}, []);
const totalRows = Math.ceil(current.length / bytesPerRow);
const firstRenderRow = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - CHUNK_ROWS); const firstRenderRow = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - CHUNK_ROWS);
const lastRenderRow = Math.min(totalRows, firstRenderRow + CHUNK_ROWS * 3); const lastRenderRow = Math.min(totalRows, firstRenderRow + CHUNK_ROWS * 3);
const paddingTop = firstRenderRow * ROW_HEIGHT; const paddingTop = firstRenderRow * ROW_HEIGHT;
@ -108,7 +120,7 @@ export default function HexEditor({ data, readOnly = false, onSave }: HexEditorP
// Scroll cursor row into view (programmatic, works with virtual scroll) // Scroll cursor row into view (programmatic, works with virtual scroll)
useEffect(() => { useEffect(() => {
if (cursor < 0) return; if (cursor < 0) return;
const row = Math.floor(cursor / BYTES_PER_ROW); const row = Math.floor(cursor / bytesPerRow);
const rowTop = row * ROW_HEIGHT + 12; // 12 = top padding equivalent const rowTop = row * ROW_HEIGHT + 12; // 12 = top padding equivalent
const el = scrollRef.current; const el = scrollRef.current;
if (!el) return; if (!el) return;
@ -117,7 +129,7 @@ export default function HexEditor({ data, readOnly = false, onSave }: HexEditorP
} else if (rowTop + ROW_HEIGHT > el.scrollTop + el.clientHeight) { } else if (rowTop + ROW_HEIGHT > el.scrollTop + el.clientHeight) {
el.scrollTop = rowTop + ROW_HEIGHT - el.clientHeight; el.scrollTop = rowTop + ROW_HEIGHT - el.clientHeight;
} }
}, [cursor]); }, [cursor, bytesPerRow]);
const pushByte = useCallback((offset: number, value: number) => { const pushByte = useCallback((offset: number, value: number) => {
const next = current.slice(); const next = current.slice();
@ -166,10 +178,10 @@ export default function HexEditor({ data, readOnly = false, onSave }: HexEditorP
const len = current.length; const len = current.length;
if (e.key === 'ArrowRight') { e.preventDefault(); setNibble(0); setCursor(c => Math.min(c < 0 ? 0 : c + 1, len - 1)); return; } if (e.key === 'ArrowRight') { e.preventDefault(); setNibble(0); setCursor(c => Math.min(c < 0 ? 0 : c + 1, len - 1)); return; }
if (e.key === 'ArrowLeft') { e.preventDefault(); setNibble(0); setCursor(c => c <= 0 ? 0 : c - 1); return; } if (e.key === 'ArrowLeft') { e.preventDefault(); setNibble(0); setCursor(c => c <= 0 ? 0 : c - 1); return; }
if (e.key === 'ArrowDown') { e.preventDefault(); setCursor(c => Math.min(c < 0 ? 0 : c + BYTES_PER_ROW, len - 1)); return; } if (e.key === 'ArrowDown') { e.preventDefault(); setCursor(c => Math.min(c < 0 ? 0 : c + bytesPerRow, len - 1)); return; }
if (e.key === 'ArrowUp') { e.preventDefault(); setCursor(c => c < 0 ? 0 : Math.max(c - BYTES_PER_ROW, 0)); return; } if (e.key === 'ArrowUp') { e.preventDefault(); setCursor(c => c < 0 ? 0 : Math.max(c - bytesPerRow, 0)); return; }
if (e.key === 'Home') { e.preventDefault(); setCursor(c => Math.floor(Math.max(c, 0) / BYTES_PER_ROW) * BYTES_PER_ROW); return; } if (e.key === 'Home') { e.preventDefault(); setCursor(c => Math.floor(Math.max(c, 0) / bytesPerRow) * bytesPerRow); return; }
if (e.key === 'End') { e.preventDefault(); setCursor(c => Math.min(Math.ceil((Math.max(c, 0) + 1) / BYTES_PER_ROW) * BYTES_PER_ROW - 1, len - 1)); return; } if (e.key === 'End') { e.preventDefault(); setCursor(c => Math.min(Math.ceil((Math.max(c, 0) + 1) / bytesPerRow) * bytesPerRow - 1, len - 1)); return; }
if (!editMode || cursor < 0) return; if (!editMode || cursor < 0) return;
@ -306,18 +318,18 @@ export default function HexEditor({ data, readOnly = false, onSave }: HexEditorP
<div className="px-3 font-mono text-xs leading-5 whitespace-nowrap"> <div className="px-3 font-mono text-xs leading-5 whitespace-nowrap">
{Array.from({ length: lastRenderRow - firstRenderRow }, (_, i) => { {Array.from({ length: lastRenderRow - firstRenderRow }, (_, i) => {
const row = firstRenderRow + i; const row = firstRenderRow + i;
const base = row * BYTES_PER_ROW; const base = row * bytesPerRow;
return ( return (
<div key={row} className="flex items-center"> <div key={row} className="flex items-center">
{/* Address */} {/* Address */}
<span className="text-neutral-400 w-20 flex-shrink-0 select-none"> <span className="text-neutral-400 w-15 flex-shrink-0 select-none">
{base.toString(16).padStart(8, '0').toUpperCase()} {base.toString(16).padStart(8, '0').toUpperCase()}
</span> </span>
{/* Hex pane */} {/* Hex pane */}
<div className="flex mr-2"> <div className="flex sm:gap-0.5 mr-2">
{Array.from({ length: BYTES_PER_ROW }, (_, col) => { {Array.from({ length: bytesPerRow }, (_, col) => {
const idx = base + col; const idx = base + col;
if (idx >= current.length) { if (idx >= current.length) {
return <span key={col} className={`inline-block w-6 text-center${col === 8 ? ' ml-2' : ''}`} />; return <span key={col} className={`inline-block w-6 text-center${col === 8 ? ' ml-2' : ''}`} />;
@ -349,7 +361,7 @@ export default function HexEditor({ data, readOnly = false, onSave }: HexEditorP
{/* ASCII pane */} {/* ASCII pane */}
<div className="flex"> <div className="flex">
{Array.from({ length: BYTES_PER_ROW }, (_, col) => { {Array.from({ length: bytesPerRow }, (_, col) => {
const idx = base + col; const idx = base + col;
if (idx >= current.length) return <span key={col} className="inline-block w-[9px]" />; if (idx >= current.length) return <span key={col} className="inline-block w-[9px]" />;
const byte = current[idx]; const byte = current[idx];