virt2/api/soft/CodeMirror/history/test/test-history.ts

652 lines
25 KiB
TypeScript
Raw Permalink Normal View History

import ist from "ist"
import {EditorState, EditorSelection, SelectionRange, Transaction,
StateEffect, StateEffectType, StateField, Mapping, Change} from "@codemirror/next/state"
import {isolateHistory, history, redo, redoDepth, redoSelection, undo, undoDepth,
undoSelection, invertedEffects} from "@codemirror/next/history"
function mkState(config?: any, doc?: string) {
return EditorState.create({
extensions: [history(config), EditorState.allowMultipleSelections.of(true)],
doc
})
}
function type(state: EditorState, text: string, at = state.doc.length) {
return state.t().replace(at, at, text).apply()
}
function timedType(state: EditorState, text: string, atTime: number) {
return state.t(atTime).replace(state.doc.length, state.doc.length, text).apply()
}
function receive(state: EditorState, text: string, from: number, to = from) {
return state.t().replace(from, to, text).annotate(Transaction.addToHistory, false).apply()
}
function command(state: EditorState, cmd: any, success: boolean = true) {
ist(cmd({state, dispatch(tr: Transaction) { state = tr.apply() }}), success)
return state
}
describe("history", () => {
it("allows to undo a change", () => {
let state = mkState()
state = type(state, "newtext")
state = command(state, undo)
ist(state.doc.toString(), "")
})
it("allows to undo nearby changes in one change", () => {
let state = mkState()
state = type(state, "new")
state = type(state, "text")
state = command(state, undo)
ist(state.doc.toString(), "")
})
it("allows to redo a change", () => {
let state = mkState()
state = type(state, "newtext")
state = command(state, undo)
state = command(state, redo)
ist(state.doc.toString(), "newtext")
})
it("allows to redo nearby changes in one change", () => {
let state = mkState()
state = type(state, "new")
state = type(state, "text")
state = command(state, undo)
state = command(state, redo)
ist(state.doc.toString(), "newtext")
})
it("tracks multiple levels of history", () => {
let state = mkState()
state = type(state, "new")
state = type(state, "text")
state = type(state, "some", 0)
ist(state.doc.toString(), "somenewtext")
state = command(state, undo)
ist(state.doc.toString(), "newtext")
state = command(state, undo)
ist(state.doc.toString(), "")
state = command(state, redo)
ist(state.doc.toString(), "newtext")
state = command(state, redo)
ist(state.doc.toString(), "somenewtext")
state = command(state, undo)
ist(state.doc.toString(), "newtext")
})
it("starts a new event when newGroupDelay elapses", () => {
let state = mkState({newGroupDelay: 1000})
state = timedType(state, "a", 1000)
state = timedType(state, "b", 1600)
ist(undoDepth(state), 1)
state = timedType(state, "c", 2700)
ist(undoDepth(state), 2)
state = command(state, undo)
state = timedType(state, "d", 2800)
ist(undoDepth(state), 2)
})
it("allows changes that aren't part of the history", () => {
let state = mkState()
state = type(state, "hello")
state = receive(state, "oops", 0)
state = receive(state, "!", 9)
state = command(state, undo)
ist(state.doc.toString(), "oops!")
})
it("doesn't get confused by an undo not adding any redo item", () => {
let state = mkState({}, "ab")
state = type(state, "cd", 1)
state = receive(state, "123", 0, 4)
state = command(state, undo, false)
command(state, redo, false)
})
it("accurately maps changes through each other", () => {
let state = mkState({}, "123")
state = state.t().replace(1, 2, "cd").replace(3, 4, "ef").replace(0, 1, "ab").apply()
state = receive(state, "!!!!!!!!", 2, 2)
state = command(state, undo)
state = command(state, redo)
ist(state.doc.toString(), "ab!!!!!!!!cdef")
})
it("can handle complex editing sequences", () => {
let state = mkState()
state = type(state, "hello")
state = state.t().annotate(isolateHistory, "before").apply()
state = type(state, "!")
state = receive(state, "....", 0)
state = type(state, "\n\n", 2)
ist(state.doc.toString(), "..\n\n..hello!")
state = receive(state, "\n\n", 1)
state = command(state, undo)
state = command(state, undo)
ist(state.doc.toString(), ".\n\n...hello")
state = command(state, undo)
ist(state.doc.toString(), ".\n\n...")
})
it("supports overlapping edits", () => {
let state = mkState()
state = type(state, "hello")
state = state.t().annotate(isolateHistory, "before").apply()
state = state.t().replace(0, 5, "").apply()
ist(state.doc.toString(), "")
state = command(state, undo)
ist(state.doc.toString(), "hello")
state = command(state, undo)
ist(state.doc.toString(), "")
})
it("supports overlapping edits that aren't collapsed", () => {
let state = mkState()
state = receive(state, "h", 0)
state = type(state, "ello")
state = state.t().annotate(isolateHistory, "before").apply()
state = state.t().replace(0, 5, "").apply()
ist(state.doc.toString(), "")
state = command(state, undo)
ist(state.doc.toString(), "hello")
state = command(state, undo)
ist(state.doc.toString(), "h")
})
it("supports overlapping unsynced deletes", () => {
let state = mkState()
state = type(state, "hi")
state = state.t().annotate(isolateHistory, "before").apply()
state = type(state, "hello")
state = state.t().replace(0, 7, "").annotate(Transaction.addToHistory, false).apply()
ist(state.doc.toString(), "")
state = command(state, undo, false)
ist(state.doc.toString(), "")
})
it("can go back and forth through history multiple times", () => {
let state = mkState()
state = type(state, "one")
state = type(state, " two")
state = state.t().annotate(isolateHistory, "before").apply()
state = type(state, " three")
state = type(state, "zero ", 0)
state = state.t().annotate(isolateHistory, "before").apply()
state = type(state, "\n\n", 0)
state = type(state, "top", 0)
for (let i = 0; i < 6; i++) {
let re = i % 2
for (let j = 0; j < 4; j++) state = command(state, re ? redo : undo)
ist(state.doc.toString(), re ? "top\n\nzero one two three" : "")
}
})
it("supports non-tracked changes next to tracked changes", () => {
let state = mkState()
state = type(state, "o")
state = type(state, "\n\n", 0)
state = receive(state, "zzz", 3)
state = command(state, undo)
ist(state.doc.toString(), "zzz")
})
it("can go back and forth through history when preserving items", () => {
let state = mkState()
state = type(state, "one")
state = type(state, " two")
state = state.t().annotate(isolateHistory, "before").apply()
state = receive(state, "xxx", state.doc.length)
state = type(state, " three")
state = type(state, "zero ", 0)
state = state.t().annotate(isolateHistory, "before").apply()
state = type(state, "\n\n", 0)
state = type(state, "top", 0)
state = receive(state, "yyy", 0)
for (let i = 0; i < 3; i++) {
for (let j = 0; j < 4; j++) state = command(state, undo)
ist(state.doc.toString(), "yyyxxx")
for (let j = 0; j < 4; j++) state = command(state, redo)
ist(state.doc.toString(), "yyytop\n\nzero one twoxxx three")
}
})
it("restores selection on undo", () => {
let state = mkState()
state = type(state, "hi")
state = state.t().annotate(isolateHistory, "before").apply()
state = state.t().setSelection(EditorSelection.single(0, 2)).apply()
const selection = state.selection
state = state.t().replaceSelection("hello").apply()
const selection2 = state.selection
state = command(state, undo)
ist(state.selection.eq(selection))
state = command(state, redo)
ist(state.selection.eq(selection2))
})
it("restores the selection before the first change in an item (#46)", () => {
let state = mkState()
state = state.t().replace(0, 0, "a").setSelection(EditorSelection.single(1)).apply()
state = state.t().replace(1, 1, "b").setSelection(EditorSelection.single(2)).apply()
state = command(state, undo)
ist(state.doc.toString(), "")
ist(state.selection.primary.anchor, 0)
})
it("doesn't merge document changes if there's a selection change in between", () => {
let state = mkState()
state = type(state, "hi")
state = state.t().setSelection(EditorSelection.single(0, 2)).apply()
state = state.t().replaceSelection("hello").apply()
ist(undoDepth(state), 2)
})
it("rebases selection on undo", () => {
let state = mkState()
state = type(state, "hi")
state = state.t().annotate(isolateHistory, "before").apply()
state = state.t().setSelection(EditorSelection.single(0, 2)).apply()
state = type(state, "hello", 0)
state = receive(state, "---", 0)
state = command(state, undo)
ist(state.selection.ranges[0].head, 5)
})
it("supports querying for the undo and redo depth", () => {
let state = mkState()
state = type(state, "a")
ist(undoDepth(state), 1)
ist(redoDepth(state), 0)
state = receive(state, "b", 0)
ist(undoDepth(state), 1)
ist(redoDepth(state), 0)
state = command(state, undo)
ist(undoDepth(state), 0)
ist(redoDepth(state), 1)
state = command(state, redo)
ist(undoDepth(state), 1)
ist(redoDepth(state), 0)
})
it("all functions gracefully handle EditorStates without history", () => {
let state = EditorState.create()
ist(undoDepth(state), 0)
ist(redoDepth(state), 0)
command(state, undo, false)
command(state, redo, false)
})
it("truncates history", () => {
let state = mkState({minDepth: 10})
for (let i = 0; i < 40; ++i) {
state = type(state, "a")
state = state.t().annotate(isolateHistory, "before").apply()
}
ist(undoDepth(state) < 40)
})
it("supports transactions with multiple changes", () => {
let state = mkState()
state = state.t().replace(0, 0, "a").replace(1, 1, "b").apply()
state = type(state, "c", 0)
ist(state.doc.toString(), "cab")
state = command(state, undo)
ist(state.doc.toString(), "ab")
state = command(state, undo)
ist(state.doc.toString(), "")
state = command(state, redo)
ist(state.doc.toString(), "ab")
state = command(state, redo)
ist(state.doc.toString(), "cab")
state = command(state, undo)
ist(state.doc.toString(), "ab")
})
it("doesn't undo selection-only transactions", () => {
let state = mkState(undefined, "abc")
ist(state.selection.primary.head, 0)
state = state.t().setSelection(EditorSelection.single(2)).apply()
state = command(state, undo, false)
ist(state.selection.primary.head, 2)
})
it("isolates transactions when asked to", () => {
let state = mkState()
state = state.t().replace(0, 0, "a").annotate(isolateHistory, "after").apply()
state = state.t().replace(1, 1, "b").apply()
state = state.t().replace(2, 2, "c").annotate(isolateHistory, "after").apply()
state = state.t().replace(3, 3, "d").apply()
state = state.t().replace(4, 4, "e").annotate(isolateHistory, "full").apply()
state = state.t().replace(5, 5, "f").apply()
ist(undoDepth(state), 5)
})
it("can group events around a non-history transaction", () => {
let state = mkState()
state = state.t().replace(0, 0, "a").apply()
state = state.t().replace(1, 1, "b").annotate(Transaction.addToHistory, false).apply()
state = state.t().replace(1, 1, "c").apply()
state = command(state, undo)
ist(state.doc.toString(), "b")
})
it("survives compression", () => {
let state = mkState()
state = state.t().replace(0, 0, "a").apply()
state = state.t().replace(1, 1, "b").annotate(Transaction.addToHistory, false).apply()
state = state.t().replace(2, 2, "c").apply()
state = state.t().replace(3, 3, "d").apply()
state = state.t().replace(4, 4, "e").apply()
state = state.t().replace(0, 0, ">").apply()
for (let i = 0; i < 500; i++) state = state.t().replace(0, 0, "*").annotate(Transaction.addToHistory, false).apply()
state = state.t().replace(0, 500, "=").annotate(Transaction.addToHistory, false).apply()
ist(state.doc.toString(), "=>abcde")
state = command(state, undo)
state = command(state, undo)
ist(state.doc.toString(), "=ab")
state = command(state, undo)
ist(state.doc.toString(), "=b")
state = command(state, redo)
state = command(state, redo)
state = command(state, redo)
ist(state.doc.toString(), "=>abcde")
})
describe("undoSelection", () => {
it("allows to undo a change", () => {
let state = mkState()
state = type(state, "newtext")
state = command(state, undoSelection)
ist(state.doc.toString(), "")
})
it("allows to undo selection-only transactions", () => {
let state = mkState(undefined, "abc")
ist(state.selection.primary.head, 0)
state = state.t().setSelection(EditorSelection.single(2)).apply()
state = command(state, undoSelection)
ist(state.selection.primary.head, 0)
})
it("merges selection-only transactions from keyboard", () => {
let state = mkState(undefined, "abc")
ist(state.selection.primary.head, 0)
state = state.t().setSelection(EditorSelection.single(2)).annotate(Transaction.userEvent, "keyboard").apply()
state = state.t().setSelection(EditorSelection.single(3)).annotate(Transaction.userEvent, "keyboard").apply()
state = state.t().setSelection(EditorSelection.single(1)).annotate(Transaction.userEvent, "keyboard").apply()
state = command(state, undoSelection)
ist(state.selection.primary.head, 0)
})
it("doesn't merge selection-only transactions from other sources", () => {
let state = mkState(undefined, "abc")
ist(state.selection.primary.head, 0)
state = state.t().setSelection(EditorSelection.single(2)).apply()
state = state.t().setSelection(EditorSelection.single(3)).apply()
state = state.t().setSelection(EditorSelection.single(1)).apply()
state = command(state, undoSelection)
ist(state.selection.primary.head, 3)
state = command(state, undoSelection)
ist(state.selection.primary.head, 2)
state = command(state, undoSelection)
ist(state.selection.primary.head, 0)
})
it("doesn't merge selection-only transactions if they change the number of selections", () => {
let state = mkState(undefined, "abc")
ist(state.selection.primary.head, 0)
state = state.t().setSelection(EditorSelection.single(2)).annotate(Transaction.userEvent, "keyboard").apply()
state = state.t().setSelection(EditorSelection.create([new SelectionRange(1, 1), new SelectionRange(3, 3)])).
annotate(Transaction.userEvent, "keyboard").apply()
state = state.t().setSelection(EditorSelection.single(1)).annotate(Transaction.userEvent, "keyboard").apply()
state = command(state, undoSelection)
ist(state.selection.ranges.length, 2)
state = command(state, undoSelection)
ist(state.selection.primary.head, 0)
})
it("doesn't merge selection-only transactions if a selection changes empty state", () => {
let state = mkState(undefined, "abc")
ist(state.selection.primary.head, 0)
state = state.t().setSelection(EditorSelection.single(2)).annotate(Transaction.userEvent, "keyboard").apply()
state = state.t().setSelection(EditorSelection.single(2, 3)).annotate(Transaction.userEvent, "keyboard").apply()
state = state.t().setSelection(EditorSelection.single(1)).annotate(Transaction.userEvent, "keyboard").apply()
state = command(state, undoSelection)
ist(state.selection.primary.anchor, 2)
ist(state.selection.primary.head, 3)
state = command(state, undoSelection)
ist(state.selection.primary.head, 0)
})
it("allows to redo a change", () => {
let state = mkState()
state = type(state, "newtext")
state = command(state, undoSelection)
state = command(state, redoSelection)
ist(state.doc.toString(), "newtext")
})
it("allows to redo selection-only transactions", () => {
let state = mkState(undefined, "abc")
ist(state.selection.primary.head, 0)
state = state.t().setSelection(EditorSelection.single(2)).apply()
state = command(state, undoSelection)
state = command(state, redoSelection)
ist(state.selection.primary.head, 2)
})
it("only changes selection", () => {
let state = mkState()
state = type(state, "hi")
state = state.t().annotate(isolateHistory, "before").apply()
const selection = state.selection
state = state.t().setSelection(EditorSelection.single(0, 2)).apply()
const selection2 = state.selection
state = command(state, undoSelection)
ist(state.selection.eq(selection))
ist(state.doc.toString(), "hi")
state = command(state, redoSelection)
ist(state.selection.eq(selection2))
state = state.t().replaceSelection("hello").apply()
const selection3 = state.selection
state = command(state, undoSelection)
ist(state.selection.eq(selection2))
state = command(state, redo)
ist(state.selection.eq(selection3))
})
it("can undo a selection through remote changes", () => {
let state = mkState()
state = type(state, "hello")
const selection = state.selection
state = state.t().setSelection(EditorSelection.single(0, 2)).apply()
state = receive(state, "oops", 0)
state = receive(state, "!", 9)
ist(state.selection.eq(EditorSelection.single(0, 6)))
state = command(state, undoSelection)
ist(state.doc.toString(), "oopshello!")
ist(state.selection.eq(selection))
})
})
describe("effects", () => {
it("includes inverted effects in the history", () => {
let set = StateEffect.define<number>()
let field = StateField.define({
create: () => 0,
update(val, tr) {
for (let effect of tr.effects) if (effect.is(set)) val = effect.value
return val
}
})
let invert = invertedEffects.of(tr => {
for (let e of tr.effects) if (e.is(set)) return [set.of(tr.startState.field(field))]
return []
})
let state = EditorState.create({extensions: [history(), field, invert]})
state = state.t().effect(set.of(10)).annotate(isolateHistory, "before").apply()
state = state.t().effect(set.of(20)).annotate(isolateHistory, "before").apply()
ist(state.field(field), 20)
state = command(state, undo)
ist(state.field(field), 10)
state = command(state, undo)
ist(state.field(field), 0)
state = command(state, redo)
ist(state.field(field), 10)
state = command(state, redo)
ist(state.field(field), 20)
state = command(state, undo)
ist(state.field(field), 10)
state = command(state, redo)
ist(state.field(field), 20)
})
class Comment {
constructor(readonly from: number,
readonly to: number,
readonly text: string) {}
eq(other: Comment) { return this.from == other.from && this.to == other.to && this.text == other.text }
}
function mapComment(comment: Comment, mapping: Mapping) {
let from = mapping.mapPos(comment.from, 1), to = mapping.mapPos(comment.to, -1)
return from >= to ? undefined : new Comment(from, to, comment.text)
}
let addComment: StateEffectType<Comment> = StateEffect.define<Comment>({map: mapComment})
let rmComment: StateEffectType<Comment> = StateEffect.define<Comment>({map: mapComment})
let comments = StateField.define<Comment[]>({
create: () => [],
update(value, tr) {
value = value.map(c => mapComment(c, tr.changes)).filter(x => x) as any
for (let effect of tr.effects) {
if (effect.is(addComment)) value = value.concat(effect.value)
else if (effect.is(rmComment)) value = value.filter(c => !c.eq(effect.value))
}
return value.sort((a, b) => a.from - b.from)
}
})
let invertComments = invertedEffects.of(tr => {
let effects = []
for (let effect of tr.effects) {
if (effect.is(addComment) || effect.is(rmComment)) {
let src = mapComment(effect.value, tr.invertedChanges())
if (src) effects.push((effect.is(addComment) ? rmComment : addComment).of(src))
}
}
for (let comment of tr.startState.field(comments)) {
if (!mapComment(comment, tr.changes)) effects.push(addComment.of(comment))
}
return effects
})
function commentStr(state: EditorState) { return state.field(comments).map(c => c.text + "@" + c.from).join(",") }
it("can map effects", () => {
let state = EditorState.create({extensions: [history(), comments, invertComments],
doc: "one two foo"})
state = state.t().effect(addComment.of(new Comment(0, 3, "c1"))).annotate(isolateHistory, "full").apply()
ist(commentStr(state), "c1@0")
state = state.t().replace(3, 4, "---").annotate(isolateHistory, "full").
effect(addComment.of(new Comment(6, 9, "c2"))).apply()
ist(commentStr(state), "c1@0,c2@6")
state = state.t().replace(0, 0, "---").annotate(Transaction.addToHistory, false).apply()
ist(commentStr(state), "c1@3,c2@9")
state = command(state, undo)
ist(state.doc.toString(), "---one two foo")
ist(commentStr(state), "c1@3")
state = command(state, undo)
ist(commentStr(state), "")
state = command(state, redo)
ist(commentStr(state), "c1@3")
state = command(state, redo)
ist(commentStr(state), "c1@3,c2@9")
ist(state.doc.toString(), "---one---two foo")
state = command(state, undo).t().replace(10, 11, "---").annotate(Transaction.addToHistory, false).apply()
state = state.t().effect(addComment.of(new Comment(13, 16, "c3"))).annotate(isolateHistory, "full").apply()
ist(commentStr(state), "c1@3,c3@13")
state = command(state, undo)
ist(state.doc.toString(), "---one two---foo")
ist(commentStr(state), "c1@3")
state = command(state, redo)
ist(commentStr(state), "c1@3,c3@13")
})
it("can restore comments lost through deletion", () => {
let state = EditorState.create({extensions: [history(), comments, invertComments],
doc: "123456"})
state = state.t().effect(addComment.of(new Comment(3, 5, "c1"))).annotate(isolateHistory, "full").apply()
state = state.t().replace(2, 6, "").apply()
ist(commentStr(state), "")
state = command(state, undo)
ist(commentStr(state), "c1@3")
})
})
it("behaves properly with rebasing changes", () => {
let state = EditorState.create({extensions: [history()], doc: "one three", selection: {anchor: 3}})
let changes: {forward: Change, backward: Change}[] = []
function dispatch(tr: Transaction) {
for (let inv = tr.invertedChanges(), i = 0, j = inv.length - 1; j >= 0; i++, j--)
changes.push({forward: tr.changes.changes[i], backward: inv.changes[j]})
state = tr.apply()
}
function receive(confirmedTo: number, f: (tr: Transaction) => void) {
let tr = state.t(), newChanges = changes.slice(0, confirmedTo)
for (let i = changes.length - 1; i >= confirmedTo; i--) tr.changeNoFilter(changes[i].backward)
f(tr)
for (let i = confirmedTo, refIndex = changes.length - confirmedTo; i < changes.length; i++) {
let mapped = changes[i].forward.map(tr.changes.partialMapping(refIndex))
refIndex--
if (mapped) {
newChanges.push({forward: mapped, backward: mapped.invert(tr.doc)})
tr.changeNoFilter(mapped, refIndex)
}
}
state = tr.annotate(Transaction.rebasedChanges, changes.length - confirmedTo)
.annotate(Transaction.addToHistory, false).apply()
changes = newChanges
}
for (let ch of " two") dispatch(state.t().replaceSelection(ch))
dispatch(state.t().setSelection(13).replaceSelection("!"))
ist(changes.length, 5)
ist(state.doc.toString(), "one two three!")
// Say the last 3 changes (adding "wo" and "!") are unconfirmed,
// and remote changes come in replacing "three" -> "four"
receive(2, tr => tr.replace(6, 11, "four"))
ist(state.doc.toString(), "one two four!")
// Another remote change, adding " five" after "four"
receive(2, tr => tr.replace(10, 10, " five"))
dispatch(state.t().replace(18, 18, "?").annotate(isolateHistory, "full"))
ist(state.doc.toString(), "one two four five!?")
undo({state, dispatch})
ist(state.doc.toString(), "one two four five!")
undo({state, dispatch})
ist(state.doc.toString(), "one two four five")
// Run through the full undo/redo to verify that still works, but
// leave `state` at two undos
let undone3 = command(state, undo)
ist(undone3.doc.toString(), "one four five")
let redone = command(undone3, redo), redone2 = command(redone, redo), redone3 = command(redone2, redo)
ist(redone.doc.toString(), "one two four five")
ist(redone2.doc.toString(), "one two four five!")
ist(redone3.doc.toString(), "one two four five!?")
receive(3, tr => tr.replace(16, 16, " six"))
ist(state.doc.toString(), "one two four five six")
undo({state, dispatch})
ist(state.doc.toString(), "one four five six")
redo({state, dispatch})
ist(state.doc.toString(), "one two four five six")
redo({state, dispatch})
ist(state.doc.toString(), "one two four five six!")
redo({state, dispatch})
ist(state.doc.toString(), "one two four five six!?")
})
})