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() 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 = StateEffect.define({map: mapComment}) let rmComment: StateEffectType = StateEffect.define({map: mapComment}) let comments = StateField.define({ 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!?") }) })