Skip to content
Source
Sign inSign up

composer-undo-grouping.test.ts

122 lines4.0 KB

1import { getSchema } from "@tiptap/core";
2import { history, undo } from "@tiptap/pm/history";
3import { EditorState, TextSelection, type Transaction } from "@tiptap/pm/state";
4import StarterKit from "@tiptap/starter-kit";
5import { describe, expect, it } from "vite-plus/test";
6​
7import {
8 COMPOSER_UNDO_GROUP_DELAY,
9 type ComposerChangeKind,
10 groupUndoByChangeKind,
11 markAsClipboardEdit,
12} from "./composer-undo-grouping";
13​
14const schema = getSchema([StarterKit]);
15​
16function composer(text = "") {
17 let clock = 0;
18 let previous: ComposerChangeKind | null = null;
19 let state = EditorState.create({
20 schema,
21 doc: schema.node("doc", null, [
22 schema.node("paragraph", null, text ? [schema.text(text)] : []),
23 ]),
24 plugins: [history({ newGroupDelay: COMPOSER_UNDO_GROUP_DELAY })],
25 });
26 const dispatch = (tr: Transaction) => {
27 tr.setTime(clock);
28 previous = groupUndoByChangeKind(tr, previous);
29 state = state.apply(tr);
30 };
31 dispatch(state.tr.setSelection(TextSelection.atEnd(state.doc)));
32 const api = {
33 text: () => state.doc.textContent,
34 wait: (ms: number) => ((clock += ms), api),
35 type: (s: string, gap = 80) => {
36 for (const ch of s) {
37 clock += gap;
38 dispatch(state.tr.insertText(ch));
39 }
40 return api;
41 },
42 backspace: (n: number) => {
43 for (let i = 0; i < n; i += 1) {
44 clock += 80;
45 const at = state.selection.from;
46 dispatch(state.tr.delete(at - 1, at));
47 }
48 return api;
49 },
50 // The custom paste path bypasses ProseMirror's clipboard tagging.
51 paste: (s: string) => {
52 clock += 80;
53 dispatch(markAsClipboardEdit(state.tr.insertText(s), "paste"));
54 return api;
55 },
56 cut: (n: number) => {
57 clock += 80;
58 const at = state.selection.from;
59 dispatch(markAsClipboardEdit(state.tr.delete(at - n, at), "cut"));
60 return api;
61 },
62 rewrite: (next: string) => {
63 clock += 80;
64 dispatch(
65 state.tr.replaceWith(
66 0,
67 state.doc.content.size,
68 schema.node("paragraph", null, [schema.text(next)]),
69 ),
70 );
71 dispatch(state.tr.setSelection(TextSelection.atEnd(state.doc)));
72 return api;
73 },
74 compose: (s: string) => {
75 const start = state.selection.from;
76 for (let i = 1; i <= s.length; i += 1) {
77 clock += 80;
78 dispatch(
79 state.tr.insertText(s.slice(0, i), start, start + i - 1).setMeta("composition", 1),
80 );
81 }
82 return api;
83 },
84 undo: () => (undo(state, dispatch), api),
85 };
86 return api;
88​
89describe("composer undo grouping", () => {
90 it("starts a new step when typing turns into deleting, and back", () => {
91 const editor = composer().type("hello world").backspace(5).type("there");
92 expect(editor.text()).toBe("hello there");
93 expect(editor.undo().text()).toBe("hello ");
94 expect(editor.undo().text()).toBe("hello world");
95 expect(editor.undo().text()).toBe("");
96 });
97​
98 it("gives a store rewrite its own step, and typing after it another", () => {
99 const editor = composer("draft ").type("one").rewrite("draft one @chip ").type("then");
100 expect(editor.undo().text()).toBe("draft one @chip ");
101 expect(editor.undo().text()).toBe("draft one");
102 expect(editor.undo().text()).toBe("draft ");
103 });
104​
105 it("gives a paste its own step", () => {
106 const editor = composer().type("see ").paste("pasted text").type(" ok");
107 expect(editor.undo().text()).toBe("see pasted text");
108 expect(editor.undo().text()).toBe("see ");
109 });
110​
111 it("gives a cut its own step between deletions", () => {
112 const editor = composer().type("hello world").backspace(1).cut(3).backspace(1);
113 expect(editor.text()).toBe("hello ");
114 expect(editor.undo().text()).toBe("hello w");
115 expect(editor.undo().text()).toBe("hello worl");
116 expect(editor.undo().text()).toBe("hello world");
117 });
118​
119 it("keeps IME composition as one run of typing", () => {
120 expect(composer().type("go ").compose("にほん").undo().text()).toBe("");
121 });
122});