tricu

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

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 }