meatloaf-config/src/app/components/HexEditor.tsx

389 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<void>;
}
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<HTMLDivElement>(null);
const searchRef = useRef<HTMLInputElement>(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<number>();
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<number>();
if (curStart >= 0) { for (let i = 0; i < needleLen; i++) curMatchSet.add(curStart + i); }
return (
<div className="flex flex-col h-full bg-neutral-950">
{/* ── Toolbar ── */}
<div className="flex items-center gap-2 px-3 py-1.5 bg-neutral-900 border-b border-neutral-700 flex-shrink-0 text-xs">
{!readOnly && (
<button
onClick={() => { setEditMode(v => !v); scrollRef.current?.focus(); }}
className={`px-2 py-1 rounded inline-flex items-center gap-1 ${editMode ? 'bg-amber-600 text-white' : 'bg-neutral-700 text-neutral-300 hover:bg-neutral-600'}`}
>
{editMode ? <><Eye className="w-3.5 h-3.5" /> View</> : <><Pencil className="w-3.5 h-3.5" /> Edit</>}
</button>
)}
{editMode && (
<>
<button onClick={() => dispatch({ type: 'undo' })} disabled={!canUndo}
className="p-1 rounded text-neutral-400 hover:text-white disabled:opacity-30" title="Undo (Ctrl+Z)">
<Undo2 className="w-3.5 h-3.5" />
</button>
<button onClick={() => dispatch({ type: 'redo' })} disabled={!canRedo}
className="p-1 rounded text-neutral-400 hover:text-white disabled:opacity-30" title="Redo (Ctrl+Y)">
<Redo2 className="w-3.5 h-3.5" />
</button>
{dirty && onSave && (
<button onClick={() => void handleSave()} disabled={saving}
className="px-2 py-1 rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 inline-flex items-center gap-1">
<Save className="w-3.5 h-3.5" /> {saving ? 'Saving…' : 'Save'}
</button>
)}
</>
)}
<div className="flex-1" />
<span className="text-neutral-500 mr-1">{current.length.toLocaleString()} bytes</span>
<button
onClick={openSearch}
className={`p-1 rounded ${searchOpen ? 'bg-neutral-700 text-white' : 'text-neutral-400 hover:text-white'}`}
title="Search (Ctrl+F)"
>
<Search className="w-3.5 h-3.5" />
</button>
</div>
{/* ── Search bar ── */}
{searchOpen && (
<div className="flex items-center gap-2 px-3 py-1.5 bg-neutral-800 border-b border-neutral-700 flex-shrink-0 text-xs">
<Search className="w-3 h-3 text-neutral-500 flex-shrink-0" />
<input
ref={searchRef}
value={query}
onChange={e => 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"
/>
<button
onClick={() => setSearchType(t => t === 'text' ? 'hex' : 'text')}
className="px-2 py-0.5 rounded bg-neutral-700 text-neutral-300 hover:bg-neutral-600 font-mono"
title="Toggle text / hex search"
>
{searchType === 'text' ? 'Txt' : 'Hex'}
</button>
{query.trim() && (
<span className={matches.length > 0 ? 'text-neutral-400' : 'text-red-400'}>
{matches.length > 0 ? `${matchIdx + 1}/${matches.length}` : 'Not found'}
</span>
)}
{matches.length > 1 && (
<>
<button onClick={() => goToMatch(matchIdx - 1)} className="text-neutral-400 hover:text-white px-0.5" title="Previous (Shift+Enter)"></button>
<button onClick={() => goToMatch(matchIdx + 1)} className="text-neutral-400 hover:text-white px-0.5" title="Next (Enter)"></button>
</>
)}
<button onClick={closeSearch} className="text-neutral-500 hover:text-neutral-300">
<X className="w-3.5 h-3.5" />
</button>
</div>
)}
{/* ── Hex grid (virtual scroll) ── */}
<div
ref={scrollRef}
tabIndex={0}
onKeyDown={onKeyDown}
onScroll={e => 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 */}
<div style={{ height: paddingTop + 12 }} />
{/* {cursor < 0 && firstRenderRow === 0 && (
<div className="px-3 text-neutral-600 text-xs mb-2 font-mono">
Click a cell to position cursor{editMode ? ' · type hex digits or ASCII to edit' : ''}
</div>
)} */}
<div className="px-3 font-mono text-xs leading-5 whitespace-nowrap">
{Array.from({ length: lastRenderRow - firstRenderRow }, (_, i) => {
const row = firstRenderRow + i;
const base = row * BYTES_PER_ROW;
return (
<div key={row} className="flex items-center">
{/* Address */}
<span className="text-neutral-600 w-20 flex-shrink-0 select-none">
{base.toString(16).padStart(8, '0').toUpperCase()}
</span>
{/* Hex pane */}
<div className="flex mr-2">
{Array.from({ length: BYTES_PER_ROW }, (_, col) => {
const idx = base + col;
if (idx >= current.length) {
return <span key={col} className={`inline-block w-6 text-center${col === 8 ? ' ml-2' : ''}`} />;
}
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 (
<span
key={col}
className={`inline-block w-6 text-center cursor-pointer${gap} ${color}${ring}`}
onClick={() => { setCursor(idx); setNibble(0); setPane('hex'); scrollRef.current?.focus(); }}
>
{byte.toString(16).padStart(2, '0').toUpperCase()}
</span>
);
})}
</div>
{/* Separator */}
<span className="text-neutral-700 mr-2 select-none"></span>
{/* ASCII pane */}
<div className="flex">
{Array.from({ length: BYTES_PER_ROW }, (_, col) => {
const idx = base + col;
if (idx >= current.length) return <span key={col} className="inline-block w-[9px]" />;
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 (
<span
key={col}
className={`inline-block w-[9px] text-center cursor-pointer ${color}${ring}`}
onClick={() => { setCursor(idx); setPane('ascii'); scrollRef.current?.focus(); }}
>
{char}
</span>
);
})}
</div>
</div>
);
})}
</div>
{/* bottom spacer */}
<div style={{ height: paddingBottom + 12 }} />
</div>
</div>
);
}