297 lines
8.0 KiB
TypeScript
Executable File
297 lines
8.0 KiB
TypeScript
Executable File
import {EditorState, Transaction, StateField, StateEffect, Extension, Mapping} from "@codemirror/next/state"
|
|
import {history, undo, redo, isolateHistory} from "@codemirror/next/history"
|
|
import ist from "ist"
|
|
import {collab, CollabConfig, receiveUpdates, sendableUpdates, Update, getClientID, getSyncedVersion} from "@codemirror/next/collab"
|
|
|
|
class DummyServer {
|
|
states: EditorState[] = []
|
|
updates: Update[] = []
|
|
clientIDs: string[] = []
|
|
delayed: number[] = []
|
|
|
|
constructor(doc: string = "", config: {n?: number, extensions?: Extension[], collabConf?: CollabConfig} = {}) {
|
|
let {n = 2, extensions = [], collabConf = {}} = config
|
|
for (let i = 0; i < n; i++)
|
|
this.states.push(EditorState.create({doc, extensions: [history(), collab(collabConf), ...extensions]}))
|
|
}
|
|
|
|
sync(n: number) {
|
|
let state = this.states[n], version = getSyncedVersion(state)
|
|
if (version != this.updates.length) {
|
|
let count = 0
|
|
for (let i = version; i < this.clientIDs.length; i++) {
|
|
if (this.clientIDs[i] == getClientID(this.states[n])) count++
|
|
else break
|
|
}
|
|
this.states[n] = receiveUpdates(state, this.updates.slice(version), count).apply()
|
|
}
|
|
}
|
|
|
|
send(n: number) {
|
|
let state = this.states[n], sendable = sendableUpdates(state)
|
|
if (sendable.length) {
|
|
this.updates = this.updates.concat(sendable)
|
|
for (let i = 0; i < sendable.length; i++) this.clientIDs.push(getClientID(state))
|
|
}
|
|
}
|
|
|
|
broadcast(n: number) {
|
|
if (this.delayed.indexOf(n) > -1) return
|
|
this.sync(n)
|
|
this.send(n)
|
|
for (let i = 0; i < this.states.length; i++) if (i != n) this.sync(i)
|
|
}
|
|
|
|
update(n: number, f: (state: EditorState) => Transaction) {
|
|
this.states[n] = f(this.states[n]).apply()
|
|
this.broadcast(n)
|
|
}
|
|
|
|
type(n: number, text: string, pos: number = this.states[n].selection.primary.head) {
|
|
this.update(n, s => s.t().setSelection(pos).replaceSelection(text))
|
|
}
|
|
|
|
undo(n: number) {
|
|
undo({state: this.states[n], dispatch: tr => this.update(n, () => tr)})
|
|
}
|
|
|
|
redo(n: number) {
|
|
redo({state: this.states[n], dispatch: tr => this.update(n, () => tr)})
|
|
}
|
|
|
|
conv(doc: string) {
|
|
this.states.forEach(state => ist(state.doc.toString(), doc))
|
|
}
|
|
|
|
delay(n: number, f: () => void) {
|
|
this.delayed.push(n)
|
|
f()
|
|
this.delayed.pop()
|
|
this.broadcast(n)
|
|
}
|
|
}
|
|
|
|
describe("collab", () => {
|
|
it("converges for simple changes", () => {
|
|
let s = new DummyServer
|
|
s.type(0, "hi")
|
|
s.type(1, "ok", 2)
|
|
s.type(0, "!", 4)
|
|
s.type(1, "...", 0)
|
|
s.conv("...hiok!")
|
|
})
|
|
|
|
it("converges for multiple local changes", () => {
|
|
let s = new DummyServer
|
|
s.type(0, "hi")
|
|
s.delay(0, () => {
|
|
s.type(0, "A")
|
|
s.type(1, "X", 2)
|
|
s.type(0, "B")
|
|
s.type(1, "Y")
|
|
})
|
|
s.conv("hiXYAB")
|
|
})
|
|
|
|
it("converges with three peers", () => {
|
|
let s = new DummyServer(undefined, {n: 3})
|
|
s.type(0, "A")
|
|
s.type(1, "U")
|
|
s.type(2, "X")
|
|
s.type(0, "B")
|
|
s.type(1, "V")
|
|
s.type(2, "Y")
|
|
s.conv("XYUVAB")
|
|
})
|
|
|
|
it("converges with three peers with multiple steps", () => {
|
|
let s = new DummyServer(undefined, {n: 3})
|
|
s.type(0, "A")
|
|
s.delay(1, () => {
|
|
s.type(1, "U")
|
|
s.type(2, "X")
|
|
s.type(0, "B")
|
|
s.type(1, "V")
|
|
s.type(2, "Y")
|
|
})
|
|
s.conv("XYUVAB")
|
|
})
|
|
|
|
it("supports undo", () => {
|
|
let s = new DummyServer
|
|
s.type(0, "A")
|
|
s.type(1, "a")
|
|
s.type(0, "B")
|
|
s.undo(1)
|
|
s.conv("AB")
|
|
s.type(1, "b")
|
|
s.type(0, "C")
|
|
s.conv("bABC")
|
|
})
|
|
|
|
it("supports redo", () => {
|
|
let s = new DummyServer
|
|
s.type(0, "A")
|
|
s.type(1, "a")
|
|
s.type(0, "B")
|
|
s.undo(1)
|
|
s.redo(1)
|
|
s.type(1, "b")
|
|
s.type(0, "C")
|
|
s.conv("abABC")
|
|
})
|
|
|
|
it("supports deep undo", () => {
|
|
let s = new DummyServer("hello bye")
|
|
s.update(0, s => s.t().setSelection(5))
|
|
s.update(1, s => s.t().setSelection(9))
|
|
s.type(0, "!")
|
|
s.type(1, "!")
|
|
s.update(0, s => s.t().annotate(isolateHistory, "full"))
|
|
s.delay(0, () => {
|
|
s.type(0, " ...")
|
|
s.type(1, " ,,,")
|
|
})
|
|
s.update(0, s => s.t().annotate(isolateHistory, "full"))
|
|
s.type(0, "*")
|
|
s.type(1, "*")
|
|
s.undo(0)
|
|
s.conv("hello! ... bye! ,,,*")
|
|
s.undo(0)
|
|
s.undo(0)
|
|
s.conv("hello bye! ,,,*")
|
|
s.redo(0)
|
|
s.redo(0)
|
|
s.redo(0)
|
|
s.conv("hello! ...* bye! ,,,*")
|
|
s.undo(0)
|
|
s.undo(0)
|
|
s.conv("hello! bye! ,,,*")
|
|
s.undo(1)
|
|
s.conv("hello! bye")
|
|
})
|
|
|
|
it("support undo with clashing events", () => {
|
|
let s = new DummyServer("okay!")
|
|
s.type(0, "A", 5)
|
|
s.delay(0, () => {
|
|
s.type(0, "B", 3)
|
|
s.type(0, "C", 4)
|
|
s.type(0, "D", 0)
|
|
s.update(1, s => s.t().replace(1, 4, ""))
|
|
})
|
|
s.conv("Do!A")
|
|
s.undo(0)
|
|
s.undo(0)
|
|
s.conv("o!")
|
|
ist(s.states[0].selection.primary.head, 0)
|
|
})
|
|
|
|
it("handles conflicting steps", () => {
|
|
let s = new DummyServer("abcde")
|
|
s.delay(0, () => {
|
|
s.update(0, s => s.t().replace(2, 3, ""))
|
|
s.type(0, "x")
|
|
s.update(1, s => s.t().replace(1, 4, ""))
|
|
})
|
|
s.undo(0)
|
|
s.undo(0)
|
|
s.conv("ae")
|
|
})
|
|
|
|
it("can undo simultaneous typing", () => {
|
|
let s = new DummyServer("A B")
|
|
s.delay(0, () => {
|
|
s.type(0, "1", 1)
|
|
s.type(0, "2")
|
|
s.type(1, "x", 3)
|
|
s.type(1, "y")
|
|
})
|
|
s.conv("A12 Bxy")
|
|
s.undo(0)
|
|
s.conv("A Bxy")
|
|
s.undo(1)
|
|
s.conv("A B")
|
|
})
|
|
|
|
it("includes rebased changes when necessary", () => {
|
|
let counter = StateField.define<number>({
|
|
create() { return 0 },
|
|
update(val, tr) { return val + tr.changes.length }
|
|
})
|
|
let s = new DummyServer("___ ___", {extensions: [counter]})
|
|
s.delay(0, () => {
|
|
s.type(0, "a", 1)
|
|
s.type(1, "b", 5)
|
|
})
|
|
ist(s.states[0].field(counter), 2)
|
|
ist(s.states[1].field(counter), 2)
|
|
s.delay(0, () => {
|
|
s.type(0, "x", 3)
|
|
s.type(1, "y", 3)
|
|
})
|
|
ist(s.states[0].field(counter), 6)
|
|
ist(s.states[1].field(counter), 4)
|
|
s.conv("_a_yx_ _b__")
|
|
})
|
|
|
|
it("allows you to set your client id", () => {
|
|
ist(getClientID(EditorState.create({extensions: [collab({clientID: "my id"})]})), "my id")
|
|
})
|
|
|
|
it("client ids survive reconfiguration", () => {
|
|
let ext = collab()
|
|
let state = EditorState.create({extensions: [ext]})
|
|
let state2 = state.t().reconfigure([ext]).apply()
|
|
ist(getClientID(state), getClientID(state2))
|
|
})
|
|
|
|
it("associates transaction info with local changes", () => {
|
|
let state = EditorState.create({extensions: [collab()]})
|
|
let tr = state.t().replace(0, 0, "hi")
|
|
ist(sendableUpdates(tr.apply())[0].origin, tr)
|
|
})
|
|
|
|
it("supports shared effects", () => {
|
|
class Mark {
|
|
constructor(readonly from: number,
|
|
readonly to: number,
|
|
readonly id: string) {}
|
|
|
|
map(mapping: Mapping) {
|
|
let from = mapping.mapPos(this.from, 1), to = mapping.mapPos(this.to, -1)
|
|
return from >= to ? undefined : new Mark(from, to, this.id)
|
|
}
|
|
|
|
toString() { return `${this.from}-${this.to}=${this.id}` }
|
|
}
|
|
let addMark = StateEffect.define<Mark>({map: (v, m) => v.map(m)})
|
|
let marks = StateField.define<Mark[]>({
|
|
create: () => [],
|
|
update(value, tr) {
|
|
value = value.map(m => m.map(tr.changes)).filter(x => x) as any
|
|
for (let effect of tr.effects) if (effect.is(addMark)) value = value.concat(effect.value)
|
|
return value.sort((a, b) => a.id < b.id ? -1 : 1)
|
|
}
|
|
})
|
|
|
|
let s = new DummyServer("hello", {
|
|
extensions: [marks],
|
|
collabConf: {sharedEffects(tr) { return tr.effects.filter(e => e.is(addMark)) }}
|
|
})
|
|
s.delay(0, () => {
|
|
s.delay(1, () => {
|
|
s.update(0, s => s.t().effect(addMark.of(new Mark(1, 3, "a"))))
|
|
s.update(1, s => s.t().effect(addMark.of(new Mark(3, 5, "b"))))
|
|
s.type(0, "A", 4)
|
|
s.type(1, "B", 0)
|
|
ist(s.states[0].field(marks).join(), "1-3=a")
|
|
ist(s.states[1].field(marks).join(), "4-6=b")
|
|
})
|
|
})
|
|
s.conv("BhellAo")
|
|
ist(s.states[0].field(marks).join(), "2-4=a,4-7=b")
|
|
ist(s.states[1].field(marks).join(), "2-4=a,4-7=b")
|
|
})
|
|
})
|