Last active
August 23, 2026 12:44
-
-
Save andy0130tw/3a901ffcbc9149f254d2c4cd6ffb4c8d to your computer and use it in GitHub Desktop.
PoC of linked editor views for CodeMirror 6
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { | |
| type Extension, | |
| type EditorStateConfig, | |
| type ChangeSpec, | |
| type TransactionSpec, | |
| Transaction, | |
| Compartment, | |
| EditorState, | |
| Annotation, | |
| StateField, | |
| StateEffect, | |
| } from '@codemirror/state' | |
| import { | |
| EditorView, | |
| lineNumbers, | |
| ViewPlugin, | |
| type PluginValue, | |
| type ViewUpdate, | |
| } from '@codemirror/view' | |
| function offsettedLineNumbers(base: number, minDigits: number) { | |
| return lineNumbers({ | |
| formatNumber(lineNo, state) { | |
| // CM will probe for the gutter width with a line number "99...9" | |
| // and we should not change it to avoid inconsistent spacing | |
| const offset = lineNo > state.doc.lines ? 0 : base | |
| return (lineNo + offset).toString().padStart(minDigits, '\u2007') | |
| } | |
| }) | |
| } | |
| const lineNumberCompartment = new Compartment() | |
| const syncAnnotation = Annotation.define<number>() | |
| const subviewBoundaries = StateField.define<number[]>({ | |
| create() { return [] }, | |
| update(value, tr) { | |
| for (const e of tr.effects) { | |
| if (e.is(setSubviewBoundaries)) { | |
| value = e.value | |
| } | |
| } | |
| return value | |
| } | |
| }) | |
| const setSubviewBoundaries = StateEffect.define<number[]>() | |
| const setIsLastUsed = StateEffect.define<boolean>() | |
| const lastUsedState = StateField.define<boolean>({ | |
| create() { return false }, | |
| update(value, tr) { | |
| for (const e of tr.effects) { | |
| if (e.is(setIsLastUsed)) { | |
| value = e.value | |
| } | |
| } | |
| return value | |
| }, | |
| provide(f) { | |
| return EditorView.editorAttributes.from(f, isLastUsed => { | |
| return isLastUsed ? { class: 'last-used' } : {} as never | |
| }) | |
| } | |
| }) | |
| const lastUsedViewPlugin = ViewPlugin.fromClass(class LastUsedViewPlugin implements PluginValue { | |
| constructor(readonly view: EditorView, readonly views: EditorView[]) {} | |
| update(upd: ViewUpdate) { | |
| for (const tr of upd.transactions) { | |
| for (const e of tr.effects) { | |
| if (e.is(setIsLastUsed) && e.value) { | |
| for (const v of this.views) { | |
| if (v === this.view) continue | |
| v.dispatch({ effects: setIsLastUsed.of(false) }) | |
| } | |
| } | |
| } | |
| } | |
| } | |
| }) | |
| export class LinkedEditors { | |
| private lineNumberOffsets: number[] = [] | |
| subviews: EditorView[] = [] | |
| private subviewToIndex = new Map<EditorView, number>() | |
| // the "grand" view for all subviews | |
| globalView: EditorView | |
| constructor(config: EditorStateConfig, subviews: number[]) { | |
| const extensions: Extension[] = | |
| Array.isArray(config.extensions) ? config.extensions : | |
| config.extensions ? [config.extensions] : [] | |
| const editorState = EditorState.create({ | |
| ...config, | |
| extensions: [ | |
| ...extensions, | |
| subviewBoundaries, | |
| // forbid edit on subview boundaries | |
| EditorState.changeFilter.of(tr => { | |
| return tr.state.field(subviewBoundaries) | |
| }), | |
| ], | |
| }) | |
| this.globalView = new EditorView({ | |
| state: editorState, | |
| dispatchTransactions: this.dispatchFromParentView.bind(this), | |
| }) | |
| let cnt = 0 | |
| for (const lines of subviews) { | |
| this.lineNumberOffsets.push(cnt) | |
| const from = editorState.doc.line(cnt + 1).from | |
| const to = cnt + lines >= this.totalLines ? | |
| editorState.doc.length : | |
| editorState.doc.line(cnt + lines).to | |
| const idx = this.subviews.length | |
| const sv = new EditorView({ | |
| doc: editorState.sliceDoc(from, to), | |
| extensions: [ | |
| ...extensions, | |
| lineNumberCompartment.of(offsettedLineNumbers(cnt, this.lineNumberDigits)), | |
| this.lastUsedSubview(), | |
| ], | |
| dispatchTransactions: this.dispatchFromSubview.bind(this), | |
| }) | |
| this.subviews.push(sv) | |
| this.subviewToIndex.set(sv, idx) | |
| cnt += lines | |
| } | |
| } | |
| lastUsedSubview() { | |
| return [ | |
| lastUsedState, | |
| lastUsedViewPlugin.of(this.subviews), | |
| EditorView.focusChangeEffect.of((_state, focused) => focused ? setIsLastUsed.of(true) : null), | |
| ] | |
| } | |
| get totalLines() { | |
| return this.globalView.state.doc.lines | |
| } | |
| get lineNumberDigits() { | |
| return this.totalLines.toString().length | |
| } | |
| // n is 1-based! | |
| lineNumberToSubviewIndex(n: number) { | |
| // TODO: use binary search | |
| for (let i = 1; i < this.lineNumberOffsets.length; i++) { | |
| if (n - 1 < this.lineNumberOffsets[i]) { | |
| return i - 1 | |
| } | |
| } | |
| return this.lineNumberOffsets.length - 1 | |
| } | |
| dispatchFromParentView(trs: readonly Transaction[], view: EditorView) { | |
| view.update(trs) | |
| for (const tr of trs) { | |
| if (tr.annotation(syncAnnotation) != null) continue | |
| if (!tr.changes.empty) { | |
| // group specs by subview idx it should go to | |
| const specsByIdx = new Map<number, ChangeSpec[]>() | |
| function pushSpec(idx: number, spec: ChangeSpec) { | |
| let sp = specsByIdx.get(idx) | |
| if (sp == null) { | |
| specsByIdx.set(idx, sp = []) | |
| } | |
| sp.push(spec) | |
| } | |
| tr.changes.iterChanges((fa, ta, _fb, _tb, insert) => { | |
| const svFrom = this.lineNumberToSubviewIndex(tr.startState.doc.lineAt(fa).number) | |
| const svTo = this.lineNumberToSubviewIndex(tr.startState.doc.lineAt(ta).number) | |
| const offsFrom = tr.startState.doc.line(this.lineNumberOffsets[svFrom] + 1).from | |
| if (svFrom == svTo) { | |
| pushSpec(svFrom, { from: fa - offsFrom, to: ta - offsFrom, insert }) | |
| } else { | |
| // should not happen if boundaries are protected properly | |
| throw new Error('Cannot apply changes that crosses subview boundary') | |
| } | |
| }, true) | |
| for (const [idx, specs] of specsByIdx.entries()) { | |
| this.subviews[idx].dispatch({ | |
| changes: specs, | |
| annotations: syncAnnotation.of(-1), | |
| }) | |
| } | |
| } | |
| // TODO: selections and effects | |
| } | |
| } | |
| dispatchFromSubview(trs: readonly Transaction[], view: EditorView) { | |
| const oldMinDigits = this.lineNumberDigits | |
| view.update(trs) | |
| if (trs.length == 0) return | |
| const oldLns = trs[0].startState.doc.lines | |
| const newLns = trs.at(-1)!.state.doc.lines | |
| const idx = this.subviewToIndex.get(view) | |
| if (idx == null) throw new Error('Cannot locate subview in linked editors') | |
| const subviewOffs = this.globalView.state.doc.line(this.lineNumberOffsets[idx] + 1).from | |
| const parentTrs: TransactionSpec[] = [] | |
| for (const tr of trs) { | |
| if (tr.annotation(syncAnnotation) != null) continue | |
| if (!tr.changes.empty) { | |
| const specs: ChangeSpec[] = [] | |
| tr.changes.iterChanges((fa, ta, _fb, _tb, insert) => { | |
| specs.push({ from: subviewOffs + fa, to: subviewOffs + ta, insert }) | |
| }) | |
| parentTrs.push({ | |
| changes: specs, | |
| annotations: syncAnnotation.of(idx), | |
| }) | |
| } | |
| if (tr.selection) { | |
| // TODO: selections and effects | |
| } | |
| } | |
| this.globalView.dispatch(...parentTrs) | |
| if (newLns != oldLns) { | |
| const newMinDigits = this.lineNumberDigits | |
| // XXX: there must be more efficient approach than this, but querying is already O(n) | |
| for (let i = idx + 1; i < this.subviews.length; i++) { | |
| this.lineNumberOffsets[i] += newLns - oldLns | |
| } | |
| // refresh all subviews if the line gutter width changes | |
| let i = newMinDigits == oldMinDigits ? idx + 1 : 0 | |
| for (; i < this.subviews.length; i++) { | |
| this.subviews[i]?.dispatch({ | |
| effects: lineNumberCompartment.reconfigure(offsettedLineNumbers(this.lineNumberOffsets[i], this.lineNumberDigits)), | |
| }) | |
| } | |
| } | |
| // compute and save new boundary locations | |
| // XXX: make it a rangeset and map with changes | |
| const ranges: number[] = [] | |
| for (let i = 1; i < this.lineNumberOffsets.length; i++) { | |
| const offs = this.globalView.state.doc.line(this.lineNumberOffsets[i] + 1).from | |
| ranges.push(offs - 1, offs) | |
| } | |
| this.globalView.dispatch({ effects: setSubviewBoundaries.of(ranges) }) | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment