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