132 lines
4.4 KiB
JavaScript
132 lines
4.4 KiB
JavaScript
// A small assembly editor: line numbers, syntax highlighting (a coloured <pre>
|
|
// underneath a transparent <textarea>), Tab-indents and error-line marking.
|
|
|
|
import { h } from './dom.js';
|
|
|
|
const MNEMONICS = new Set(
|
|
('nop yield halt ldi lui mov add sub mul div mod and or xor shl shr sar addi jmp beq bne blt bge ' +
|
|
'call ret push pop ldb ldh ldw stb sth stw in out li').split(' '),
|
|
);
|
|
|
|
const TOKEN = new RegExp(
|
|
[
|
|
'(\\.[A-Za-z_]\\w*)', // 1 directive
|
|
'([A-Za-z_]\\w*)(?=:)', // 2 label definition
|
|
'\\b(r(?:1[0-5]|\\d)|sp)\\b', // 3 register
|
|
'(-?\\b(?:0x[0-9a-fA-F]+|0b[01]+|\\d+)\\b)', // 4 number
|
|
'([A-Za-z_]\\w*)', // 5 identifier
|
|
'([\\[\\]+,:-])', // 6 punctuation
|
|
].join('|'),
|
|
'gi',
|
|
);
|
|
|
|
const esc = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
const span = (cls, text) => `<span class="${cls}">${esc(text)}</span>`;
|
|
|
|
/** Highlight one line of assembly, returning HTML. Exported for testing. */
|
|
export function highlightLine(line) {
|
|
const c = line.search(/[;#]/);
|
|
const code = c < 0 ? line : line.slice(0, c);
|
|
const comment = c < 0 ? '' : line.slice(c);
|
|
let out = '';
|
|
let pos = 0;
|
|
TOKEN.lastIndex = 0;
|
|
for (let m; (m = TOKEN.exec(code)); ) {
|
|
out += esc(code.slice(pos, m.index));
|
|
pos = m.index + m[0].length;
|
|
if (m[1]) out += span('hl-dir', m[0]);
|
|
else if (m[2]) out += span('hl-lbl', m[0]);
|
|
else if (m[3]) out += span('hl-reg', m[0]);
|
|
else if (m[4]) out += span('hl-num', m[0]);
|
|
else if (m[5]) out += span(MNEMONICS.has(m[5].toLowerCase()) ? 'hl-mn' : 'hl-sym', m[0]);
|
|
else out += span('hl-pun', m[0]);
|
|
}
|
|
out += esc(code.slice(pos));
|
|
if (comment) out += span('hl-cmt', comment);
|
|
return out;
|
|
}
|
|
|
|
export function createEditor({ value = '', onInput = () => {}, onCursor = () => {} } = {}) {
|
|
const gutter = h('div', { class: 'ed-gutter', 'aria-hidden': 'true' });
|
|
const hl = h('div', { class: 'ed-hl', 'aria-hidden': 'true' });
|
|
const input = h('textarea', {
|
|
class: 'ed-input',
|
|
spellcheck: 'false',
|
|
autocapitalize: 'off',
|
|
autocomplete: 'off',
|
|
autocorrect: 'off',
|
|
wrap: 'off',
|
|
'aria-label': 'Assembly source code',
|
|
});
|
|
const el = h('div', { class: 'editor' }, gutter, h('div', { class: 'ed-body' }, hl, input));
|
|
|
|
let errorLine = null;
|
|
let escaped = false; // Escape pressed: let the next Tab leave the editor
|
|
|
|
function render() {
|
|
const lines = input.value.split('\n');
|
|
hl.innerHTML = lines
|
|
.map((l, i) => `<div class="ed-line${i + 1 === errorLine ? ' err' : ''}">${highlightLine(l) || ' '}</div>`)
|
|
.join('');
|
|
gutter.innerHTML = lines
|
|
.map((_, i) => `<div class="ed-num${i + 1 === errorLine ? ' err' : ''}">${i + 1}</div>`)
|
|
.join('');
|
|
syncScroll();
|
|
}
|
|
|
|
function syncScroll() {
|
|
hl.scrollTop = gutter.scrollTop = input.scrollTop;
|
|
hl.scrollLeft = input.scrollLeft;
|
|
}
|
|
|
|
function cursor() {
|
|
const upTo = input.value.slice(0, input.selectionStart);
|
|
const line = upTo.split('\n').length;
|
|
const col = upTo.length - upTo.lastIndexOf('\n');
|
|
onCursor({ line, col });
|
|
}
|
|
|
|
input.addEventListener('input', () => {
|
|
errorLine = null;
|
|
render();
|
|
onInput(input.value);
|
|
});
|
|
input.addEventListener('scroll', syncScroll);
|
|
for (const ev of ['keyup', 'click', 'focus']) input.addEventListener(ev, cursor);
|
|
input.addEventListener('blur', () => { escaped = false; });
|
|
input.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape') { escaped = true; return; }
|
|
if (e.key === 'Tab' && !e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey && !escaped) {
|
|
e.preventDefault();
|
|
input.setRangeText(' ', input.selectionStart, input.selectionEnd, 'end');
|
|
input.dispatchEvent(new Event('input'));
|
|
}
|
|
});
|
|
|
|
input.value = value;
|
|
render();
|
|
|
|
return {
|
|
el,
|
|
get value() { return input.value; },
|
|
set value(v) { input.value = v; errorLine = null; render(); onInput(v); },
|
|
insertAtTop(text) {
|
|
input.value = text + input.value;
|
|
errorLine = null;
|
|
render();
|
|
onInput(input.value);
|
|
},
|
|
setError(line) {
|
|
errorLine = line;
|
|
render();
|
|
if (line) {
|
|
const lh = parseFloat(getComputedStyle(input).lineHeight) || 22;
|
|
input.scrollTop = Math.max(0, (line - 3) * lh);
|
|
syncScroll();
|
|
}
|
|
},
|
|
focus() { input.focus(); },
|
|
render, // re-measure after the element is attached
|
|
};
|
|
}
|