Files
cosmique/src/components/BinaryEditor.tsx
2025-07-08 23:11:03 +02:00

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>
);
}