tricu

A lazy functional language based on Tree Calculus
Log | Files | Refs | README | LICENSE

source-editor.js (9326B)


      1 import { importFolds, importSites } from './import-folds.js';
      2 import { EditorState, StateField, StateEffect, Transaction, EditorSelection, Compartment, EditorView, Decoration, keymap, drawSelection, history, historyKeymap, standardKeymap, isolateHistory, undo, redo, insertNewline } from './codemirror.js';
      3 import { tokenize, bracketPair, definitionSites } from './syntax.js';
      4 // Enter inserts a plain newline, preserving explicit source indentation.
      5 const plainNewline = (view) => !view.state.readOnly && insertNewline(view);
      6 const definitions = StateEffect.define();
      7 const definitionState = StateField.define({
      8     create: () => ({ ready: false, busy: false }),
      9     update: (old, tr) => { for (const effect of tr.effects)
     10         if (effect.is(definitions))
     11             return effect.value; return old; }
     12 });
     13 const presentations = new WeakMap();
     14 function presentation(state) {
     15     let cached = presentations.get(state.doc);
     16     if (!cached) {
     17         const source = state.doc.toString(), tokens = tokenize(source);
     18         cached = { tokens, sites: definitionSites(source, tokens) };
     19         presentations.set(state.doc, cached);
     20     }
     21     return cached;
     22 }
     23 function decorations(state) {
     24     const { tokens, sites } = presentation(state), selection = state.selection.main;
     25     const pair = selection.empty ? bracketPair(tokens, selection.head) : [];
     26     const marks = [], imports = importSites(state);
     27     for (const token of tokens)
     28         if (token.kind && !imports.some(site => token.start >= site.from && token.end <= site.to)) {
     29             const match = pair.includes(token.start) ? pair.length === 2 ? ' bracket-match' : ' bracket-unmatched' : '';
     30             marks.push(Decoration.mark({ class: `syntax-${token.kind}${match}` }).range(token.start, token.end));
     31         }
     32     const controls = state.field(definitionState);
     33     if (controls.ready)
     34         for (const site of sites) {
     35             if (controls.published && !controls.published.includes(site.name) || controls.busy && controls.selected !== site.name)
     36                 continue;
     37             marks.push(Decoration.mark({ class: 'definition-name', attributes: {
     38                     role: 'button', tabindex: controls.busy ? '-1' : '0', 'data-definition': site.name,
     39                     'aria-label': `Inspect compiled definition ${site.name}`, 'aria-controls': 'inline-definition',
     40                     'aria-expanded': String(controls.selected === site.name), 'aria-disabled': String(controls.busy)
     41                 } }).range(site.start, site.start + site.name.length));
     42         }
     43     return Decoration.set(marks, true);
     44 }
     45 const syntax = StateField.define({
     46     create: decorations,
     47     update: (old, tr) => tr.docChanged || tr.selection || tr.effects.some(effect => effect.is(definitions)) ? decorations(tr.state) : old,
     48     provide: field => EditorView.decorations.from(field)
     49 });
     50 // Symbol.for shares the mounted component across independently bundled test
     51 // and application entrypoints.
     52 const editorKey = Symbol.for('tricu.source-editor');
     53 // Controller and browser fixtures access the same EditorState document,
     54 // selection and history through the mounted component.
     55 export function editorFor(element) {
     56     const editor = element[editorKey];
     57     if (!editor)
     58         throw Error('Source editor is not initialized');
     59     return editor;
     60 }
     61 export class SourceEditor {
     62     dom;
     63     view;
     64     configuration;
     65     currentDefinitions = { ready: false, busy: false };
     66     availability = new Compartment();
     67     changes = new Set();
     68     layouts = new Set();
     69     locked = false;
     70     readonlySource = false;
     71     controls = '';
     72     pendingEpoch;
     73     epoch = 0;
     74     constructor(dom) {
     75         this.dom = dom;
     76         dom.classList.add('source-editor');
     77         this.configuration = [
     78             definitionState, syntax, importFolds, history(), drawSelection(), EditorState.tabSize.of(2),
     79             keymap.of([{ key: 'Enter', run: plainNewline, shift: plainNewline }, ...historyKeymap, ...standardKeymap]), EditorView.lineWrapping,
     80             this.availability.of([EditorState.readOnly.of(false), EditorView.editable.of(true)]),
     81             EditorView.contentAttributes.of({ 'aria-label': 'tricu source (up to 16,384 characters)', spellcheck: 'false', autocorrect: 'off', autocapitalize: 'off' }),
     82             EditorView.updateListener.of(update => {
     83                 if (update.docChanged)
     84                     this.notify();
     85                 // Schedule external listeners after EditorView.update so controller
     86                 // effects can dispatch their own transactions.
     87                 queueMicrotask(() => { for (const listener of this.layouts)
     88                     listener(); });
     89             }),
     90             EditorView.theme({ '&': { height: '100%' }, '.cm-scroller': { overflow: 'auto', fontFamily: 'inherit', lineHeight: 'inherit' },
     91                 '.cm-content': { padding: '18px 20px', fontFamily: 'inherit', fontVariantLigatures: 'none', minHeight: '100%' },
     92                 '.cm-line': { padding: '0' }, '.cm-cursor': { borderLeftColor: 'var(--syntax-ink)' },
     93                 '.cm-selectionBackground, &.cm-focused .cm-selectionBackground': { background: '#799ea066' } })
     94         ];
     95         this.view = new EditorView({ parent: dom, state: EditorState.create({ extensions: this.configuration }) });
     96         this.view.scrollDOM.addEventListener('scroll', () => { for (const listener of this.layouts)
     97             listener(); }, { passive: true });
     98         new ResizeObserver(() => { for (const listener of this.layouts)
     99             listener(); }).observe(dom);
    100         dom.addEventListener('compositionstart', () => dom.classList.add('composing'));
    101         dom.addEventListener('compositionend', () => dom.classList.remove('composing'));
    102         dom[editorKey] = this;
    103     }
    104     notify() {
    105         const epoch = this.epoch;
    106         if (this.pendingEpoch === epoch)
    107             return;
    108         this.pendingEpoch = epoch;
    109         queueMicrotask(() => { if (this.pendingEpoch === epoch)
    110             this.flushChange(); });
    111     }
    112     // Controller edits notify after the view update returns; native-input edits
    113     // notify at the microtask boundary. Both refresh revisions before the next
    114     // user event. Document epochs discard notifications from previous loads.
    115     flushChange() {
    116         if (this.pendingEpoch !== this.epoch)
    117             return;
    118         this.pendingEpoch = undefined;
    119         for (const listener of this.changes)
    120             listener();
    121     }
    122     undo() { const result = undo(this.view); this.flushChange(); return result; }
    123     redo() { const result = redo(this.view); this.flushChange(); return result; }
    124     getSource() { return this.view.state.doc.toString(); }
    125     getSelection() { const { from, to } = this.view.state.selection.main; return { from, to }; }
    126     focus() { this.view.focus(); }
    127     declarations() { return presentation(this.view.state).sites; }
    128     revealDefinition(name) {
    129         const site = this.declarations().find(site => site.name === name);
    130         if (!site)
    131             return false;
    132         this.view.dispatch({ effects: EditorView.scrollIntoView(site.start, { y: 'center' }) });
    133         return true;
    134     }
    135     select(from, to = from) { this.view.dispatch({ selection: EditorSelection.single(from, to), scrollIntoView: true }); this.focus(); }
    136     // Loading/restoring documents retires old history; import insertion is a
    137     // separate, isolated undoable edit. Notification is explicit for controller edits.
    138     replaceSource(source, notify = false) {
    139         this.epoch++;
    140         this.pendingEpoch = undefined;
    141         const state = EditorState.create({ doc: source, extensions: this.configuration });
    142         this.view.setState(state.update({ effects: [definitions.of(this.currentDefinitions),
    143                 this.availability.reconfigure([EditorState.readOnly.of(this.locked || this.readonlySource), EditorView.editable.of(!this.locked && !this.readonlySource)])] }).state);
    144         if (notify) {
    145             this.pendingEpoch = this.epoch;
    146             this.flushChange();
    147         }
    148     }
    149     edit(from, to, text) {
    150         this.view.dispatch({ changes: { from, to, insert: text }, selection: EditorSelection.single(from + text.length),
    151             annotations: [Transaction.userEvent.of('input'), isolateHistory.of('full')] });
    152         this.focus();
    153         this.flushChange();
    154     }
    155     get isDisabled() { return this.locked; }
    156     get isReadOnly() { return this.readonlySource; }
    157     setAvailability(disabled, readOnly) {
    158         if (this.locked === disabled && this.readonlySource === readOnly)
    159             return;
    160         this.locked = disabled;
    161         this.readonlySource = readOnly;
    162         this.dom.classList.toggle('disabled', disabled);
    163         this.view.dispatch({ effects: this.availability.reconfigure([EditorState.readOnly.of(disabled || readOnly), EditorView.editable.of(!disabled && !readOnly)]) });
    164     }
    165     setDefinitions(value) {
    166         const key = JSON.stringify(value);
    167         if (key === this.controls)
    168             return;
    169         this.controls = key;
    170         this.currentDefinitions = value;
    171         this.view.dispatch({ effects: definitions.of(value) });
    172     }
    173     onChange(listener) { this.changes.add(listener); return () => this.changes.delete(listener); }
    174     onLayout(listener) { this.layouts.add(listener); }
    175     refresh() { this.view.requestMeasure(); }
    176 }