import { useCallback, useEffect, useReducer, useRef, useState } from 'react'; import { Eye, Pencil, Redo2, Save, Search, Undo2, X } from 'lucide-react'; const BYTES_PER_ROW = 16; 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 // ── History ─────────────────────────────────────────────────────────────────── type HistState = { stack: Uint8Array[]; idx: number }; type HistAction = | { type: 'push'; data: Uint8Array } | { type: 'undo' } | { type: 'redo' } | { type: 'reset'; data: Uint8Array }; function histReducer(s: HistState, a: HistAction): HistState { switch (a.type) { case 'push': { const prev = s.stack.slice(0, s.idx + 1); const capped = prev.length > 100 ? prev.slice(prev.length - 100) : prev; const next = [...capped, a.data]; return { stack: next, idx: next.length - 1 }; } case 'undo': return s.idx > 0 ? { ...s, idx: s.idx - 1 } : s; case 'redo': return s.idx < s.stack.length - 1 ? { ...s, idx: s.idx + 1 } : s; case 'reset': return { stack: [a.data], idx: 0 }; } } // ── Search ──────────────────────────────────────────────────────────────────── function matchAll(data: Uint8Array, needle: Uint8Array): number[] { if (needle.length === 0) return []; const results: number[] = []; outer: for (let i = 0; i <= data.length - needle.length; i++) { for (let j = 0; j < needle.length; j++) { if (data[i + j] !== needle[j]) continue outer; } results.push(i); } return results; } function parseHex(s: string): Uint8Array | null { const clean = s.replace(/\s+/g, ''); if (!clean || clean.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(clean)) return null; return new Uint8Array( Array.from({ length: clean.length / 2 }, (_, i) => parseInt(clean.slice(i * 2, i * 2 + 2), 16), ), ); } // ── Component ───────────────────────────────────────────────────────────────── interface HexEditorProps { data: Uint8Array; readOnly?: boolean; onSave?: (data: Uint8Array) => Promise; } export default function HexEditor({ data, readOnly = false, onSave }: HexEditorProps) { const [hist, dispatch] = useReducer(histReducer, { stack: [data.slice()], idx: 0 }); const current = hist.stack[hist.idx]; const [editMode, setEditMode] = useState(false); const [saving, setSaving] = useState(false); const [cursor, setCursor] = useState(-1); const [nibble, setNibble] = useState<0 | 1>(0); const [pane, setPane] = useState<'hex' | 'ascii'>('hex'); const [searchOpen, setSearchOpen] = useState(false); const [query, setQuery] = useState(''); const [searchType, setSearchType] = useState<'text' | 'hex'>('text'); const [matchIdx, setMatchIdx] = useState(-1); // Virtual scroll const scrollRef = useRef(null); const searchRef = useRef(null); const [scrollTop, setScrollTop] = useState(0); const totalRows = Math.ceil(current.length / BYTES_PER_ROW); const firstRenderRow = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - CHUNK_ROWS); const lastRenderRow = Math.min(totalRows, firstRenderRow + CHUNK_ROWS * 3); const paddingTop = firstRenderRow * ROW_HEIGHT; const paddingBottom = (totalRows - lastRenderRow) * ROW_HEIGHT; const needle = query.trim() ? (searchType === 'text' ? new TextEncoder().encode(query) : parseHex(query)) : null; const matches = needle ? matchAll(current, needle) : []; const needleLen = needle?.length ?? 0; const dirty = hist.idx > 0; const canUndo = hist.idx > 0; const canRedo = hist.idx < hist.stack.length - 1; // Reset history when the data prop changes (e.g. after a save that updates the prop) useEffect(() => { dispatch({ type: 'reset', data: data.slice() }); }, [data]); // Jump to first result when query / search type changes useEffect(() => { if (matches.length > 0) { setMatchIdx(0); setCursor(matches[0]); } else setMatchIdx(-1); // eslint-disable-next-line react-hooks/exhaustive-deps }, [query, searchType]); // Scroll cursor row into view (programmatic, works with virtual scroll) useEffect(() => { if (cursor < 0) return; const row = Math.floor(cursor / BYTES_PER_ROW); const rowTop = row * ROW_HEIGHT + 12; // 12 = top padding equivalent const el = scrollRef.current; if (!el) return; if (rowTop < el.scrollTop) { el.scrollTop = rowTop; } else if (rowTop + ROW_HEIGHT > el.scrollTop + el.clientHeight) { el.scrollTop = rowTop + ROW_HEIGHT - el.clientHeight; } }, [cursor]); const pushByte = useCallback((offset: number, value: number) => { const next = current.slice(); next[offset] = value; dispatch({ type: 'push', data: next }); }, [current]); const handleSave = async () => { if (!onSave || !dirty) return; setSaving(true); try { await onSave(current); dispatch({ type: 'reset', data: current.slice() }); } finally { setSaving(false); } }; const goToMatch = (idx: number) => { if (!matches.length) return; const i = ((idx % matches.length) + matches.length) % matches.length; setMatchIdx(i); setCursor(matches[i]); scrollRef.current?.focus(); }; const openSearch = () => { setSearchOpen(true); setTimeout(() => searchRef.current?.focus(), 50); }; const closeSearch = () => { setSearchOpen(false); setQuery(''); scrollRef.current?.focus(); }; const onKeyDown = (e: React.KeyboardEvent) => { const ctrl = e.ctrlKey || e.metaKey; if (ctrl && e.key === 'f') { e.preventDefault(); openSearch(); return; } if (ctrl && e.key === 'z') { e.preventDefault(); dispatch({ type: 'undo' }); return; } if (ctrl && (e.key === 'y' || (e.shiftKey && e.key === 'Z'))) { e.preventDefault(); dispatch({ type: 'redo' }); return; } if (ctrl && e.key === 's' && editMode) { e.preventDefault(); void handleSave(); return; } if (e.key === 'Escape') { if (editMode) setEditMode(false); return; } 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 === '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 === 'ArrowUp') { e.preventDefault(); setCursor(c => c < 0 ? 0 : Math.max(c - BYTES_PER_ROW, 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 === '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 (!editMode || cursor < 0) return; if (pane === 'hex') { const hex = e.key.toLowerCase(); if (/^[0-9a-f]$/.test(hex) && !ctrl) { e.preventDefault(); const v = parseInt(hex, 16); const cur = current[cursor] ?? 0; if (nibble === 0) { pushByte(cursor, (v << 4) | (cur & 0x0f)); setNibble(1); } else { pushByte(cursor, (cur & 0xf0) | v); setNibble(0); setCursor(c => Math.min(c + 1, current.length - 1)); } } } else { if (e.key.length === 1 && !ctrl) { e.preventDefault(); pushByte(cursor, e.key.charCodeAt(0) & 0xff); setCursor(c => Math.min(c + 1, current.length - 1)); } } }; // Highlight sets const allMatchSet = new Set(); for (const pos of matches) { for (let i = 0; i < needleLen; i++) allMatchSet.add(pos + i); } const curStart = matchIdx >= 0 && matches[matchIdx] !== undefined ? matches[matchIdx] : -1; const curMatchSet = new Set(); if (curStart >= 0) { for (let i = 0; i < needleLen; i++) curMatchSet.add(curStart + i); } return (
{/* ── Toolbar ── */}
{!readOnly && ( )} {editMode && ( <> {dirty && onSave && ( )} )}
{current.length.toLocaleString()} bytes
{/* ── Search bar ── */} {searchOpen && (
setQuery(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); goToMatch(matchIdx + (e.shiftKey ? -1 : 1)); } if (e.key === 'Escape') closeSearch(); }} placeholder={searchType === 'text' ? 'Search text…' : 'Search hex (e.g. 48 65 6c)…'} className="flex-1 bg-transparent text-neutral-200 focus:outline-none placeholder:text-neutral-600" /> {query.trim() && ( 0 ? 'text-neutral-400' : 'text-red-400'}> {matches.length > 0 ? `${matchIdx + 1}/${matches.length}` : 'Not found'} )} {matches.length > 1 && ( <> )}
)} {/* ── Hex grid (virtual scroll) ── */}
setScrollTop(e.currentTarget.scrollTop)} className="flex-1 overflow-auto focus:outline-none select-none" onClick={() => scrollRef.current?.focus()} > {/* top spacer — 12px mimics former p-3 top padding */}
{/* {cursor < 0 && firstRenderRow === 0 && (
Click a cell to position cursor{editMode ? ' · type hex digits or ASCII to edit' : ''}
)} */}
{Array.from({ length: lastRenderRow - firstRenderRow }, (_, i) => { const row = firstRenderRow + i; const base = row * BYTES_PER_ROW; return (
{/* Address */} {base.toString(16).padStart(8, '0').toUpperCase()} {/* Hex pane */}
{Array.from({ length: BYTES_PER_ROW }, (_, col) => { const idx = base + col; if (idx >= current.length) { return ; } const byte = current[idx]; const isCursor = idx === cursor; const isCurMatch = curMatchSet.has(idx); const isAllMatch = allMatchSet.has(idx) && !isCurMatch; const color = isCurMatch ? 'bg-orange-500 text-white' : isAllMatch ? 'bg-yellow-800 text-yellow-200' : byte === 0 ? 'text-neutral-700' : 'text-green-400'; const ring = isCursor && pane === 'hex' ? ' ring-1 ring-inset ring-blue-400' : ''; const gap = col === 8 ? ' ml-2' : ''; return ( { setCursor(idx); setNibble(0); setPane('hex'); scrollRef.current?.focus(); }} > {byte.toString(16).padStart(2, '0').toUpperCase()} ); })}
{/* Separator */} {/* ASCII pane */}
{Array.from({ length: BYTES_PER_ROW }, (_, col) => { const idx = base + col; if (idx >= current.length) return ; const byte = current[idx]; const printable = byte >= 32 && byte < 127; const char = printable ? String.fromCharCode(byte) : '·'; const isCursor = idx === cursor; const isCurMatch = curMatchSet.has(idx); const isAllMatch = allMatchSet.has(idx) && !isCurMatch; const color = isCurMatch ? 'bg-orange-500 text-white' : isAllMatch ? 'bg-yellow-800 text-yellow-200' : printable ? 'text-blue-300' : 'text-neutral-700'; const ring = isCursor && pane === 'ascii' ? ' ring-1 ring-inset ring-blue-400' : ''; return ( { setCursor(idx); setPane('ascii'); scrollRef.current?.focus(); }} > {char} ); })}
); })}
{/* bottom spacer */}
); }