|
| 1 | +import { |
| 2 | + Annotation, |
| 3 | + Facet, |
| 4 | + Prec, |
| 5 | + StateEffect, |
| 6 | + StateField, |
| 7 | + type Extension, |
| 8 | +} from '@codemirror/state'; |
| 9 | +import { Decoration, EditorView, WidgetType, keymap, type DecorationSet } from '@codemirror/view'; |
| 10 | +import type { NesMode } from './types'; |
| 11 | + |
| 12 | +export interface NesSuggestion { |
| 13 | + text: string; |
| 14 | + pos: number; |
| 15 | +} |
| 16 | + |
| 17 | +export interface NesCompleteInput { |
| 18 | + title: string; |
| 19 | + content: string; |
| 20 | + cursor: number; |
| 21 | +} |
| 22 | + |
| 23 | +export interface NesExtensionOptions { |
| 24 | + getMode: () => NesMode; |
| 25 | + getTitle: () => string; |
| 26 | + complete: (input: NesCompleteInput) => Promise<string | null>; |
| 27 | + idleMs?: number; |
| 28 | +} |
| 29 | + |
| 30 | +const setNesEffect = StateEffect.define<NesSuggestion | null>(); |
| 31 | +const nesAccepted = Annotation.define<boolean>(); |
| 32 | + |
| 33 | +export const nesOptionsFacet = Facet.define<NesExtensionOptions, NesExtensionOptions | null>({ |
| 34 | + combine(values) { |
| 35 | + return values[0] ?? null; |
| 36 | + }, |
| 37 | +}); |
| 38 | + |
| 39 | +class NesGhostWidget extends WidgetType { |
| 40 | + constructor(readonly text: string) { |
| 41 | + super(); |
| 42 | + } |
| 43 | + |
| 44 | + eq(other: NesGhostWidget): boolean { |
| 45 | + return other.text === this.text; |
| 46 | + } |
| 47 | + |
| 48 | + toDOM(): HTMLElement { |
| 49 | + const span = document.createElement('span'); |
| 50 | + span.className = 'cm-nes-ghost'; |
| 51 | + span.textContent = this.text; |
| 52 | + span.setAttribute('aria-hidden', 'true'); |
| 53 | + return span; |
| 54 | + } |
| 55 | + |
| 56 | + ignoreEvent(): boolean { |
| 57 | + return true; |
| 58 | + } |
| 59 | +} |
| 60 | + |
| 61 | +function decorationsFor(suggestion: NesSuggestion | null): DecorationSet { |
| 62 | + if (!suggestion?.text) return Decoration.none; |
| 63 | + return Decoration.set([ |
| 64 | + Decoration.widget({ |
| 65 | + widget: new NesGhostWidget(suggestion.text), |
| 66 | + side: 1, |
| 67 | + }).range(suggestion.pos), |
| 68 | + ]); |
| 69 | +} |
| 70 | + |
| 71 | +export const nesField = StateField.define<NesSuggestion | null>({ |
| 72 | + create: () => null, |
| 73 | + update(value, tr) { |
| 74 | + for (const effect of tr.effects) { |
| 75 | + if (effect.is(setNesEffect)) return effect.value; |
| 76 | + } |
| 77 | + if (tr.docChanged || tr.selection) return null; |
| 78 | + return value; |
| 79 | + }, |
| 80 | + provide: field => [ |
| 81 | + EditorView.decorations.from(field, decorationsFor), |
| 82 | + EditorView.editorAttributes.from(field, value => |
| 83 | + value ? { class: 'cm-nes-active' } : ({} as Record<string, string>) |
| 84 | + ), |
| 85 | + ], |
| 86 | +}); |
| 87 | + |
| 88 | +export function hasNesSuggestion(view: EditorView): boolean { |
| 89 | + return view.state.field(nesField, false) != null; |
| 90 | +} |
| 91 | + |
| 92 | +export function dismissNes(view: EditorView): boolean { |
| 93 | + if (!hasNesSuggestion(view)) return false; |
| 94 | + view.dispatch({ effects: setNesEffect.of(null) }); |
| 95 | + return true; |
| 96 | +} |
| 97 | + |
| 98 | +export function acceptNes(view: EditorView): boolean { |
| 99 | + const suggestion = view.state.field(nesField, false); |
| 100 | + if (!suggestion) return false; |
| 101 | + const pos = suggestion.pos; |
| 102 | + if (pos < 0 || pos > view.state.doc.length) { |
| 103 | + view.dispatch({ effects: setNesEffect.of(null) }); |
| 104 | + return false; |
| 105 | + } |
| 106 | + view.dispatch({ |
| 107 | + changes: { from: pos, insert: suggestion.text }, |
| 108 | + selection: { anchor: pos + suggestion.text.length }, |
| 109 | + effects: setNesEffect.of(null), |
| 110 | + annotations: nesAccepted.of(true), |
| 111 | + userEvent: 'input', |
| 112 | + }); |
| 113 | + return true; |
| 114 | +} |
| 115 | + |
| 116 | +const generations = new WeakMap<EditorView, number>(); |
| 117 | + |
| 118 | +function nextGeneration(view: EditorView): number { |
| 119 | + const n = (generations.get(view) ?? 0) + 1; |
| 120 | + generations.set(view, n); |
| 121 | + return n; |
| 122 | +} |
| 123 | + |
| 124 | +export function triggerNes(view: EditorView): boolean { |
| 125 | + const options = view.state.facet(nesOptionsFacet); |
| 126 | + if (!options || options.getMode() === 'disabled') return false; |
| 127 | + void requestSuggestion(view, options); |
| 128 | + return true; |
| 129 | +} |
| 130 | + |
| 131 | +async function requestSuggestion(view: EditorView, options: NesExtensionOptions): Promise<void> { |
| 132 | + const cursor = view.state.selection.main.head; |
| 133 | + const doc = view.state.doc.toString(); |
| 134 | + const generation = nextGeneration(view); |
| 135 | + const insertion = await options.complete({ |
| 136 | + title: options.getTitle(), |
| 137 | + content: doc, |
| 138 | + cursor, |
| 139 | + }); |
| 140 | + if (generations.get(view) !== generation) return; |
| 141 | + if (!view.dom.isConnected) return; |
| 142 | + if (!insertion) return; |
| 143 | + if (view.state.doc.toString() !== doc) return; |
| 144 | + if (view.state.selection.main.head !== cursor) return; |
| 145 | + view.dispatch({ |
| 146 | + effects: setNesEffect.of({ text: insertion, pos: cursor }), |
| 147 | + }); |
| 148 | +} |
| 149 | + |
| 150 | +export function createNesExtension(options: NesExtensionOptions): Extension { |
| 151 | + const idleMs = options.idleMs ?? 500; |
| 152 | + let idleTimer: ReturnType<typeof setTimeout> | null = null; |
| 153 | + |
| 154 | + function clearIdle(): void { |
| 155 | + if (idleTimer) { |
| 156 | + clearTimeout(idleTimer); |
| 157 | + idleTimer = null; |
| 158 | + } |
| 159 | + } |
| 160 | + |
| 161 | + return [ |
| 162 | + nesOptionsFacet.of(options), |
| 163 | + nesField, |
| 164 | + Prec.high( |
| 165 | + keymap.of([ |
| 166 | + { key: 'Tab', run: acceptNes }, |
| 167 | + { key: 'Escape', run: dismissNes }, |
| 168 | + ]) |
| 169 | + ), |
| 170 | + EditorView.updateListener.of(update => { |
| 171 | + if (update.docChanged || update.selectionSet) { |
| 172 | + nextGeneration(update.view); |
| 173 | + } |
| 174 | + if (!update.docChanged) return; |
| 175 | + if (update.transactions.some(tr => tr.annotation(nesAccepted))) return; |
| 176 | + clearIdle(); |
| 177 | + if (options.getMode() !== 'automatic') return; |
| 178 | + const view = update.view; |
| 179 | + idleTimer = setTimeout(() => { |
| 180 | + idleTimer = null; |
| 181 | + if (options.getMode() !== 'automatic') return; |
| 182 | + if (!view.dom.isConnected) return; |
| 183 | + if (view.state.selection.main.empty) { |
| 184 | + void requestSuggestion(view, options); |
| 185 | + } |
| 186 | + }, idleMs); |
| 187 | + }), |
| 188 | + EditorView.domEventObservers({ |
| 189 | + blur() { |
| 190 | + clearIdle(); |
| 191 | + }, |
| 192 | + }), |
| 193 | + ]; |
| 194 | +} |
| 195 | + |
| 196 | +export function setNesSuggestion(view: EditorView, text: string, pos: number): void { |
| 197 | + view.dispatch({ effects: setNesEffect.of({ text, pos }) }); |
| 198 | +} |
0 commit comments