Skip to content
Source
Sign inSign up

composer-block-shortcuts.test.ts

384 lines15.2 KB

1import { Editor, Extension, InputRule, type Extensions } from "@tiptap/core";
2import { closeHistory, redo, undo } from "@tiptap/pm/history";
3import { splitBlock } from "@tiptap/pm/commands";
4import { TextSelection } from "@tiptap/pm/state";
5import StarterKit from "@tiptap/starter-kit";
6import { describe, expect, it, onTestFinished, vi } from "vite-plus/test";
7​
8import {
9 buildDocJson,
10 ComposerBlockExtensions,
11 ComposerCodeBlockExtension,
12 ComposerListExtensions,
13 ComposerTaskItemExtension,
14 ComposerTaskListExtension,
15 serializeEditorDoc,
16} from "./composer-rich-text-doc";
17​
18const mac = typeof navigator !== "undefined" && /Mac|iP(hone|[oa]d)/.test(navigator.platform);
19​
20function chord(spec: string) {
21 const parts = spec.split("-");
22 const key = parts.pop()!;
23 const has = (name: string) => parts.includes(name);
24 const base = key.length === 1 ? key.toUpperCase() : key;
25 return {
26 key: has("Shift") && /^\d$/.test(key) ? "!@#$%^&*("[Number(key) - 1]! : key,
27 keyCode: base.length === 1 ? base.charCodeAt(0) : 0,
28 shiftKey: has("Shift"),
29 altKey: has("Alt"),
30 metaKey: has("Mod") && mac,
31 ctrlKey: has("Mod") && !mac,
32 preventDefault() {},
33 stopPropagation() {},
34 } as unknown as KeyboardEvent;
36​
37function createEditor(value: string, extra: Extensions = []) {
38 const editor = new Editor({
39 extensions: [
40 StarterKit.configure({
41 blockquote: false,
42 bulletList: false,
43 codeBlock: false,
44 heading: false,
45 horizontalRule: false,
46 listItem: false,
47 orderedList: false,
48 trailingNode: false,
49 }),
50 ComposerCodeBlockExtension,
51 ...ComposerBlockExtensions,
52 ...ComposerListExtensions,
53 ComposerTaskListExtension,
54 ComposerTaskItemExtension,
55 ...extra,
56 ],
57 content: buildDocJson(value, (name) => ({ label: name, description: null })),
58 });
59 // Headless editors need the plugins installed explicitly because there is no mounted view.
60 editor.view.updateState(editor.state.reconfigure({ plugins: editor.extensionManager.plugins }));
61 editor.view.dispatch(editor.state.tr.setSelection(TextSelection.atEnd(editor.state.doc)));
62 onTestFinished(() => editor.destroy());
63 return editor;
65​
66function expectViewMatchesMarkdown(editor: Editor) {
67 const markdown = serializeEditorDoc(editor.state.doc).value;
68 const rebuilt = editor.schema.nodeFromJSON(
69 buildDocJson(markdown, (name) => ({ label: name, description: null })),
70 );
71 expect(editor.state.doc.toString()).toBe(rebuilt.toString());
73​
74function press(value: string, spec: string) {
75 const editor = createEditor(value);
76 const event = chord(spec);
77 const before = editor.state.doc;
78 const handled = editor.extensionManager.plugins.some((plugin) =>
79 plugin.props.handleKeyDown?.call(plugin, editor.view, event),
80 );
81 return {
82 handled,
83 changed: !editor.state.doc.eq(before),
84 value: serializeEditorDoc(editor.state.doc).value,
85 };
87​
88const BLOCK_CHORDS = [
89 "Mod-Shift-8",
90 "Mod-Shift-7",
91 "Mod-Shift-9",
92 "Mod-Shift-b",
93 "Mod-Alt-c",
94 "Mod-Alt-1",
95 "Mod-Alt-3",
96];
97​
98describe("Tiptap's block shortcuts", () => {
99 it.each(BLOCK_CHORDS)("leave a quote's text alone on %s", (spec) => {
100 expect(press("> keep this text", spec).value).toBe("> keep this text");
101 });
102​
103 it.each([
104 ["- p\n- a", "Tab"],
105 ["- p\n - a", "Shift-Tab"],
106 ["- [ ] p\n- [ ] a", "Tab"],
107 ["- [ ] p\n - [ ] a", "Shift-Tab"],
108 ])("leave the nesting of %j alone on %s", (value, spec) => {
109 const result = press(value, spec);
110 expect(result.changed).toBe(false);
111 expect(result.value).toBe(value);
112 });
113});
114​
115describe("composer list normalization", () => {
116 it("converts a dash edited on an earlier line and keeps the view equal to Markdown", () => {
117 const editor = createEditor("");
118 editor.view.dispatch(editor.state.tr.insertText("-get md"));
119 splitBlock(editor.state, editor.view.dispatch);
120 editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, 2)));
121 editor.view.dispatch(editor.state.tr.insertText(" "));
122 editor.view.dispatch(editor.state.tr.setSelection(TextSelection.atEnd(editor.state.doc)));
123​
124 expect(editor.state.doc.firstChild?.type.name).toBe("bulletList");
125 expect(editor.state.doc.firstChild?.firstChild?.textContent).toBe("get md");
126 expect(serializeEditorDoc(editor.state.doc).value).toBe("- get md\n");
127 expectViewMatchesMarkdown(editor);
128 });
129​
130 it.each([
131 ["- a\nb\n- c", "- "],
132 ["* a\nb", "* "],
133 ["1. a\nb", "2. "],
134 ["- [ ] a\nb", "- [ ] "],
135 ])("joins the converted line into the neighbouring list in %j", (value, marker) => {
136 const editor = createEditor(value);
137 let paragraph = 0;
138 editor.state.doc.forEach((node, pos) => {
139 if (node.type.name === "paragraph") paragraph = pos + 1;
140 });
141 editor.view.dispatch(editor.state.tr.insertText(marker, paragraph));
142 expect(editor.state.doc.childCount).toBe(1);
143 expectViewMatchesMarkdown(editor);
144 });
145​
146 it("keeps a converted line apart from a list with another marker", () => {
147 const editor = createEditor("* a\nb");
148 editor.view.dispatch(
149 editor.state.tr.insertText("- ", editor.state.doc.firstChild!.nodeSize + 1),
150 );
151 expect(editor.state.doc.childCount).toBe(2);
152 expectViewMatchesMarkdown(editor);
153 });
154​
155 it.each(["[link](url)", "[ordinary text]", "[x]text"])(
156 "converts a dash before existing bracket-prefixed text %j",
157 (text) => {
158 const editor = createEditor(text);
159 editor.view.dispatch(editor.state.tr.insertText("- ", 1));
160 expect(editor.state.doc.firstChild?.type.name).toBe("bulletList");
161 expect(editor.state.doc.firstChild?.firstChild?.textContent).toBe(text);
162 expect(serializeEditorDoc(editor.state.doc).value).toBe(`- ${text}`);
163 },
164 );
165​
166 it.each(["- ", "* ", "+ ", "3) ", "4. ", "- [ ] ", "- [x] "])(
167 "converts %j inserted in front of existing text",
168 (marker) => {
169 const editor = createEditor("existing text");
170 editor.view.dispatch(editor.state.tr.insertText(marker, 1));
171 const list = editor.state.doc.firstChild!;
172 expect(list.type.name).toBe(
173 marker.includes("[") ? "taskList" : /^\d/.test(marker) ? "orderedList" : "bulletList",
174 );
175 expect(list.firstChild?.textContent).toBe("existing text");
176 expect(serializeEditorDoc(editor.state.doc).value).toBe(`${marker}existing text`);
177 },
178 );
179​
180 it.each(["a - b", "-x", "-"])("leaves %j as a paragraph", (text) => {
181 const editor = createEditor("");
182 editor.view.dispatch(editor.state.tr.insertText(text));
183 expect(editor.state.doc.firstChild?.type.name).toBe("paragraph");
184 expect(editor.state.doc.textContent).toBe(text);
185 });
186​
187 it("undoes the insertion and conversion together and can redo them", () => {
188 const editor = createEditor("existing text");
189 editor.view.dispatch(closeHistory(editor.state.tr).insertText("- ", 1));
190 expect(editor.state.doc.firstChild?.type.name).toBe("bulletList");
191 expect(undo(editor.state, editor.view.dispatch)).toBe(true);
192 expect(editor.state.doc.firstChild?.type.name).toBe("paragraph");
193 expect(editor.state.doc.textContent).toBe("existing text");
194 expect(redo(editor.state, editor.view.dispatch)).toBe(true);
195 expect(editor.state.doc.firstChild?.type.name).toBe("bulletList");
196 });
197​
198 it("keeps the dash gesture available until a task marker is complete", () => {
199 const editor = createEditor("");
200 for (const ch of "- [x]") {
201 editor.view.dispatch(editor.state.tr.insertText(ch));
202 expect(editor.state.doc.firstChild?.type.name).toBe("paragraph");
203 }
204 editor.view.dispatch(editor.state.tr.insertText(" "));
205 expect(editor.state.doc.firstChild?.type.name).toBe("taskList");
206 expect(editor.state.doc.firstChild?.firstChild?.attrs.checked).toBe(true);
207 });
208​
209 it.each(["```\ncode\n```", "- outer\n - inner", "> quoted"])(
210 "does not convert paragraphs inside %j",
211 (value) => {
212 const editor = createEditor(value);
213 let position = 0;
214 editor.state.doc.descendants((node, pos) => {
215 if (node.isText) position = pos;
216 });
217 const before: string[] = [];
218 editor.state.doc.descendants((node) => {
219 before.push(node.type.name);
220 });
221 editor.view.dispatch(editor.state.tr.insertText("- ", position));
222 const after: string[] = [];
223 editor.state.doc.descendants((node) => {
224 after.push(node.type.name);
225 });
226 expect(after).toEqual(before);
227 expect(editor.state.doc.textContent).toContain("- ");
228 },
229 );
230​
231 it("waits for a non-composition transaction before changing paragraph structure", () => {
232 const editor = createEditor("");
233 editor.view.dispatch(editor.state.tr.insertText("- text").setMeta("composition", 1));
234 expect(editor.state.doc.firstChild?.type.name).toBe("paragraph");
235 editor.view.dispatch(editor.state.tr.insertText("日本語", 3, 7).setMeta("composition", 1));
236 expect(editor.state.doc.firstChild?.type.name).toBe("paragraph");
237 editor.view.dispatch(editor.state.tr.insertText("確定", 3, 6).setMeta("composition", 1));
238 expect(editor.state.doc.firstChild?.type.name).toBe("paragraph");
239 vi.useFakeTimers();
240 onTestFinished(() => {
241 vi.useRealTimers();
242 });
243 for (const plugin of editor.state.plugins) {
244 plugin.props.handleDOMEvents?.compositionend?.call(
245 plugin,
246 editor.view,
247 new Event("compositionend") as CompositionEvent,
248 );
249 }
250 vi.runAllTimers();
251 expect(editor.state.doc.firstChild?.type.name).toBe("bulletList");
252 expect(serializeEditorDoc(editor.state.doc).value).toBe("- 確定");
253 expect(undo(editor.state, editor.view.dispatch)).toBe(true);
254 expect(editor.state.doc.textContent).toBe("");
255 expect(redo(editor.state, editor.view.dispatch)).toBe(true);
256 expect(editor.state.doc.firstChild?.type.name).toBe("bulletList");
257 });
258​
259 it("does not immediately reconvert a literal marker restored by undo", () => {
260 const editor = createEditor("");
261 editor.view.dispatch(editor.state.tr.insertText("- "));
262 expect(editor.state.doc.firstChild?.type.name).toBe("paragraph");
263 editor.view.dispatch(closeHistory(editor.state.tr).insertText("text"));
264 expect(editor.state.doc.firstChild?.type.name).toBe("bulletList");
265 expect(undo(editor.state, editor.view.dispatch)).toBe(true);
266 expect(editor.state.doc.firstChild?.type.name).toBe("paragraph");
267 expect(editor.state.doc.textContent).toBe("- ");
268 editor.view.dispatch(editor.state.tr.setSelection(TextSelection.atStart(editor.state.doc)));
269 expect(editor.state.doc.firstChild?.type.name).toBe("paragraph");
270 });
271​
272 it("keeps the literal marker that Backspace restores from a list input rule", () => {
273 // Mirrors the composer's `- ` + first character rule; the test editor keeps Tiptap's `- ` rule.
274 const dashRule = Extension.create({
275 name: "dashRule",
276 addInputRules() {
277 return [
278 new InputRule({
279 find: /^- (?<carried>\S)$/,
280 handler: ({ range, match, chain }) => {
281 chain()
282 .deleteRange(range)
283 .wrapInList("bulletList")
284 .command(({ tr }) => {
285 tr.insertText(match.groups?.carried ?? "");
286 return true;
287 })
288 .run();
289 },
290 }),
291 ];
292 },
293 });
294 const editor = createEditor("", [dashRule]);
295 const type = (text: string) => {
296 const { from, to } = editor.state.selection;
297 const insert = () => editor.state.tr.insertText(text, from, to);
298 const handled = editor.state.plugins.some((plugin) =>
299 plugin.props.handleTextInput?.call(plugin, editor.view, from, to, text, insert),
300 );
301 if (!handled) editor.view.dispatch(editor.state.tr.insertText(text, from, to));
302 };
303 editor.view.dispatch(editor.state.tr.insertText("- "));
304 type("g");
305 expect(editor.state.doc.firstChild?.type.name).toBe("bulletList");
306 const backspace = chord("Backspace");
307 expect(
308 editor.state.plugins.some((plugin) =>
309 plugin.props.handleKeyDown?.call(plugin, editor.view, backspace),
310 ),
311 ).toBe(true);
312 expect(editor.state.doc.firstChild?.type.name).toBe("paragraph");
313 expect(editor.state.doc.textContent).toBe("- g");
314 type("o");
315 expect(editor.state.doc.firstChild?.type.name).toBe("paragraph");
316 expect(editor.state.doc.textContent).toBe("- go");
317 });
318​
319 it.each([
320 ["+ ", "bulletList"],
321 ["* ", "bulletList"],
322 ["1. ", "orderedList"],
323 ])("keeps the typed %j when Backspace follows its list shortcut", (marker, list) => {
324 const editor = createEditor("");
325 for (const char of marker) {
326 const { from, to } = editor.state.selection;
327 const insert = () => editor.state.tr.insertText(char, from, to);
328 const handled = editor.state.plugins.some((plugin) =>
329 plugin.props.handleTextInput?.call(plugin, editor.view, from, to, char, insert),
330 );
331 if (!handled) editor.view.dispatch(editor.state.tr.insertText(char, from, to));
332 }
333 expect(editor.state.doc.firstChild?.type.name).toBe(list);
334 const backspace = chord("Backspace");
335 expect(
336 editor.state.plugins.some((plugin) =>
337 plugin.props.handleKeyDown?.call(plugin, editor.view, backspace),
338 ),
339 ).toBe(true);
340 expect(editor.state.doc.firstChild?.type.name).toBe("paragraph");
341 expect(editor.state.doc.textContent).toBe(marker);
342 });
343​
344 it("preserves formatting and the caret when removing the literal prefix", () => {
345 const editor = createEditor("**text**");
346 editor.view.dispatch(editor.state.tr.insert(1, editor.schema.text("- ")));
347 expect(editor.state.selection.$from.parent.type.name).toBe("paragraph");
348 expect(editor.state.selection.$from.parentOffset).toBe(4);
349 expect(serializeEditorDoc(editor.state.doc).value).toBe("- **text**");
350 });
351​
352 it("does not turn an inline code marker into a list", () => {
353 const editor = createEditor("`-text`");
354 editor.view.dispatch(editor.state.tr.insertText(" ", 2));
355 expect(editor.state.doc.firstChild?.type.name).toBe("paragraph");
356 expect(serializeEditorDoc(editor.state.doc).value).toBe("`- text`");
357 });
358​
359 it.each(["**- x**", "*-* x", "~~- x~~", "a\n**1.** b"])(
360 "preserves styled markers when restoring %j",
361 (value) => {
362 const editor = createEditor("");
363 editor.commands.setContent(
364 buildDocJson(value, (name) => ({ label: name, description: null })),
365 { emitUpdate: false },
366 );
367 expect(serializeEditorDoc(editor.state.doc).value).toBe(value);
368 expect(editor.state.doc.lastChild?.type.name).toBe("paragraph");
369 editor.view.dispatch(editor.state.tr.insertText("x", editor.state.doc.content.size - 1));
370 expect(editor.state.doc.lastChild?.type.name).toBe("paragraph");
371 },
372 );
373​
374 it("only normalizes changed blocks", () => {
375 const editor = createEditor("");
376 editor.view.updateState(editor.state.reconfigure({ plugins: [] }));
377 editor.view.dispatch(editor.state.tr.insertText("- untouched"));
378 splitBlock(editor.state, editor.view.dispatch);
379 editor.view.updateState(editor.state.reconfigure({ plugins: editor.extensionManager.plugins }));
380 editor.view.dispatch(editor.state.tr.insertText("changed"));
381 expect(editor.state.doc.firstChild?.type.name).toBe("paragraph");
382 expect(editor.state.doc.firstChild?.textContent).toBe("- untouched");
383 });
384});