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(null); const scrollContainerRef = useRef(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) => { 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 ) => { 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) => { 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) => { 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 ( 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()} ); }); // Pad remaining columns if row is incomplete while (hexBytes.length < bytesPerRow) { hexBytes.push( {' '} ); } // ASCII column const asciiChars = Array.from(rowData).map((byte, colIndex) => { const globalOffset = address + colIndex; const char = byteToChar(byte); return ( handleAsciiClick(Math.floor(i / bytesPerRow), colIndex, byte, globalOffset)} title={`Click to edit ASCII char at offset ${globalOffset.toString(16).toUpperCase()} (byte value: ${byte})`} > {char} ); }); // Pad remaining columns if row is incomplete while (asciiChars.length < bytesPerRow) { asciiChars.push( {' '} ); } rows.push(
{addressStr}
{hexBytes.map((byte, idx) => ( {byte} {idx < bytesPerRow - 1 && } ))}
{asciiChars}
); } 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 ( ); } return ( handleByteClick(globalRowIndex, colIndex, byte, globalOffset)} title={`Click to edit byte at offset ${globalOffset.toString(16).toUpperCase()}`} > {byte.toString(16).padStart(2, '0').toUpperCase()} ); }); // Pad remaining columns if row is incomplete while (hexBytes.length < bytesPerRow) { hexBytes.push( {' '} ); } // 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 ( ); } const char = byteToChar(byte); return ( handleAsciiClick(globalRowIndex, colIndex, byte, globalOffset)} title={`Click to edit ASCII char at offset ${globalOffset.toString(16).toUpperCase()} (byte value: ${byte})`} > {char} ); }); // Pad remaining columns if row is incomplete while (asciiChars.length < bytesPerRow) { asciiChars.push( {' '} ); } rows.push(
{addressStr}
{hexBytes.map((byte, idx) => ( {byte} {idx < bytesPerRow - 1 && } ))}
{asciiChars}
); } return rows; }; if (!metadata) { return (
Hex Editor - No file loaded
Upload an image to start editing
); } if (!originalData) { return (
Hex Editor - {metadata.fileName} ({metadata.fileSize} bytes)
Loading file data...
); } return (
Hex Editor - {metadata.fileName} ({metadata.fileSize} bytes)
File: {metadata.fileSize} bytes • Scroll position: {Math.floor(scrollTop / ROW_HEIGHT * BYTES_PER_ROW).toString(16).toUpperCase()} {modified && ' • Modified'}
Address Hex Data ASCII
{renderVirtualizedHexView()}