588 lines
18 KiB
TypeScript
588 lines
18 KiB
TypeScript
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
|
import { useStore } from '@nanostores/react';
|
|
import {
|
|
fileMetadata,
|
|
isCompiling,
|
|
hasModifications,
|
|
totalHexChunks,
|
|
compileImage,
|
|
resetToOriginal,
|
|
byteToChar,
|
|
originalFileData,
|
|
modifiedFileData,
|
|
hexChunkSize,
|
|
pushToUndoStack,
|
|
undo,
|
|
canUndo,
|
|
} from '../stores/imageStore';
|
|
import ActionButtons from './ActionButtons';
|
|
|
|
export default function BinaryEditor() {
|
|
const metadata = useStore(fileMetadata);
|
|
const compiling = useStore(isCompiling);
|
|
const modified = useStore(hasModifications);
|
|
const totalChunks = useStore(totalHexChunks);
|
|
const originalData = useStore(originalFileData);
|
|
const modifiedData = useStore(modifiedFileData);
|
|
const chunkSize = useStore(hexChunkSize);
|
|
const canUndoState = useStore(canUndo);
|
|
|
|
const [hexInput, setHexInput] = useState('');
|
|
const [editingByte, setEditingByte] = useState<{ row: number; col: number; value: string; globalOffset: number } | null>(null);
|
|
const [editingAscii, setEditingAscii] = useState<{ row: number; col: number; value: string; globalOffset: number } | null>(null);
|
|
const [scrollTop, setScrollTop] = useState(0);
|
|
const editorRef = useRef<HTMLDivElement>(null);
|
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Virtual scrolling configuration
|
|
const ROW_HEIGHT = 20; // Height of each hex row in pixels
|
|
const BYTES_PER_ROW = 16;
|
|
const BUFFER_SIZE = 10; // Extra rows to render above/below viewport for smooth scrolling
|
|
|
|
// Calculate virtual scrolling parameters
|
|
const virtualScrollData = useMemo(() => {
|
|
if (!metadata || !originalData) return null;
|
|
|
|
const totalRows = Math.ceil(metadata.fileSize / BYTES_PER_ROW);
|
|
const containerHeight = 400; // Fixed height for hex container
|
|
const visibleRows = Math.ceil(containerHeight / ROW_HEIGHT);
|
|
|
|
const startRow = Math.floor(scrollTop / ROW_HEIGHT);
|
|
const endRow = Math.min(startRow + visibleRows + BUFFER_SIZE * 2, totalRows);
|
|
const actualStartRow = Math.max(0, startRow - BUFFER_SIZE);
|
|
|
|
const startByte = actualStartRow * BYTES_PER_ROW;
|
|
const endByte = Math.min(endRow * BYTES_PER_ROW, metadata.fileSize);
|
|
|
|
return {
|
|
totalRows,
|
|
visibleRows,
|
|
startRow: actualStartRow,
|
|
endRow,
|
|
startByte,
|
|
endByte,
|
|
totalHeight: Math.max(totalRows * ROW_HEIGHT, containerHeight),
|
|
offsetY: actualStartRow * ROW_HEIGHT,
|
|
};
|
|
}, [metadata, originalData, scrollTop]);
|
|
|
|
// Get data for virtual scrolling viewport
|
|
const viewportData = useMemo(() => {
|
|
if (!virtualScrollData || !originalData) return null;
|
|
|
|
const dataToUse = modifiedData || originalData;
|
|
const viewportBytes = dataToUse.slice(virtualScrollData.startByte, virtualScrollData.endByte);
|
|
|
|
return viewportBytes;
|
|
}, [virtualScrollData, originalData, modifiedData]);
|
|
|
|
// Update hex input when viewport data changes
|
|
useEffect(() => {
|
|
if (viewportData) {
|
|
const hexString = Array.from(viewportData)
|
|
.map(byte => byte.toString(16).padStart(2, '0'))
|
|
.join('');
|
|
setHexInput(hexString);
|
|
}
|
|
}, [viewportData]);
|
|
|
|
// Handle scroll events
|
|
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
|
setScrollTop(e.currentTarget.scrollTop);
|
|
}, []);
|
|
|
|
// Random button functionality
|
|
const handleRandomize = useCallback((count: number = 1) => {
|
|
if (!originalData || !virtualScrollData) return;
|
|
|
|
// Push current state to undo stack
|
|
const currentData = modifiedData || new Uint8Array(originalData);
|
|
pushToUndoStack(currentData);
|
|
|
|
const newData = new Uint8Array(currentData);
|
|
|
|
// Get visible range
|
|
const visibleStart = virtualScrollData.startByte;
|
|
const visibleEnd = virtualScrollData.endByte;
|
|
|
|
// Make multiple random changes
|
|
for (let i = 0; i < count; i++) {
|
|
const randomIndex = Math.floor(Math.random() * (visibleEnd - visibleStart)) + visibleStart;
|
|
const randomValue = Math.floor(Math.random() * 256);
|
|
newData[randomIndex] = randomValue;
|
|
}
|
|
|
|
modifiedFileData.set(newData);
|
|
|
|
// Auto-update image after random change
|
|
setTimeout(() => compileImage(), 100);
|
|
}, [originalData, modifiedData, virtualScrollData]);
|
|
|
|
// Undo functionality
|
|
const handleUndo = useCallback(() => {
|
|
undo();
|
|
setTimeout(() => compileImage(), 100);
|
|
}, []);
|
|
|
|
// Global random button functionality (affects entire file)
|
|
const handleGlobalRandomize = useCallback((count: number = 1) => {
|
|
if (!originalData || !metadata) return;
|
|
|
|
// Push current state to undo stack
|
|
const currentData = modifiedData || new Uint8Array(originalData);
|
|
pushToUndoStack(currentData);
|
|
|
|
const newData = new Uint8Array(currentData);
|
|
|
|
// Make random changes across the entire file
|
|
for (let i = 0; i < count; i++) {
|
|
const randomIndex = Math.floor(Math.random() * metadata.fileSize);
|
|
const randomValue = Math.floor(Math.random() * 256);
|
|
newData[randomIndex] = randomValue;
|
|
}
|
|
|
|
modifiedFileData.set(newData);
|
|
|
|
// Auto-update image after random change
|
|
setTimeout(() => compileImage(), 100);
|
|
}, [originalData, modifiedData, metadata]);
|
|
|
|
|
|
const handleHexInputChange = (
|
|
event: React.ChangeEvent<HTMLTextAreaElement>
|
|
) => {
|
|
const value = event.target.value.replace(/[^0-9a-fA-F]/gi, '');
|
|
setHexInput(value);
|
|
};
|
|
|
|
// Auto-updates happen immediately now
|
|
|
|
const handleByteClick = (row: number, col: number, currentValue: number, globalOffset: number) => {
|
|
setEditingByte({
|
|
row,
|
|
col,
|
|
value: currentValue.toString(16).padStart(2, '0').toUpperCase(),
|
|
globalOffset
|
|
});
|
|
};
|
|
|
|
const handleByteEdit = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
if (!editingByte) return;
|
|
const value = e.target.value.replace(/[^0-9a-fA-F]/gi, '').slice(0, 2);
|
|
setEditingByte({ ...editingByte, value });
|
|
};
|
|
|
|
const handleByteSubmit = (e: React.KeyboardEvent) => {
|
|
if (e.key === 'Enter' && editingByte) {
|
|
applyByteEdit();
|
|
} else if (e.key === 'Escape') {
|
|
setEditingByte(null);
|
|
}
|
|
};
|
|
|
|
const applyByteEdit = () => {
|
|
if (!editingByte || !originalData) return;
|
|
|
|
const newValue = parseInt(editingByte.value, 16);
|
|
if (isNaN(newValue)) {
|
|
setEditingByte(null);
|
|
return;
|
|
}
|
|
|
|
// Push current state to undo stack
|
|
const currentData = modifiedData || new Uint8Array(originalData);
|
|
pushToUndoStack(currentData);
|
|
|
|
// Update the full modified data directly
|
|
const newData = new Uint8Array(currentData);
|
|
newData[editingByte.globalOffset] = newValue;
|
|
|
|
// Update store with new modified data
|
|
modifiedFileData.set(newData);
|
|
|
|
setEditingByte(null);
|
|
// Auto-update image after byte edit
|
|
setTimeout(() => compileImage(), 100);
|
|
};
|
|
|
|
const handleAsciiClick = (row: number, col: number, currentValue: number, globalOffset: number) => {
|
|
const char = currentValue >= 32 && currentValue <= 126 ? String.fromCharCode(currentValue) : '.';
|
|
setEditingAscii({
|
|
row,
|
|
col,
|
|
value: char,
|
|
globalOffset
|
|
});
|
|
};
|
|
|
|
const handleAsciiEdit = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
if (!editingAscii) return;
|
|
const value = e.target.value.slice(0, 1); // Only allow single character
|
|
setEditingAscii({ ...editingAscii, value });
|
|
};
|
|
|
|
const handleAsciiSubmit = (e: React.KeyboardEvent) => {
|
|
if (e.key === 'Enter' && editingAscii) {
|
|
applyAsciiEdit();
|
|
} else if (e.key === 'Escape') {
|
|
setEditingAscii(null);
|
|
}
|
|
};
|
|
|
|
const applyAsciiEdit = () => {
|
|
if (!editingAscii || !originalData) return;
|
|
|
|
const char = editingAscii.value;
|
|
if (char.length !== 1) {
|
|
setEditingAscii(null);
|
|
return;
|
|
}
|
|
|
|
const newValue = char.charCodeAt(0);
|
|
|
|
// Push current state to undo stack
|
|
const currentData = modifiedData || new Uint8Array(originalData);
|
|
pushToUndoStack(currentData);
|
|
|
|
// Update the full modified data directly
|
|
const newData = new Uint8Array(currentData);
|
|
newData[editingAscii.globalOffset] = newValue;
|
|
|
|
// Update store with new modified data
|
|
modifiedFileData.set(newData);
|
|
|
|
setEditingAscii(null);
|
|
// Auto-update image after ASCII edit
|
|
setTimeout(() => compileImage(), 100);
|
|
};
|
|
|
|
const renderFallbackHexView = (data: Uint8Array) => {
|
|
const rows = [];
|
|
const bytesPerRow = BYTES_PER_ROW;
|
|
|
|
for (let i = 0; i < data.length; i += bytesPerRow) {
|
|
const rowData = data.slice(i, i + bytesPerRow);
|
|
const address = i;
|
|
|
|
// Address column
|
|
const addressStr = address.toString(16).toUpperCase().padStart(8, '0');
|
|
|
|
// Hex column
|
|
const hexBytes = Array.from(rowData).map((byte, colIndex) => {
|
|
const globalOffset = address + colIndex;
|
|
|
|
return (
|
|
<span
|
|
key={colIndex}
|
|
className="hex-byte"
|
|
onClick={() => handleByteClick(Math.floor(i / bytesPerRow), colIndex, byte, globalOffset)}
|
|
title={`Click to edit byte at offset ${globalOffset.toString(16).toUpperCase()}`}
|
|
>
|
|
{byte.toString(16).padStart(2, '0').toUpperCase()}
|
|
</span>
|
|
);
|
|
});
|
|
|
|
// Pad remaining columns if row is incomplete
|
|
while (hexBytes.length < bytesPerRow) {
|
|
hexBytes.push(
|
|
<span key={hexBytes.length} className="hex-byte hex-byte-empty">
|
|
{' '}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
// ASCII column
|
|
const asciiChars = Array.from(rowData).map((byte, colIndex) => {
|
|
const globalOffset = address + colIndex;
|
|
const char = byteToChar(byte);
|
|
|
|
return (
|
|
<span
|
|
key={colIndex}
|
|
className="ascii-char"
|
|
onClick={() => handleAsciiClick(Math.floor(i / bytesPerRow), colIndex, byte, globalOffset)}
|
|
title={`Click to edit ASCII char at offset ${globalOffset.toString(16).toUpperCase()} (byte value: ${byte})`}
|
|
>
|
|
{char}
|
|
</span>
|
|
);
|
|
});
|
|
|
|
// Pad remaining columns if row is incomplete
|
|
while (asciiChars.length < bytesPerRow) {
|
|
asciiChars.push(
|
|
<span key={asciiChars.length} className="ascii-char ascii-char-empty">
|
|
{' '}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
rows.push(
|
|
<div key={address} className="hex-row" style={{ height: ROW_HEIGHT }}>
|
|
<span className="hex-address">{addressStr}</span>
|
|
<div className="hex-data">
|
|
{hexBytes.map((byte, idx) => (
|
|
<React.Fragment key={idx}>
|
|
{byte}
|
|
{idx < bytesPerRow - 1 && <span className="hex-space"> </span>}
|
|
</React.Fragment>
|
|
))}
|
|
</div>
|
|
<div className="hex-ascii">
|
|
{asciiChars}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return rows;
|
|
};
|
|
|
|
const renderVirtualizedHexView = () => {
|
|
if (!virtualScrollData || !viewportData) {
|
|
// Fallback: render first chunk if virtual scrolling data not available
|
|
if (!originalData) return null;
|
|
|
|
const fallbackData = originalData.slice(0, Math.min(512, originalData.length));
|
|
return renderFallbackHexView(fallbackData);
|
|
}
|
|
|
|
const rows = [];
|
|
const bytesPerRow = BYTES_PER_ROW;
|
|
|
|
for (let i = 0; i < viewportData.length; i += bytesPerRow) {
|
|
const rowData = viewportData.slice(i, i + bytesPerRow);
|
|
const globalRowIndex = virtualScrollData.startRow + Math.floor(i / bytesPerRow);
|
|
const address = globalRowIndex * bytesPerRow;
|
|
|
|
// Address column
|
|
const addressStr = address.toString(16).toUpperCase().padStart(8, '0');
|
|
|
|
// Hex column with editable bytes
|
|
const hexBytes = Array.from(rowData).map((byte, colIndex) => {
|
|
const globalOffset = address + colIndex;
|
|
const isEditing = editingByte?.globalOffset === globalOffset;
|
|
|
|
if (isEditing) {
|
|
return (
|
|
<input
|
|
key={colIndex}
|
|
type="text"
|
|
value={editingByte.value}
|
|
onChange={handleByteEdit}
|
|
onKeyDown={handleByteSubmit}
|
|
onBlur={applyByteEdit}
|
|
className="hex-byte-input"
|
|
autoFocus
|
|
maxLength={2}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<span
|
|
key={colIndex}
|
|
className="hex-byte"
|
|
onClick={() => handleByteClick(globalRowIndex, colIndex, byte, globalOffset)}
|
|
title={`Click to edit byte at offset ${globalOffset.toString(16).toUpperCase()}`}
|
|
>
|
|
{byte.toString(16).padStart(2, '0').toUpperCase()}
|
|
</span>
|
|
);
|
|
});
|
|
|
|
// Pad remaining columns if row is incomplete
|
|
while (hexBytes.length < bytesPerRow) {
|
|
hexBytes.push(
|
|
<span key={hexBytes.length} className="hex-byte hex-byte-empty">
|
|
{' '}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
// ASCII column with editable characters
|
|
const asciiChars = Array.from(rowData).map((byte, colIndex) => {
|
|
const globalOffset = address + colIndex;
|
|
const isEditingAsciiChar = editingAscii?.globalOffset === globalOffset;
|
|
|
|
if (isEditingAsciiChar) {
|
|
return (
|
|
<input
|
|
key={colIndex}
|
|
type="text"
|
|
value={editingAscii.value}
|
|
onChange={handleAsciiEdit}
|
|
onKeyDown={handleAsciiSubmit}
|
|
onBlur={applyAsciiEdit}
|
|
className="ascii-char-input"
|
|
autoFocus
|
|
maxLength={1}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const char = byteToChar(byte);
|
|
return (
|
|
<span
|
|
key={colIndex}
|
|
className="ascii-char"
|
|
onClick={() => handleAsciiClick(globalRowIndex, colIndex, byte, globalOffset)}
|
|
title={`Click to edit ASCII char at offset ${globalOffset.toString(16).toUpperCase()} (byte value: ${byte})`}
|
|
>
|
|
{char}
|
|
</span>
|
|
);
|
|
});
|
|
|
|
// Pad remaining columns if row is incomplete
|
|
while (asciiChars.length < bytesPerRow) {
|
|
asciiChars.push(
|
|
<span key={asciiChars.length} className="ascii-char ascii-char-empty">
|
|
{' '}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
rows.push(
|
|
<div key={address} className="hex-row" style={{ height: ROW_HEIGHT }}>
|
|
<span className="hex-address">{addressStr}</span>
|
|
<div className="hex-data">
|
|
{hexBytes.map((byte, idx) => (
|
|
<React.Fragment key={idx}>
|
|
{byte}
|
|
{idx < bytesPerRow - 1 && <span className="hex-space"> </span>}
|
|
</React.Fragment>
|
|
))}
|
|
</div>
|
|
<div className="hex-ascii">
|
|
{asciiChars}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return rows;
|
|
};
|
|
|
|
|
|
|
|
if (!metadata) {
|
|
return (
|
|
<div className="hex-editor">
|
|
<div className="hex-editor-header">
|
|
<span className="hex-editor-title">
|
|
Hex Editor - No file loaded
|
|
</span>
|
|
</div>
|
|
<div className="hex-editor-content">
|
|
<div style={{ padding: '20px', textAlign: 'center', color: '#888' }}>
|
|
Upload an image to start editing
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!originalData) {
|
|
return (
|
|
<div className="hex-editor">
|
|
<div className="hex-editor-header">
|
|
<span className="hex-editor-title">
|
|
Hex Editor - {metadata.fileName} ({metadata.fileSize} bytes)
|
|
</span>
|
|
</div>
|
|
<div className="hex-editor-content">
|
|
<div style={{ padding: '20px', textAlign: 'center' }}>
|
|
Loading file data...
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
ref={editorRef}
|
|
className="hex-editor"
|
|
>
|
|
<div className="hex-editor-header">
|
|
<span className="hex-editor-title">
|
|
Hex Editor - {metadata.fileName} ({metadata.fileSize} bytes)
|
|
</span>
|
|
</div>
|
|
|
|
<div className="hex-editor-content">
|
|
<div className="hex-controls">
|
|
<div className="chunk-info">
|
|
<small>
|
|
File: {metadata.fileSize} bytes • Scroll position: {Math.floor(scrollTop / ROW_HEIGHT * BYTES_PER_ROW).toString(16).toUpperCase()}
|
|
{modified && ' • Modified'}
|
|
</small>
|
|
</div>
|
|
|
|
<ActionButtons
|
|
handleRandomize={handleRandomize}
|
|
handleGlobalRandomize={handleGlobalRandomize}
|
|
virtualScrollData={virtualScrollData}
|
|
/>
|
|
</div>
|
|
|
|
<div className="hex-view-container">
|
|
<div className="hex-view">
|
|
<div className="hex-header">
|
|
<span className="hex-address-header">Address</span>
|
|
<span className="hex-data-header">Hex Data</span>
|
|
<span className="hex-ascii-header">ASCII</span>
|
|
</div>
|
|
<div
|
|
ref={scrollContainerRef}
|
|
className="hex-content-scroll"
|
|
style={{
|
|
height: '400px',
|
|
overflow: 'auto',
|
|
position: 'relative'
|
|
}}
|
|
onScroll={handleScroll}
|
|
>
|
|
<div
|
|
className="hex-content-virtual"
|
|
style={{
|
|
height: virtualScrollData?.totalHeight || 0,
|
|
position: 'relative'
|
|
}}
|
|
>
|
|
<div
|
|
className="hex-content-viewport"
|
|
style={{
|
|
transform: `translateY(${virtualScrollData?.offsetY || 0}px)`,
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
right: 0
|
|
}}
|
|
>
|
|
{renderVirtualizedHexView()}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="hex-input-section">
|
|
<label>Raw Hex Input (Current View):</label>
|
|
<textarea
|
|
className="hex-input"
|
|
value={hexInput}
|
|
onChange={handleHexInputChange}
|
|
placeholder="Enter hex values..."
|
|
rows={8}
|
|
style={{ minHeight: '120px', maxHeight: '200px' }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
);
|
|
}
|