Skip to content

Instantly share code, notes, and snippets.

@andy0130tw
Last active August 23, 2026 12:44
Show Gist options
  • Select an option

  • Save andy0130tw/3a901ffcbc9149f254d2c4cd6ffb4c8d to your computer and use it in GitHub Desktop.

Select an option

Save andy0130tw/3a901ffcbc9149f254d2c4cd6ffb4c8d to your computer and use it in GitHub Desktop.
PoC of linked editor views for CodeMirror 6
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