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 }