Fix diagram navigation and token highlighting
This commit is contained in:
1 parent
7ed885f0d1
commit
7aa729d41e
6 files changed
+354
-63
No files matched your search
+87
-14
@@ -94,6 +94,18 @@ function cpMap(text) {
|
||||
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) {
|
||||
@@ -238,18 +250,23 @@ function lineSpan(t, a, b) {
|
||||
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;
|
||||
var unit = ed.tab.kind === 'grammar' ? ' ' : '\t';
|
||||
if (a === b && !outdent) {
|
||||
if (unit === '\t') { replaceRange(ed, a, b, '\t'); return; }
|
||||
var col = a - (t.lastIndexOf('\n', a - 1) + 1);
|
||||
replaceRange(ed, a, b, unit.slice(0, 4 - (col % 4)));
|
||||
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 ? unit + l : 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;
|
||||
@@ -259,6 +276,68 @@ function indentSelection(ed, outdent) {
|
||||
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);
|
||||
@@ -277,15 +356,8 @@ function toggleComment(ed) {
|
||||
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();
|
||||
var a = ta.selectionStart, t = ta.value, ls = t.lastIndexOf('\n', a - 1) + 1;
|
||||
var lead = /^[ \t]*/.exec(t.slice(ls, a))[0];
|
||||
var before = t.slice(ls, a).replace(/\s+$/, '');
|
||||
var extra = (ed.tab.kind === 'grammar' && /[:({]$/.test(before)) ? ' ' : '';
|
||||
replaceRange(ed, a, ta.selectionEnd, '\n' + lead + extra);
|
||||
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); }
|
||||
@@ -455,6 +527,7 @@ function makeEditor(tab) {
|
||||
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;
|
||||
|
||||
@@ -33,6 +33,7 @@ function addTab(o) {
|
||||
t.ed = makeEditor(t);
|
||||
t.ed.onInput = function () { onEdit(t); };
|
||||
t.ed.onCaret = updatePos;
|
||||
t.ed.onPick = function () { if (t.kind === 'input') focusDiagramAt(t); };
|
||||
setEditorText(t.ed, o.text);
|
||||
$('editors').appendChild(t.ed.root);
|
||||
S.tabs.push(t);
|
||||
|
||||
+68
-6
@@ -411,16 +411,78 @@ function savePng(data) {
|
||||
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svgMarkup(data));
|
||||
}
|
||||
|
||||
/* Zoom speed. A mouse-wheel notch reports a deltaY of about 100 (a trackpad many
|
||||
* small ones, a pinch gesture arrives as wheel + ctrlKey); each event changes the
|
||||
* zoom by exp(-delta * rate), and no single event may change it by more than maxStep. */
|
||||
var ZOOM = { wheel: 0.0006, pinch: 0.005, maxStep: 0.08, button: 1.2 };
|
||||
|
||||
function stopGlide() { $('stage').style.transition = ''; }
|
||||
|
||||
// Pans (and, if it is very small, zooms) so that the node is in the middle.
|
||||
function focusNode(g) {
|
||||
var c = $('dgm'), stage = $('stage'), cw = c.clientWidth, ch = c.clientHeight;
|
||||
if (!cw || !ch || !g.getBBox) return;
|
||||
var bb = g.getBBox();
|
||||
var z = V.z < 0.9 ? 1 : V.z;
|
||||
Array.prototype.forEach.call(stage.querySelectorAll('.al-node.focus'), function (n) { n.classList.remove('focus'); });
|
||||
g.classList.add('focus');
|
||||
V.z = z;
|
||||
V.x = cw / 2 - (bb.x + bb.width / 2) * z;
|
||||
V.y = ch / 2 - (bb.y + bb.height / 2) * z;
|
||||
V.userMoved = true;
|
||||
var calm = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
stage.style.transition = calm ? '' : 'transform 220ms ease';
|
||||
applyView();
|
||||
if (!calm) setTimeout(stopGlide, 260);
|
||||
}
|
||||
|
||||
// The node that best matches the characters [a, b] (code point indices): the
|
||||
// smallest one that contains them; a token wins over a rule of the same size.
|
||||
function nodeAround(a, b) {
|
||||
var list = $('stage').querySelectorAll('.al-node[data-s]'), best = null, span = Infinity, leaf = false;
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
var g = list[i], s = +g.getAttribute('data-s'), e = +g.getAttribute('data-e');
|
||||
if (s > a || e < b) continue;
|
||||
var isLeaf = g.classList.contains('al-term') || g.classList.contains('al-error');
|
||||
if (e - s < span || (e - s === span && !leaf)) { best = g; span = e - s; leaf = isLeaf; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// A click (or a selection) in the input text: show where it is in the tree.
|
||||
function focusDiagramAt(tab) {
|
||||
var it = currentInput(), r = it ? S.results[it.id] : null;
|
||||
if (!it || it !== tab || !r || r.rev !== it.rev) return; // not the parsed input, or its tree is out of date
|
||||
if ($('p-diagram').hidden || $('dgm').hidden || !V.w) return; // the diagram is not on screen
|
||||
var ta = it.ed.ta, text = ta.value;
|
||||
var a = unitToCp(text, ta.selectionStart), b = unitToCp(text, ta.selectionEnd);
|
||||
var g = null;
|
||||
if (b > a) g = nodeAround(a, b - 1);
|
||||
else g = nodeAround(a, a) || (a > 0 ? nodeAround(a - 1, a - 1) : null); // at the end of a token or of the text: the one before
|
||||
if (g) focusNode(g);
|
||||
}
|
||||
|
||||
function initDiagram() {
|
||||
var box = $('dgm'), stage = $('stage'), drag = null, moved = false;
|
||||
box.addEventListener('wheel', function (ev) {
|
||||
if (!V.w) return;
|
||||
ev.preventDefault();
|
||||
var r = box.getBoundingClientRect(), unit = ev.deltaMode === 1 ? 16 : 1;
|
||||
zoomAt(ev.clientX - r.left, ev.clientY - r.top, Math.exp(-ev.deltaY * unit * (ev.ctrlKey ? 0.01 : 0.0015)));
|
||||
stopGlide();
|
||||
var r = box.getBoundingClientRect(), unit = ev.deltaMode === 1 ? 16 : ev.deltaMode === 2 ? r.height : 1;
|
||||
var step = clamp(-ev.deltaY * unit * (ev.ctrlKey ? ZOOM.pinch : ZOOM.wheel), -ZOOM.maxStep, ZOOM.maxStep);
|
||||
zoomAt(ev.clientX - r.left, ev.clientY - r.top, Math.exp(step));
|
||||
}, { passive: false });
|
||||
// Dragging must pan, never select text; the diagram still has to get keyboard focus.
|
||||
box.addEventListener('mousedown', function (ev) {
|
||||
if (ev.button !== 0) return;
|
||||
ev.preventDefault();
|
||||
box.focus({ preventScroll: true });
|
||||
});
|
||||
box.addEventListener('selectstart', function (ev) { ev.preventDefault(); });
|
||||
box.addEventListener('dragstart', function (ev) { ev.preventDefault(); });
|
||||
box.addEventListener('pointerdown', function (ev) {
|
||||
if (ev.button !== 0 || !V.w) return;
|
||||
stopGlide();
|
||||
drag = { x: ev.clientX, y: ev.clientY, vx: V.x, vy: V.y };
|
||||
moved = false;
|
||||
box.setPointerCapture(ev.pointerId);
|
||||
@@ -451,8 +513,8 @@ function initDiagram() {
|
||||
stage.addEventListener('mouseout', function (ev) { if (ev.target.closest('.al-node')) setRange('hover', -1, -1); });
|
||||
box.addEventListener('keydown', function (ev) {
|
||||
var k = ev.key;
|
||||
if (k === '+' || k === '=') zoomCentre(1.25);
|
||||
else if (k === '-') zoomCentre(0.8);
|
||||
if (k === '+' || k === '=') zoomCentre(ZOOM.button);
|
||||
else if (k === '-') zoomCentre(1 / ZOOM.button);
|
||||
else if (k === '0') fit();
|
||||
else if (k === 'ArrowLeft') { V.x += 60; V.userMoved = true; applyView(); }
|
||||
else if (k === 'ArrowRight') { V.x -= 60; V.userMoved = true; applyView(); }
|
||||
@@ -461,8 +523,8 @@ function initDiagram() {
|
||||
else return;
|
||||
ev.preventDefault();
|
||||
});
|
||||
$('zin').onclick = function () { zoomCentre(1.25); };
|
||||
$('zout').onclick = function () { zoomCentre(0.8); };
|
||||
$('zin').onclick = function () { zoomCentre(ZOOM.button); };
|
||||
$('zout').onclick = function () { zoomCentre(1 / ZOOM.button); };
|
||||
$('zfit').onclick = fit;
|
||||
$('psvg').onclick = function () { var r = currentResult(); if (r && V.w) download(exportName('svg'), new Blob([svgMarkup(r.data)], { type: 'image/svg+xml' })); };
|
||||
$('ppng').onclick = function () { var r = currentResult(); if (r && V.w) savePng(r.data); };
|
||||
|
||||
+3
-1
@@ -134,13 +134,15 @@ main.no-right #split-v,main.no-right #right{display:none}
|
||||
.wbtns{display:flex;gap:8px}
|
||||
|
||||
/* diagram */
|
||||
#dgm{cursor:grab;touch-action:none;background-color:var(--bg);background-image:radial-gradient(var(--dot) 1px,transparent 1px);background-size:22px 22px}
|
||||
#dgm{cursor:grab;touch-action:none;user-select:none;-webkit-user-select:none;background-color:var(--bg);background-image:radial-gradient(var(--dot) 1px,transparent 1px);background-size:22px 22px}
|
||||
#dgm.drag{cursor:grabbing}
|
||||
#dgm.empty{cursor:default}
|
||||
#dgm.stale #stage{opacity:.45}
|
||||
#stage{position:absolute;left:0;top:0;transform-origin:0 0}
|
||||
#stage svg{display:block;overflow:visible}
|
||||
#stage .al-node{cursor:pointer}
|
||||
#stage .al-node.focus .al-box{stroke:var(--accent);stroke-width:3.5}
|
||||
#stage text{user-select:none;-webkit-user-select:none}
|
||||
#dnote{position:absolute;left:0;right:0;top:0}
|
||||
|
||||
/* tokens, problems */
|
||||
|
||||
Reference in new issue
Block a user