Dev Tool: Binary Editor Dev Tool: Binary Editordebugger virtual-scrolling binary-editor devtool

In this post we will explore the design choices and strategies used to create the hexadecimal and ASCII views of a binary editor.

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:

Implementation

Hexadecimal Value Conversion

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

Character Display

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)
		: '.';
}

Layout and Rendering

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:

computeColumnCount

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

renderRow

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.

Keyboard Navigation

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:

The observable returned by this function emits the next element to be focused.

Conclusion

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.

Back to Main Page