Created
March 29, 2025 13:14
-
-
Save bigmistqke/563b5c6b870154de56f24443d389029c to your computer and use it in GitHub Desktop.
hex editor
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { render } from "solid-js/web"; | |
import { createSignal, Index, For, type JSX } from "solid-js"; | |
import { createStore, type SetStoreFunction } from "solid-js/store"; | |
import "./index.css"; | |
function getNodeAndOffsetAtIndex(element: Node, index: number) { | |
const nodes = element.childNodes; | |
let accumulator = 0; | |
// Determine which node contains the selection-(start|end) | |
for (const node of nodes) { | |
const contentLength = node.textContent?.length || 0; | |
accumulator += contentLength; | |
if (accumulator >= index) { | |
const offset = index - (accumulator - contentLength); | |
if (node instanceof Text) { | |
return { | |
node, | |
offset, | |
}; | |
} | |
return getNodeAndOffsetAtIndex(node, offset); | |
} | |
} | |
throw `Could not find node`; | |
} | |
type RangeVector = { start: number; end: number }; | |
function getSelection(element: HTMLElement): RangeVector { | |
const selection = document.getSelection(); | |
if (!selection || selection.rangeCount === 0) { | |
return { start: 0, end: 0 }; | |
} | |
const documentRange = selection.getRangeAt(0); | |
// Create a range that spans from the start of the contenteditable to the selection start | |
const elementRange = document.createRange(); | |
elementRange.selectNodeContents(element); | |
elementRange.setEnd(documentRange.startContainer, documentRange.startOffset); | |
// The length of the elementRange gives the start offset relative to the whole content | |
const start = elementRange.toString().length; | |
const end = start + documentRange.toString().length; | |
return { start, end }; | |
} | |
function select( | |
element: HTMLElement, | |
{ start, end }: { start: number; end?: number }, | |
) { | |
const selection = document.getSelection()!; | |
const range = document.createRange(); | |
selection.removeAllRanges(); | |
const resultStart = getNodeAndOffsetAtIndex(element, start); | |
range.setStart(resultStart.node, resultStart.offset); | |
if (end) { | |
const resultEnd = getNodeAndOffsetAtIndex(element, end); | |
range.setEnd(resultEnd.node, resultEnd.offset); | |
} else { | |
range.setEnd(resultStart.node, resultStart.offset); | |
} | |
selection.addRange(range); | |
} | |
const hexValues = Array.from({ length: 16 }, (_, i) => i.toString(16)); | |
const isHex = (char: string | undefined) => char && hexValues.includes(char); | |
const isAscii = (char: unknown): char is string => { | |
if (typeof char !== "string") return false; | |
const charCode = char.charCodeAt(0); | |
return charCode > 32 && charCode < 127; | |
}; | |
function floor(value: number, floor: number) { | |
return Math.floor(value / floor) * floor; | |
} | |
function HexEditor(props: { | |
array: Array<number>; | |
setArray(index: number, value: number): void; | |
}) { | |
let container: HTMLDivElement; | |
function scrollToSelection() { | |
const selection = window.getSelection(); | |
if (!selection?.rangeCount) return; // Exit if no selection | |
const range = selection.getRangeAt(0); | |
const rect = range.getBoundingClientRect(); | |
const containerRect = container.getBoundingClientRect(); | |
if (rect.top < containerRect.top) { | |
container.scrollTop += | |
rect.top - containerRect.top - containerRect.height / 4; | |
} else if (rect.bottom > containerRect.bottom) { | |
container.scrollTop += | |
rect.bottom - containerRect.bottom + containerRect.height / 4; | |
} | |
} | |
return ( | |
<div | |
ref={container!} | |
style={{ | |
height: "100vh", | |
padding: "10px", | |
overflow: "auto", | |
display: "grid", | |
"grid-template-columns": "auto 1fr auto", | |
gap: "20px", | |
"font-family": "monospace", | |
}} | |
> | |
<div | |
style={{ | |
display: "grid", | |
"grid-template-rows": `repeat(${props.array.length / 16}, 1fr)`, | |
}} | |
> | |
<Index each={Array.from({ length: props.array.length / 16 })}> | |
{(_, index) => ( | |
<div style={{ "text-align": "center", padding: "5px" }}> | |
{(index * 16).toString(16).padStart(8, "0")} | |
</div> | |
)} | |
</Index> | |
</div> | |
<div | |
style={{ | |
display: "grid", | |
"grid-template-columns": "repeat(16, 1fr)", | |
"user-select": "none", | |
}} | |
contentEditable | |
onPointerUp={(e) => { | |
const selection = getSelection(e.currentTarget); | |
if (floor(selection.start, 2) !== floor(selection.end, 2)) return; | |
if (e.target instanceof HTMLElement) { | |
select(e.target, { start: 0, end: 2 }); | |
} | |
}} | |
onPaste={(e) => { | |
console.log(e.clipboardData?.getData("text/plain").split("\n")); | |
}} | |
onKeyDown={(e) => { | |
const selection = getSelection(e.currentTarget); | |
switch (e.code) { | |
case "ArrowLeft": { | |
e.preventDefault(); | |
const start = floor(selection.start, 2) - 2; | |
if (start > 0) { | |
select(e.currentTarget, { | |
start, | |
end: start + 2, | |
}); | |
} | |
break; | |
} | |
case "ArrowRight": { | |
e.preventDefault(); | |
const end = floor(selection.end, 2) + 2; | |
if (end <= props.array.length * 2) { | |
select(e.currentTarget, { | |
start: end - 2, | |
end, | |
}); | |
} | |
break; | |
} | |
case "ArrowUp": { | |
e.preventDefault(); | |
const start = floor(selection.start, 2) - 16 * 2; | |
if (start > 0) { | |
select(e.currentTarget, { | |
start, | |
end: start + 2, | |
}); | |
} | |
break; | |
} | |
case "ArrowDown": { | |
e.preventDefault(); | |
const start = floor(selection.start, 2) + 16 * 2; | |
if (start <= props.array.length * 2) { | |
select(e.currentTarget, { | |
start, | |
end: start + 2, | |
}); | |
} | |
break; | |
} | |
} | |
scrollToSelection(); | |
}} | |
onBeforeInput={(e) => { | |
e.preventDefault(); | |
const selection = getSelection(e.currentTarget); | |
switch (e.inputType) { | |
case "insertText": { | |
const data = e.data?.toLowerCase(); | |
if (isHex(data)) { | |
const offset = selection.start % 2; | |
const index = Math.floor(selection.start / 2); | |
const hex = props.array[index].toString(16).padStart(2, "0"); | |
props.setArray( | |
index, | |
parseInt( | |
offset === 0 ? data + hex.slice(1) : hex.slice(0, 1) + data, | |
16, | |
), | |
); | |
if (floor(selection.start + 1, 2) === selection.start) { | |
select(e.currentTarget, { | |
start: selection.start + 1, | |
end: selection.start + 2, | |
}); | |
} else { | |
select(e.currentTarget, { | |
start: selection.start + 1, | |
end: selection.start + 3, | |
}); | |
} | |
} | |
break; | |
} | |
case "deleteContentBackward": { | |
const index = Math.floor(selection.start / 2); | |
props.setArray(index, 0); | |
select(e.currentTarget, { | |
start: selection.start, | |
end: selection.end, | |
}); | |
} | |
} | |
}} | |
> | |
<Index each={props.array}> | |
{(value) => ( | |
<span | |
style={{ | |
display: "inline-block", | |
"text-align": "center", | |
padding: "5px", | |
}} | |
> | |
{value().toString(16).padStart(2, "0").toUpperCase()} | |
</span> | |
)} | |
</Index> | |
</div> | |
<div | |
style={{ | |
display: "grid", | |
"grid-template-columns": "repeat(16, 1fr)", | |
}} | |
contentEditable | |
onPointerUp={(e) => { | |
if (e.target instanceof HTMLElement) { | |
select(e.target, { start: 0, end: 1 }); | |
} | |
}} | |
onKeyDown={(e) => { | |
const selection = getSelection(e.currentTarget); | |
switch (e.code) { | |
case "ArrowLeft": { | |
e.preventDefault(); | |
const start = selection.start - 1; | |
if (start >= 0) { | |
select(e.currentTarget, { | |
start, | |
end: selection.start, | |
}); | |
} | |
break; | |
} | |
case "ArrowRight": { | |
e.preventDefault(); | |
const end = selection.start + 2; | |
if (end <= props.array.length) { | |
select(e.currentTarget, { | |
start: end - 1, | |
end, | |
}); | |
} | |
break; | |
} | |
case "ArrowUp": { | |
e.preventDefault(); | |
const start = selection.start - 16; | |
if (start >= 0) { | |
select(e.currentTarget, { | |
start, | |
end: start + 1, | |
}); | |
} | |
break; | |
} | |
case "ArrowDown": { | |
e.preventDefault(); | |
const start = selection.start + 16; | |
if (start <= props.array.length) { | |
select(e.currentTarget, { | |
start, | |
end: start + 1, | |
}); | |
} | |
break; | |
} | |
} | |
scrollToSelection(); | |
}} | |
onBeforeInput={(e) => { | |
e.preventDefault(); | |
const selection = getSelection(e.currentTarget); | |
switch (e.inputType) { | |
case "insertText": { | |
if (isAscii(e.data)) { | |
const index = Math.floor(selection.start); | |
props.setArray(index, e.data.charCodeAt(0)); | |
select(e.currentTarget, { | |
start: selection.start + 1, | |
end: selection.start + 2, | |
}); | |
} | |
break; | |
} | |
case "deleteContentBackward": { | |
const index = Math.floor(selection.start); | |
props.setArray(index, 0); | |
select(e.currentTarget, { | |
start: selection.start, | |
end: selection.start + 1, | |
}); | |
} | |
} | |
}} | |
> | |
<Index each={props.array}> | |
{(value) => ( | |
<span style={{ "text-align": "center", padding: "0px 1px" }}> | |
{value() > 32 && value() < 127 ? String.fromCharCode(value()) : "."} | |
</span> | |
)} | |
</Index> | |
</div> | |
</div> | |
); | |
} | |
const [array, setArray] = createSignal( | |
Array.from({ length: 256 * 4 }, (_, i) => i % 255), | |
{ equals: false }, | |
); | |
render( | |
() => ( | |
<HexEditor | |
array={array()} | |
setArray={(index, value) => | |
setArray((array) => { | |
array[index] = value; | |
return array; | |
}) | |
} | |
/> | |
), | |
document.getElementById("app")!, | |
); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment