Files

550 lines
21 KiB
JavaScript

/* ================================================================== editor
* A plain <textarea> sits on top of a <pre> that paints the same text with
* syntax colors and marks. Both have identical font metrics and size, so the
* textarea provides the caret, selection, IME and native undo while the <pre>
* provides the colors. One editor (and so one undo history) per tab. */
var CLS = ['', 't-com', 't-str', 't-set', 't-act', 't-ann', 't-kw', 't-tok', 't-def', 't-lab', 't-op', 't-tokdef'];
var MKCLS = [[1, 'mk-err'], [2, 'mk-warn'], [4, 'mk-hl'], [8, 'mk-find'], [16, 'mk-cur']];
var KW = { grammar: 1, lexer: 1, parser: 1, fragment: 1, options: 1, tokens: 1, channels: 1, 'import': 1, mode: 1, returns: 1,
locals: 1, throws: 1, 'catch': 1, 'finally': 1, skip: 1, more: 1, pushMode: 1, popMode: 1, type: 1, channel: 1 };
function isIdStart(c) { return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c === '_'; }
function isIdChar(c) { return isIdStart(c) || (c >= '0' && c <= '9'); }
// One class id per UTF-16 unit, for ANTLR grammar syntax.
function g4Classes(t) {
var n = t.length, cls = new Uint8Array(n), i = 0, prevKw = '';
function paint(a, b, c) { for (var k = a; k < b; k++) cls[k] = c; }
function quoted(from, close) {
var j = from + 1;
while (j < n && t.charAt(j) !== '\n') {
if (t.charAt(j) === '\\') { j += 2; continue; }
if (t.charAt(j) === close) { j++; break; }
j++;
}
return Math.min(j, n);
}
while (i < n) {
var c = t.charAt(i), j;
if (c === '/' && t.charAt(i + 1) === '/') {
j = t.indexOf('\n', i); if (j < 0) j = n;
paint(i, j, 1); i = j;
} else if (c === '/' && t.charAt(i + 1) === '*') {
j = t.indexOf('*/', i + 2); j = j < 0 ? n : j + 2;
paint(i, j, 1); i = j;
} else if (c === "'") {
j = quoted(i, "'"); paint(i, j, 2); i = j;
} else if (c === '[') {
j = quoted(i, ']'); paint(i, j, 3); i = j;
} else if (c === '{') {
var depth = 0; j = i;
while (j < n) {
var d = t.charAt(j);
if (d === "'" || d === '"') { j = quoted(j, d); continue; }
if (d === '{') depth++;
else if (d === '}' && --depth === 0) { j++; break; }
j++;
}
paint(i, j, 4); i = j;
} else if (c === '@' || c === '#') {
j = i + 1;
while (j < n && (t.charAt(j) === ' ' || t.charAt(j) === '\t') && c === '#') j++;
while (j < n && (isIdChar(t.charAt(j)) || t.charAt(j) === ':')) j++;
paint(i, j, 5); i = j;
} else if (isIdStart(c)) {
j = i + 1;
while (j < n && isIdChar(t.charAt(j))) j++;
var word = t.slice(i, j);
var k = j;
while (k < n && (t.charAt(k) === ' ' || t.charAt(k) === '\t' || t.charAt(k) === '\n' || t.charAt(k) === '\r')) k++;
var next = t.charAt(k), after = t.charAt(k + 1);
var ls = t.lastIndexOf('\n', i - 1) + 1;
var first = /^[ \t]*$/.test(t.slice(ls, i));
var def = next === ':' && after !== ':' && (first || prevKw === 'fragment');
var c0 = word.charAt(0);
var up = c0 >= 'A' && c0 <= 'Z';
var id;
if (KW[word] === 1 && !def && next !== ':') id = 6;
else if (def) id = up ? 11 : 8;
else if ((next === '=' && after !== '=') || (next === '+' && after === '=')) id = 9;
else id = up ? 7 : 0;
paint(i, j, id);
prevKw = word;
i = j;
} else {
if ('|:;()*+?~.,<>=-'.indexOf(c) >= 0) cls[i] = 10;
i++;
}
}
return cls;
}
// code point index -> UTF-16 index
function cpMap(text) {
if (!/[\ud800-\udbff]/.test(text)) return function (i) { return i; };
var map = [];
for (var i = 0; i < text.length;) {
map.push(i);
var c = text.charCodeAt(i);
i += (c >= 0xd800 && c <= 0xdbff && i + 1 < text.length) ? 2 : 1;
}
map.push(text.length);
return function (k) { return k < map.length ? map[k] : text.length; };
}
// UTF-16 index -> code point index (the inverse of cpMap)
function unitToCp(text, u) {
if (!/[\ud800-\udbff]/.test(text)) return u;
var n = 0;
for (var i = 0; i < u && i < text.length; i++) {
var c = text.charCodeAt(i);
if (c >= 0xd800 && c <= 0xdbff && i + 1 < text.length) i++;
n++;
}
return n;
}
/* ----------------------------------------------------------- line helpers */
function lineStarts(ed) {
if (!ed.ls) {
var t = ed.ta.value, a = [0];
for (var i = t.indexOf('\n'); i >= 0; i = t.indexOf('\n', i + 1)) a.push(i + 1);
ed.ls = a;
}
return ed.ls;
}
function lineOf(ed, idx) {
var a = lineStarts(ed), lo = 0, hi = a.length - 1;
while (lo < hi) {
var mid = (lo + hi + 1) >> 1;
if (a[mid] <= idx) lo = mid; else hi = mid - 1;
}
return lo;
}
// 1-based line + 0-based column -> unit index (column clamped to the line)
function unitAt(ed, line, col) {
var a = lineStarts(ed);
if (line < 1) return 0;
if (line > a.length) return ed.ta.value.length;
var s = a[line - 1], e = line < a.length ? a[line] - 1 : ed.ta.value.length;
return Math.min(s + col, e);
}
var metricCache = {};
function metrics(ed) {
var key = S.prefs.fs;
if (metricCache[key]) return metricCache[key];
var probe = el('span', '', '0000000000000000000000000000000000000000');
probe.style.position = 'absolute';
probe.style.visibility = 'hidden';
probe.style.whiteSpace = 'pre';
probe.className = 'hl';
probe.style.padding = '0';
probe.style.minHeight = '0';
ed.hl.parentNode.appendChild(probe);
var w = probe.getBoundingClientRect().width / 40;
probe.remove();
var lh = parseFloat(getComputedStyle(ed.ta).lineHeight) || 20;
metricCache[key] = { cw: w || 8, lh: lh };
return metricCache[key];
}
// Scrolls the editor so that the given unit index is visible.
function revealUnit(ed, idx) {
var m = metrics(ed), sc = ed.scroller;
var line = lineOf(ed, idx);
var ls = lineStarts(ed)[line];
var col = 0, pre = ed.ta.value.slice(ls, idx);
for (var i = 0; i < pre.length; i++) col = pre.charAt(i) === '\t' ? (Math.floor(col / 4) + 1) * 4 : col + 1;
var y = 8 + line * m.lh;
var gw = ed.gutter.offsetWidth;
var x = gw + 10 + col * m.cw;
if (y < sc.scrollTop + m.lh) sc.scrollTop = Math.max(0, y - m.lh * 2);
else if (y + m.lh * 2 > sc.scrollTop + sc.clientHeight) sc.scrollTop = y + m.lh * 3 - sc.clientHeight;
if (x < sc.scrollLeft + gw + 30) sc.scrollLeft = Math.max(0, x - gw - 60);
else if (x + 40 > sc.scrollLeft + sc.clientWidth) sc.scrollLeft = x + 80 - sc.clientWidth;
}
function caretInfo(ed) {
var p = ed.ta.selectionEnd, line = lineOf(ed, p);
return { line: line + 1, col: p - lineStarts(ed)[line] + 1 };
}
/* ---------------------------------------------------------------- painting */
function paintEditor(ed) {
var t = ed.ta.value, n = t.length;
var big = n > 800000;
var cls = (ed.tab.kind === 'grammar' && !big) ? g4Classes(t) : null;
var mk = new Uint8Array(n + 1);
function mark(list, bit) {
for (var i = 0; i < list.length; i++) {
var r = list[i];
for (var k = Math.max(0, r.s); k < Math.min(n, r.e); k++) mk[k] |= (r.bit || bit);
}
}
if (!big) {
mark(ed.diag, 1);
if (ed.find) {
for (var f = 0; f < ed.find.matches.length && f < 5000; f++) {
var fm = ed.find.matches[f];
for (var q = fm[0]; q < fm[1]; q++) mk[q] |= (f === ed.find.cur ? 16 : 8);
}
}
var rng = ed.hover || ed.pin;
if (rng) for (var h = Math.max(0, rng[0]); h < Math.min(n, rng[1]); h++) mk[h] |= 4;
}
var frag = document.createDocumentFragment();
var i = 0;
while (i < n) {
var key = (cls ? cls[i] : 0) | (mk[i] << 4), j = i + 1;
while (j < n && ((cls ? cls[j] : 0) | (mk[j] << 4)) === key) j++;
var txt = t.slice(i, j);
if (key === 0) {
frag.appendChild(document.createTextNode(txt));
} else {
var names = [];
if (key & 15) names.push(CLS[key & 15]);
for (var m = 0; m < MKCLS.length; m++) if ((key >> 4) & MKCLS[m][0]) names.push(MKCLS[m][1]);
frag.appendChild(el('span', names.join(' '), txt));
}
i = j;
}
frag.appendChild(document.createTextNode('\n')); // matches the textarea's trailing line
clear(ed.hl);
ed.hl.appendChild(frag);
var lines = lineStarts(ed).length;
if (lines !== ed.lines) {
var nums = [];
for (var L = 1; L <= lines; L++) nums.push(L);
ed.gutter.textContent = nums.join('\n') + '\n';
ed.lines = lines;
}
}
/* ----------------------------------------------------------------- editing */
function replaceRange(ed, a, b, text) {
var ta = ed.ta;
ta.focus();
ta.setSelectionRange(a, b);
var ok = false;
// execCommand keeps the undo history but is very slow for big multi-line
// strings in Chrome, so large edits take the direct route.
if (text.length + (b - a) <= 20000) {
try { ok = document.execCommand('insertText', false, text); } catch (e) { ok = false; }
}
if (!ok) {
ta.setRangeText(text, a, b, 'end');
ta.dispatchEvent(new Event('input', { bubbles: true }));
}
}
function lineSpan(t, a, b) {
if (b > a && t.charAt(b - 1) === '\n') b--;
var s = t.lastIndexOf('\n', a - 1) + 1;
var e = t.indexOf('\n', b);
if (e < 0) e = t.length;
return [s, e];
}
/* ------------------------------------------------------------------ indent
* Indentation is made of hard tabs (the editor shows a tab as 4 columns). A line
* that is already indented with spaces keeps getting spaces, so one line never
* mixes the two. */
function indentFor(lead) { return lead.charAt(0) === ' ' ? ' ' : '\t'; }
function indentSelection(ed, outdent) {
var ta = ed.ta, t = ta.value, a = ta.selectionStart, b = ta.selectionEnd;
if (a === b && !outdent) {
var head = t.slice(t.lastIndexOf('\n', a - 1) + 1, a);
if (/^ +$/.test(head)) replaceRange(ed, a, b, ' '.slice(0, 4 - (head.length % 4))); // inside space indentation
else replaceRange(ed, a, b, '\t');
return;
}
var sp = lineSpan(t, a, b);
var lines = t.slice(sp[0], sp[1]).split('\n').map(function (l) {
if (!outdent) return l.length ? indentFor(l) + l : l;
if (l.charAt(0) === '\t') return l.slice(1);
var m = /^ {1,4}/.exec(l);
return m ? l.slice(m[0].length) : l;
});
var out = lines.join('\n');
replaceRange(ed, sp[0], sp[1], out);
ed.ta.setSelectionRange(sp[0], sp[0] + out.length);
}
// Enter: new line with the right indentation.
// - keeps the indentation of the current line, and one level more after an
// opening ( [ { (and, in grammars, after a rule name or a ':')
// - Enter between a pair like {|} opens a block: the closer moves to its own line
// - after the ';' that ends a rule (grammars) the next line starts at column 0
// - a line that holds only whitespace is emptied, so no trailing whitespace is left
function newline(ed) {
var ta = ed.ta, t = ta.value, a = ta.selectionStart, b = ta.selectionEnd;
var grammar = ed.tab.kind === 'grammar';
var ls = t.lastIndexOf('\n', a - 1) + 1;
var head = t.slice(ls, a);
var le = t.indexOf('\n', b);
if (le < 0) le = t.length;
var tail = t.slice(b, le);
var lead = /^[ \t]*/.exec(head)[0];
var code = head.replace(/\s+$/, '');
var unit = indentFor(lead);
var from = a, next = lead, caretAt = -1, to = b;
if (head.length === lead.length) {
from = ls; // whitespace-only so far: drop it from the line above
} else {
var last = ls + code.length - 1; // index of the last character of code
var cls = (grammar && t.length <= 400000) ? g4Classes(t) : null;
var kind = cls ? cls[last] : 0; // 1 comment, 2 string, 3 [set], 4 action, 10 operator, ...
var quiet = kind === 1 || kind === 2; // inside a comment or a string: only keep the indentation
if (!quiet && /[\[({]$/.test(code)) {
next = lead + unit;
var closer = /^[ \t]*[\])}]/.exec(tail);
if (closer) { // {|} -> { / indented / }
to = b + /^[ \t]*/.exec(tail)[0].length;
caretAt = from + 1 + next.length;
replaceRange(ed, from, to, '\n' + next + '\n' + lead);
ta.setSelectionRange(caretAt, caretAt);
return;
}
} else if (!quiet && grammar && kind === 10 && /;$/.test(code)) {
next = ''; // the rule is finished
} else if (!quiet && grammar && kind === 10 && /:$/.test(code)) {
next = lead + unit; // alternatives start after a ':'
} else if (!quiet && grammar && lead === '' && kind !== 4 &&
/^(?:fragment[ \t]+)?[A-Za-z_]\w*(?![\w.])/.test(code) &&
!/^(?:grammar|lexer|parser|import|options|tokens|channels|mode)\b/.test(code) &&
!/[;:{(]$/.test(code)) {
next = unit; // a rule name (or the start of a rule): its body is indented
}
}
replaceRange(ed, from, to, '\n' + next);
}
// Typing a closing bracket as the first thing on an indented line steps it back one level.
function electricCloser(ed, ev) {
var ta = ed.ta, a = ta.selectionStart;
if (a !== ta.selectionEnd) return false;
var t = ta.value, ls = t.lastIndexOf('\n', a - 1) + 1, head = t.slice(ls, a);
if (!head.length || !/^[ \t]+$/.test(head)) return false;
var keep = head.charAt(head.length - 1) === '\t' ? head.length - 1 : head.length - (((head.length - 1) % 4) + 1);
ev.preventDefault();
replaceRange(ed, ls, a, head.slice(0, keep) + ev.key);
return true;
}
function toggleComment(ed) {
if (ed.tab.kind !== 'grammar') return;
var ta = ed.ta, t = ta.value, sp = lineSpan(t, ta.selectionStart, ta.selectionEnd);
var lines = t.slice(sp[0], sp[1]).split('\n');
var live = lines.filter(function (l) { return l.trim().length; });
var all = live.length && live.every(function (l) { return /^\s*\/\//.test(l); });
var indent = Math.min.apply(null, live.map(function (l) { return /^\s*/.exec(l)[0].length; }).concat([1e9]));
var out = lines.map(function (l) {
if (!l.trim().length) return l;
return all ? l.replace(/^(\s*)\/\/ ?/, '$1') : l.slice(0, indent) + '// ' + l.slice(indent);
}).join('\n');
replaceRange(ed, sp[0], sp[1], out);
ta.setSelectionRange(sp[0], sp[0] + out.length);
}
function editorKeys(ed, ev) {
var ta = ed.ta, k = ev.key, mod = ev.ctrlKey || ev.metaKey;
if (k === 'Tab' && !mod && !ev.altKey) { ev.preventDefault(); indentSelection(ed, ev.shiftKey); return; }
if (k === 'Enter' && !mod && !ev.altKey && !ev.shiftKey) { ev.preventDefault(); newline(ed); return; }
if ((k === '}' || k === ')' || k === ']') && !mod && !ev.altKey && electricCloser(ed, ev)) return;
if (mod && k === '/') { ev.preventDefault(); toggleComment(ed); return; }
if (mod && (k === 'f' || k === 'F' || k === 'h' || k === 'H')) { ev.preventDefault(); openFind(ed); return; }
if (k === 'Escape' && ed.findBar && !ed.findBar.hidden) { ev.preventDefault(); closeFind(ed); }
}
/* -------------------------------------------------------------------- find */
function buildFind(ed) {
var bar = el('div', 'find');
bar.setAttribute('role', 'search');
var r1 = el('div', 'row'), r2 = el('div', 'row');
var q = el('input'); q.type = 'text'; q.placeholder = 'Find'; q.setAttribute('aria-label', 'Find');
var cnt = el('span', 'cnt');
var bPrev = el('button', 'btn sm', '\u2191'); bPrev.title = 'Previous (Shift+Enter)';
var bNext = el('button', 'btn sm', '\u2193'); bNext.title = 'Next (Enter)';
var bCase = el('button', 'btn sm', 'Aa'); bCase.title = 'Match case'; bCase.setAttribute('aria-pressed', 'false');
var bX = el('button', 'btn sm', '\u00d7'); bX.title = 'Close (Esc)';
var rep = el('input'); rep.type = 'text'; rep.placeholder = 'Replace'; rep.setAttribute('aria-label', 'Replace with');
var bRep = el('button', 'btn sm', 'Replace');
var bAll = el('button', 'btn sm', 'All');
[bPrev, bNext, bCase, bX, bRep, bAll].forEach(function (b) { b.type = 'button'; });
[q, bPrev, bNext, bCase, cnt, bX].forEach(function (n) { r1.appendChild(n); });
[rep, bRep, bAll].forEach(function (n) { r2.appendChild(n); });
bar.appendChild(r1); bar.appendChild(r2);
ed.root.appendChild(bar);
ed.findBar = bar;
ed.fq = q; ed.fr = rep; ed.fcnt = cnt; ed.fcase = false;
q.oninput = function () { runFind(ed, true); };
q.onkeydown = function (ev) {
if (ev.key === 'Enter') { ev.preventDefault(); stepFind(ed, ev.shiftKey ? -1 : 1); }
else if (ev.key === 'Escape') { ev.preventDefault(); closeFind(ed); }
};
rep.onkeydown = function (ev) {
if (ev.key === 'Enter') { ev.preventDefault(); replaceOne(ed); }
else if (ev.key === 'Escape') { ev.preventDefault(); closeFind(ed); }
};
bPrev.onclick = function () { stepFind(ed, -1); };
bNext.onclick = function () { stepFind(ed, 1); };
bCase.onclick = function () {
ed.fcase = !ed.fcase;
bCase.setAttribute('aria-pressed', ed.fcase ? 'true' : 'false');
runFind(ed, true);
};
bX.onclick = function () { closeFind(ed); };
bRep.onclick = function () { replaceOne(ed); };
bAll.onclick = function () { replaceAll(ed); };
}
function openFind(ed) {
if (!ed.findBar) buildFind(ed);
ed.findBar.hidden = false;
var sel = ed.ta.value.slice(ed.ta.selectionStart, ed.ta.selectionEnd);
if (sel && sel.indexOf('\n') < 0) ed.fq.value = sel;
ed.fq.focus();
ed.fq.select();
runFind(ed, false);
}
function closeFind(ed) {
if (!ed.findBar) return;
ed.findBar.hidden = true;
ed.find = null;
paintEditor(ed);
ed.ta.focus();
}
function findMatches(ed) {
var q = ed.fq.value, out = [];
if (!q) return out;
var hay = ed.fcase ? ed.ta.value : ed.ta.value.toLowerCase();
var needle = ed.fcase ? q : q.toLowerCase();
for (var i = hay.indexOf(needle); i >= 0 && out.length < 5000; i = hay.indexOf(needle, i + Math.max(1, needle.length))) {
out.push([i, i + needle.length]);
}
return out;
}
function runFind(ed, jump) {
var ms = findMatches(ed), cur = -1;
if (ms.length) {
var from = ed.ta.selectionStart;
for (var i = 0; i < ms.length; i++) { if (ms[i][0] >= from) { cur = i; break; } }
if (cur < 0) cur = 0;
}
ed.find = ms.length || ed.fq.value ? { matches: ms, cur: cur } : null;
ed.fcnt.textContent = ed.fq.value ? (ms.length ? (cur + 1) + ' of ' + ms.length : 'No results') : '';
paintEditor(ed);
if (jump && cur >= 0) selectMatch(ed, ms[cur]);
}
function selectMatch(ed, m) {
ed.ta.setSelectionRange(m[0], m[1]);
revealUnit(ed, m[0]);
}
function stepFind(ed, dir) {
if (!ed.find || !ed.find.matches.length) { runFind(ed, true); return; }
var n = ed.find.matches.length;
ed.find.cur = (ed.find.cur + dir + n) % n;
ed.fcnt.textContent = (ed.find.cur + 1) + ' of ' + n;
paintEditor(ed);
selectMatch(ed, ed.find.matches[ed.find.cur]);
}
function replaceOne(ed) {
if (!ed.find || ed.find.cur < 0) { runFind(ed, true); return; }
var m = ed.find.matches[ed.find.cur];
replaceRange(ed, m[0], m[1], ed.fr.value);
ed.fq.focus();
runFind(ed, true);
}
function replaceAll(ed) {
var ms = findMatches(ed);
if (!ms.length) return;
var t = ed.ta.value, out = '', last = 0;
ms.forEach(function (m) { out += t.slice(last, m[0]) + ed.fr.value; last = m[1]; });
out += t.slice(last);
replaceRange(ed, 0, t.length, out);
ed.fq.focus();
runFind(ed, false);
toast('Replaced ' + plural(ms.length, 'match'));
}
/* ------------------------------------------------------------------ editor */
function makeEditor(tab) {
var root = el('div', 'ed');
root.hidden = true;
var banner = el('div', 'banner');
banner.hidden = true;
var scroller = el('div', 'scroller');
var rows = el('div', 'rows');
var gutter = el('pre', 'gutter');
gutter.setAttribute('aria-hidden', 'true');
var content = el('div', 'content');
var hl = el('pre', 'hl');
hl.setAttribute('aria-hidden', 'true');
var ta = el('textarea', 'ta');
ta.spellcheck = false;
ta.setAttribute('wrap', 'off');
ta.setAttribute('autocomplete', 'off');
ta.setAttribute('autocorrect', 'off');
ta.setAttribute('autocapitalize', 'off');
ta.setAttribute('aria-label', 'Editor');
content.appendChild(hl);
content.appendChild(ta);
rows.appendChild(gutter);
rows.appendChild(content);
scroller.appendChild(rows);
root.appendChild(banner);
root.appendChild(scroller);
var ed = { tab: tab, root: root, banner: banner, scroller: scroller, gutter: gutter, hl: hl, ta: ta,
diag: [], hover: null, pin: null, find: null, ls: null, lines: 0, findBar: null, onInput: null, onCaret: null };
ta.addEventListener('input', function () {
ed.ls = null;
ed.diag = [];
ed.pin = null;
if (ed.find) runFind(ed, false); else paintEditor(ed);
revealUnit(ed, ta.selectionEnd);
if (ed.onInput) ed.onInput(ed);
if (ed.onCaret) ed.onCaret(ed);
});
ta.addEventListener('keydown', function (ev) { editorKeys(ed, ev); });
['keyup', 'mouseup', 'focus'].forEach(function (name) {
ta.addEventListener(name, function (ev) {
if (name === 'keyup' && /^(Arrow|Page|Home|End)/.test(ev.key)) revealUnit(ed, ta.selectionEnd);
if (ed.onCaret) ed.onCaret(ed);
if (name === 'mouseup' && ev.button === 0 && ed.onPick) ed.onPick(ed); // a click: show it in the diagram
});
});
return ed;
}
function setEditorText(ed, text) {
ed.ta.value = text;
ed.ls = null;
ed.diag = [];
ed.pin = null;
ed.find = null;
if (ed.findBar) { ed.findBar.hidden = true; }
paintEditor(ed);
}
function setEditorRange(ed, kind, range) { // kind: 'hover' | 'pin'; range [s,e) in units or null
ed[kind] = range;
paintEditor(ed);
}