389 lines
17 KiB
TypeScript
389 lines
17 KiB
TypeScript
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>
|
||
);
|
||
}
|