diff --git a/README.md b/README.md index d8bad89..e07f44b 100644 --- a/README.md +++ b/README.md @@ -187,11 +187,22 @@ opens it for you. It is a local web app that edits **real files on disk**: listed under *Problems*; ANTLR tool errors are marked in the grammar at the reported line. Click a problem to jump to it. Hovering or clicking a node in the diagram, or a row in *Tokens*, highlights its text in the input. -- **Diagram** with zoom (wheel), pan (drag), *Fit*, and SVG / PNG export. *Tree* - shows the LISP form (indented or on one line, with Copy), *Tokens* the token - table. -- **Editor niceties:** grammar syntax colors, line numbers, auto-indent, - `Tab` / `Shift+Tab` indent, `Ctrl+/` comment, `Ctrl+F` find and replace. +- **Diagram** with zoom (wheel, `+` / `-`), pan (drag; it never selects text), + *Fit* (double-click or `0`), and SVG / PNG export. **Click or select text in + the input** and the diagram glides to the matching node (the smallest one that + covers the text, a token rather than a rule when both match) and outlines it. + *Tree* shows the LISP form (indented or on one line, with Copy), *Tokens* the + token table. +- **Editor niceties:** grammar syntax colors, line numbers, `Ctrl+/` comment, + `Ctrl+F` find and replace, and indentation with **hard tabs** (shown 4 columns + wide; `Tab` / `Shift+Tab` also indent and outdent a selection). A line that is + already indented with spaces keeps getting spaces, so a line never mixes both. + `Enter` auto-indents: it keeps the current indentation, adds a level after + `(` `[` `{`, and between a pair like `{|}` opens a block with the closer on its + own line. In grammars it also indents after a rule name or a `:`, and starts + the next line at column 0 after the `;` that ends a rule. A whitespace-only + line is emptied when you press `Enter` on it, and typing `}` `)` or `]` first + on an indented line steps it back one level. - Panels close with their `x` and come back from the View menu; the splitters drag. Layout, theme (system / light / dark) and text size are remembered, and so are the open files per workspace. @@ -223,6 +234,10 @@ Opening a file larger than 5 MB or an input larger than 4 MB is refused. ### Changing the UI +The wheel zoom speed is the `ZOOM` object near the top of the diagram code in +`web/40-views.js` (`wheel` is the rate per scrolled pixel, `maxStep` caps the +change of one event). + The browser code lives in `web/` (plain HTML, CSS and JavaScript, no build tools or libraries). After editing it, run `python3 tools/embed_web.py` to regenerate `src/webui_assets.hpp`, which is checked in, so building antlrlab diff --git a/src/webui_assets.hpp b/src/webui_assets.hpp index ecdf950..3121d82 100644 --- a/src/webui_assets.hpp +++ b/src/webui_assets.hpp @@ -234,13 +234,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 */ @@ -265,9 +267,9 @@ td.num{text-align:right;color:var(--muted)} .mbody{padding:12px 14px;overflow:auto;min-height:0} .mbody p{margin:0 0 6px} .mfoot{display:flex;justify-content:flex-end;gap:8px;padding:10px 14px;border-top:1px solid var(--line)} -.crumbs{display:flex;flex-wrap:wrap;gap:2px;margin-bottom:8px;font-size:12.5px} )__WEB__"; - s += R"__WEB__(.crumbs button{background:none;border:0;padding:1px 5px;border-radius:3px;cursor:pointer;color:var(--accent)} + s += R"__WEB__(.crumbs{display:flex;flex-wrap:wrap;gap:2px;margin-bottom:8px;font-size:12.5px} +.crumbs button{background:none;border:0;padding:1px 5px;border-radius:3px;cursor:pointer;color:var(--accent)} .crumbs button:hover{background:var(--hov)} .flist{list-style:none;margin:8px 0 0;padding:0;height:260px;overflow:auto;border:1px solid var(--line);background:var(--edit)} .flist li{display:flex;gap:10px;padding:4px 10px;cursor:pointer} @@ -617,6 +619,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) { @@ -761,18 +775,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; @@ -782,6 +801,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); @@ -800,15 +881,9 @@ 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; } +)__WEB__"; + s += R"__WEB__( 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); } @@ -893,8 +968,7 @@ function runFind(ed, jump) { for (var i = 0; i < ms.length; i++) { if (ms[i][0] >= from) { cur = i; break; } } if (cur < 0) cur = 0; } -)__WEB__"; - s += R"__WEB__( ed.find = ms.length || ed.fq.value ? { matches: ms, cur: cur } : null; + 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]); @@ -979,6 +1053,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; @@ -1033,6 +1108,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); @@ -1122,7 +1198,8 @@ function toggleKind(t) { S.grev++; ensureLast(); paintEditor(t.ed); - if (t.id === S.active) activate(t.id, false); else renderTabs(); +)__WEB__"; + s += R"__WEB__( if (t.id === S.active) activate(t.id, false); else renderTabs(); refreshPanels(); scheduleRun(200); } @@ -1227,8 +1304,7 @@ function saveTab(t) { if (!t.path) return saveAs(t); return doSave(t, t.path, { baseEtag: t.etag }).catch(function (e) { if (e.status === 409 && e.data && e.data.changed) { -)__WEB__"; - s += R"__WEB__( return ask('File changed on disk', basename(t.path) + ' was modified outside this editor after you opened it. Saving now overwrites those changes.', + return ask('File changed on disk', basename(t.path) + ' was modified outside this editor after you opened it. Saving now overwrites those changes.', [{ label: 'Overwrite', value: 'o', primary: true }, { label: 'Cancel', value: null }]).then(function (v) { if (v !== 'o') return false; return doSave(t, t.path, { force: true }).catch(saveError); @@ -1437,7 +1513,8 @@ function fileDialog(o) { nameIn = el('input'); nameIn.type = 'text'; nameIn.value = o.name || ''; - nameIn.setAttribute('aria-label', 'File name'); +)__WEB__"; + s += R"__WEB__( nameIn.setAttribute('aria-label', 'File name'); nameIn.setAttribute('data-focus', '1'); wrap.appendChild(nameIn); setTimeout(function () { nameIn.focus(); nameIn.select(); }, 0); @@ -1530,8 +1607,7 @@ function initSplit(id, vertical) { /* ================================================================== results */ function currentInput() { return getTab(S.lastInput); } function currentResult() { -)__WEB__"; - s += R"__WEB__( var it = currentInput(); + var it = currentInput(); return it ? (S.results[it.id] || null) : null; } function isStale() { @@ -1739,7 +1815,8 @@ function paintProblems() { li.setAttribute('aria-current', 'true'); p.go(); }; - li.onkeydown = function (ev) { if (ev.key === 'Enter') { ev.preventDefault(); li.click(); } }; +)__WEB__"; + s += R"__WEB__( li.onkeydown = function (ev) { if (ev.key === 'Enter') { ev.preventDefault(); li.click(); } }; } list.appendChild(li); }); @@ -1836,8 +1913,7 @@ function paintDiagram(data) { box.classList.remove('empty'); stage.innerHTML = svg; var vb = stage.querySelector('svg').getAttribute('viewBox').split(' '); -)__WEB__"; - s += R"__WEB__( V.w = parseFloat(vb[2]); + V.w = parseFloat(vb[2]); V.h = parseFloat(vb[3]); if (V.needFit || !V.userMoved) fit(); else applyView(); } @@ -1884,16 +1960,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); @@ -1924,8 +2062,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(); } @@ -1934,8 +2072,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); }; @@ -1959,7 +2097,8 @@ function initDiagram() { ta.remove(); } }; - $('o-problems').onclick = function () { S.prefs.out = 'problems'; savePrefs(); refreshPanels(); }; +)__WEB__"; + s += R"__WEB__( $('o-problems').onclick = function () { S.prefs.out = 'problems'; savePrefs(); refreshPanels(); }; $('o-log').onclick = function () { S.prefs.out = 'log'; savePrefs(); refreshPanels(); }; } @@ -2098,8 +2237,7 @@ function fatal(e) { m.appendChild(box); } -)__WEB__"; - s += R"__WEB__(function boot() { +function boot() { var saved = store.get('prefs', null); if (saved) Object.keys(saved).forEach(function (k) { S.prefs[k] = saved[k]; }); buildMenus(); diff --git a/web/20-editor.js b/web/20-editor.js index e5e1651..994c265 100644 --- a/web/20-editor.js +++ b/web/20-editor.js @@ -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; diff --git a/web/30-tabs.js b/web/30-tabs.js index cbc4d0c..604be0e 100644 --- a/web/30-tabs.js +++ b/web/30-tabs.js @@ -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); diff --git a/web/40-views.js b/web/40-views.js index 2368e0f..ff9224a 100644 --- a/web/40-views.js +++ b/web/40-views.js @@ -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); }; diff --git a/web/app.css b/web/app.css index 09b96e8..ab0a486 100644 --- a/web/app.css +++ b/web/app.css @@ -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 */