first commit

This commit is contained in:
2025-07-08 21:49:27 +02:00
commit bc374cad73
21 changed files with 5865 additions and 0 deletions

View File

@ -0,0 +1,714 @@
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';
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 [jumpToChunk, setJumpToChunk] = useState('');
const [jumpToAddress, setJumpToAddress] = useState('');
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;
};
const handleJumpToChunk = (e: React.FormEvent) => {
e.preventDefault();
const chunkIndex = parseInt(jumpToChunk) - 1;
if (!isNaN(chunkIndex) && chunkIndex >= 0 && chunkIndex < totalChunks) {
// Calculate the address of the chunk and scroll to it
const chunkAddress = chunkIndex * chunkSize;
const targetRow = Math.floor(chunkAddress / BYTES_PER_ROW);
const targetScrollTop = targetRow * ROW_HEIGHT;
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTop = targetScrollTop;
}
setScrollTop(targetScrollTop);
setJumpToChunk('');
}
};
const handleJumpToAddress = (e: React.FormEvent) => {
e.preventDefault();
const address = parseInt(jumpToAddress, 16);
if (!isNaN(address) && metadata) {
if (address >= 0 && address < metadata.fileSize) {
// For virtual scrolling, calculate the row and scroll to it
const targetRow = Math.floor(address / BYTES_PER_ROW);
const targetScrollTop = targetRow * ROW_HEIGHT;
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTop = targetScrollTop;
}
setScrollTop(targetScrollTop);
}
setJumpToAddress('');
}
};
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="control-row">
<form onSubmit={handleJumpToChunk} className="jump-form">
<input
type="number"
value={jumpToChunk}
onChange={e => setJumpToChunk(e.target.value)}
placeholder="Jump to chunk..."
className="jump-input"
min="1"
max={totalChunks}
/>
</form>
<form onSubmit={handleJumpToAddress} className="jump-form">
<input
type="text"
value={jumpToAddress}
onChange={e => setJumpToAddress(e.target.value)}
placeholder="Go to address (hex)"
className="jump-input address-input"
/>
</form>
</div>
<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>
<div className="action-buttons">
<button
onClick={compileImage}
className="action-button compile"
disabled={compiling}
title="Update Image"
>
</button>
<button
onClick={() => handleRandomize(1)}
className="action-button random"
disabled={compiling || !originalData}
title="Random (Visible)"
>
1
</button>
<button
onClick={() => handleRandomize(10)}
className="action-button random"
disabled={compiling || !originalData}
title="Random × 10 (Visible)"
>
10
</button>
<button
onClick={() => handleRandomize(100)}
className="action-button random"
disabled={compiling || !originalData}
title="Random × 100 (Visible)"
>
100
</button>
<button
onClick={() => handleGlobalRandomize(1)}
className="action-button global-random"
disabled={compiling || !originalData}
title="Global Random"
>
1
</button>
<button
onClick={() => handleGlobalRandomize(10)}
className="action-button global-random"
disabled={compiling || !originalData}
title="Global Random × 10"
>
10
</button>
<button
onClick={() => handleGlobalRandomize(100)}
className="action-button global-random"
disabled={compiling || !originalData}
title="Global Random × 100"
>
100
</button>
<button
onClick={handleUndo}
className="action-button undo"
disabled={compiling || !canUndoState}
title="Undo"
>
</button>
<button
onClick={resetToOriginal}
className="action-button reset"
disabled={compiling}
title="Reset"
>
</button>
</div>
</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>
);
}