In this post we will explore the design choices and strategies used to create the hexadecimal and ASCII views of a binary editor.
A binary editor is a tool designed for directly manipulating binary data. In its first version, our editor will focus on displaying data in two primary views:
The hexValue
function converts a byte into its hexadecimal representation, ensuring that each value is properly padded to two characters.
function hexValue(byte: number | undefined) {
return byte?.toString(16).padStart(2, '0') || '';
}
The isPrintable
function checks if a byte corresponds to a printable ASCII character. The getPrintableCharacter
function then returns either the corresponding character or a placeholder (e.g., .
for non-printable characters).
function isPrintable(byte: number) {
return byte >= 0x20 && byte <= 0x7e;
}
function getPrintableCharacter(byte: number | undefined): string {
return byte === undefined
? ' '
: isPrintable(byte)
? String.fromCharCode(byte)
: '.';
}
The code is designed to handle various screen sizes and render only the visible portion of the data using virtual scrolling.
Virtual scrolling is a technique used to optimize the rendering of long lists or large datasets. Instead of loading and displaying every item in the list at once, virtual scrolling dynamically loads only the elements that are currently in the viewport. As the user scrolls, new elements are rendered while old ones are removed, creating the illusion of a continuously scrolling list.
The binary editor uses the virtualScroll
function from my web components library for rendering. Here's how it works:
return virtualScroll({
host: container,
axis: 'y',
scrollElement: $,
dataLength: 0,
refresh: onResize($).switchMap(() => {
cols = computeColumnCount(measureBox);
rows = Math.ceil(buffer.length / cols);
return merge(
navigationGrid(
navigationGridOptions({
host: container,
selector: 'span',
startSelector: ':focus',
focusSelector: 'span',
columns: cols,
}),
)
.tap(el => (el as HTMLElement)?.focus())
.ignoreElements(),
of({ dataLength: rows }),
);
}),
render(index, elementIndex) {
const start = index * cols;
return renderRow(start, elementIndex);
},
}).raf(() => assistToken());
Parameters:
host
: The host
parameter is the container element that holds the binary data. It acts as the viewport where the virtual scrolling takes place.
axis
: The axis
is set to 'y'
, indicating that the scrolling will occur vertically.
scrollElement
: This is the scrollable element that the virtual scroll function will monitor. In this case, it's the editor component itself ($
).
dataLength
: Initially set to 0
, this parameter will be updated dynamically as the content is loaded. It represents the total number of rows that need to be rendered.
refresh
: When the editor's dimensions change this function recalculates the number of columns that can fit within the current viewport. It returns an observable that merges the navigation grid setup with the updated data length.
render(index, elementIndex)
: This function is responsible for rendering individual rows of binary data. The index
corresponds to the current row being rendered, while elementIndex
is the position within the container. The renderRow
function is called here to populate the row with hexadecimal and ASCII representations of the binary data.
The computeColumnCount
function, used by the refresh
observable, calculates how many tokens fit in a row based on the combined width of a token and a character.
function computeColumnCount({
tokenWidth,
charWidth,
}: {
tokenWidth: number;
charWidth: number;
}) {
const combinedWidth = Math.ceil(tokenWidth + charWidth);
return (container.clientWidth / combinedWidth) | 0;
}
The renderRow
function is responsible for converting a segment of the binary data into its visual representation, row by row, ensuring that the data is correctly displayed and aligned in the editor.
/**
* This function takes a starting byte index and an element index, and renders the corresponding
* row of binary data. It updates the existing row element if available, or creates a new one if necessary.
*
* @param start The starting byte index for the row.
* @param elementIndex The index of the row element in the container.
* @returns The rendered row element.
*/
function renderRow(start: number, elementIndex: number) {
const row =
(hexContainer.children[elementIndex] as HTMLElement) ?? createRow();
const asciiEl = asciiContainer.children[elementIndex];
const end = start + cols;
const chars: string[] = [];
for (let i = start, col = 1; i < end; i++, col++) {
const byte = buffer[i];
chars.push(getPrintableCharacter(byte));
const token = row.children[col] as BinaryToken;
if (token) {
requestAnimationFrame(() => (token.textContent = hexValue(byte)));
token.byteIndex = i;
} else row.append(hexToken(buffer, i));
}
// Remove any extraneous children from the row. This ensures that
// the row always contains the correct number of tokens, even if the
// number of columns has changed.
while (row.children[cols + 1]) row.children[cols + 1].remove();
requestAnimationFrame(() => (asciiEl.textContent = chars.join('')));
return row;
}
The renderRow
function updates existing elements where possible instead of creating new ones. This minimizes the number of DOM operations, which can be expensive.
When updating the DOM, particularly in a loop, there's a risk of triggering layout recalculations or reflows. Every time you alter the DOM, such as by setting the textContent
of an element, the browser may need to re-calculate the layout. If not managed, this can happen multiple times within a single function, resulting in performance issues.
To mitigate this, we use requestAnimationFrame
to wrap the updates. This schedules the DOM changes to occur just before the next repaint, batching all changes together.
The navigationGrid
function enables keyboard-based navigation, allowing users to traverse the binary data grid using the arrow keys. It's integrated with the refresh
observable within the virtualScroll
function call, so navigation remains responsive to changes like resizing the editor window.
navigationGrid(
navigationGridOptions({
host: container,
selector: 'span',
startSelector: ':focus',
focusSelector: 'span',
columns: cols,
}),
)
.tap(el => (el as HTMLElement)?.focus())
.ignoreElements(),
The navigationGridOptions
function is called to configure the grid navigation:
span
elements representing individual bytes).The observable returned by this function emits the next element to be focused.
Future development may involve adding editing capabilities, advanced data visualization techniques, and support for additional binary formats. Stay tuned for updates and enhancements as we expand this initial release.