Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
271 changes: 268 additions & 3 deletions apps/web/src/composer-block-shortcuts.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { Editor } from "@tiptap/core";
import { Editor, Extension, InputRule, type Extensions } from "@tiptap/core";
import { closeHistory, redo, undo } from "@tiptap/pm/history";
import { splitBlock } from "@tiptap/pm/commands";
import { TextSelection } from "@tiptap/pm/state";
import StarterKit from "@tiptap/starter-kit";
import { describe, expect, it } from "vite-plus/test";
import { describe, expect, it, onTestFinished, vi } from "vite-plus/test";

import {
buildDocJson,
Expand Down Expand Up @@ -32,7 +34,7 @@ function chord(spec: string) {
} as unknown as KeyboardEvent;
}

function press(value: string, spec: string) {
function createEditor(value: string, extra: Extensions = []) {
const editor = new Editor({
extensions: [
StarterKit.configure({
Expand All @@ -50,10 +52,27 @@ function press(value: string, spec: string) {
...ComposerListExtensions,
ComposerTaskListExtension,
ComposerTaskItemExtension,
...extra,
],
content: buildDocJson(value, (name) => ({ label: name, description: null })),
});
// Headless editors need the plugins installed explicitly because there is no mounted view.
editor.view.updateState(editor.state.reconfigure({ plugins: editor.extensionManager.plugins }));
editor.view.dispatch(editor.state.tr.setSelection(TextSelection.atEnd(editor.state.doc)));
onTestFinished(() => editor.destroy());
return editor;
}

function expectViewMatchesMarkdown(editor: Editor) {
const markdown = serializeEditorDoc(editor.state.doc).value;
const rebuilt = editor.schema.nodeFromJSON(
buildDocJson(markdown, (name) => ({ label: name, description: null })),
);
expect(editor.state.doc.toString()).toBe(rebuilt.toString());
}

function press(value: string, spec: string) {
const editor = createEditor(value);
const event = chord(spec);
const before = editor.state.doc;
const handled = editor.extensionManager.plugins.some((plugin) =>
Expand Down Expand Up @@ -92,3 +111,249 @@ describe("Tiptap's block shortcuts", () => {
expect(result.value).toBe(value);
});
});

describe("composer list normalization", () => {
it("converts a dash edited on an earlier line and keeps the view equal to Markdown", () => {
const editor = createEditor("");
editor.view.dispatch(editor.state.tr.insertText("-get md"));
splitBlock(editor.state, editor.view.dispatch);
editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, 2)));
editor.view.dispatch(editor.state.tr.insertText(" "));
editor.view.dispatch(editor.state.tr.setSelection(TextSelection.atEnd(editor.state.doc)));

expect(editor.state.doc.firstChild?.type.name).toBe("bulletList");
expect(editor.state.doc.firstChild?.firstChild?.textContent).toBe("get md");
expect(serializeEditorDoc(editor.state.doc).value).toBe("- get md\n");
expectViewMatchesMarkdown(editor);
});

it.each([
["- a\nb\n- c", "- "],
["* a\nb", "* "],
["1. a\nb", "2. "],
["- [ ] a\nb", "- [ ] "],
])("joins the converted line into the neighbouring list in %j", (value, marker) => {
const editor = createEditor(value);
let paragraph = 0;
editor.state.doc.forEach((node, pos) => {
if (node.type.name === "paragraph") paragraph = pos + 1;
});
editor.view.dispatch(editor.state.tr.insertText(marker, paragraph));
expect(editor.state.doc.childCount).toBe(1);
expectViewMatchesMarkdown(editor);
});

it("keeps a converted line apart from a list with another marker", () => {
const editor = createEditor("* a\nb");
editor.view.dispatch(
editor.state.tr.insertText("- ", editor.state.doc.firstChild!.nodeSize + 1),
);
expect(editor.state.doc.childCount).toBe(2);
expectViewMatchesMarkdown(editor);
});

it.each(["[link](url)", "[ordinary text]", "[x]text"])(
"converts a dash before existing bracket-prefixed text %j",
(text) => {
const editor = createEditor(text);
editor.view.dispatch(editor.state.tr.insertText("- ", 1));
expect(editor.state.doc.firstChild?.type.name).toBe("bulletList");
expect(editor.state.doc.firstChild?.firstChild?.textContent).toBe(text);
expect(serializeEditorDoc(editor.state.doc).value).toBe(`- ${text}`);
},
);

it.each(["- ", "* ", "+ ", "3) ", "4. ", "- [ ] ", "- [x] "])(
"converts %j inserted in front of existing text",
(marker) => {
const editor = createEditor("existing text");
editor.view.dispatch(editor.state.tr.insertText(marker, 1));
const list = editor.state.doc.firstChild!;
expect(list.type.name).toBe(
marker.includes("[") ? "taskList" : /^\d/.test(marker) ? "orderedList" : "bulletList",
);
expect(list.firstChild?.textContent).toBe("existing text");
expect(serializeEditorDoc(editor.state.doc).value).toBe(`${marker}existing text`);
},
);

it.each(["a - b", "-x", "-"])("leaves %j as a paragraph", (text) => {
const editor = createEditor("");
editor.view.dispatch(editor.state.tr.insertText(text));
expect(editor.state.doc.firstChild?.type.name).toBe("paragraph");
expect(editor.state.doc.textContent).toBe(text);
});

it("undoes the insertion and conversion together and can redo them", () => {
const editor = createEditor("existing text");
editor.view.dispatch(closeHistory(editor.state.tr).insertText("- ", 1));
expect(editor.state.doc.firstChild?.type.name).toBe("bulletList");
expect(undo(editor.state, editor.view.dispatch)).toBe(true);
expect(editor.state.doc.firstChild?.type.name).toBe("paragraph");
expect(editor.state.doc.textContent).toBe("existing text");
expect(redo(editor.state, editor.view.dispatch)).toBe(true);
expect(editor.state.doc.firstChild?.type.name).toBe("bulletList");
});

it("keeps the dash gesture available until a task marker is complete", () => {
const editor = createEditor("");
for (const ch of "- [x]") {
editor.view.dispatch(editor.state.tr.insertText(ch));
expect(editor.state.doc.firstChild?.type.name).toBe("paragraph");
}
editor.view.dispatch(editor.state.tr.insertText(" "));
expect(editor.state.doc.firstChild?.type.name).toBe("taskList");
expect(editor.state.doc.firstChild?.firstChild?.attrs.checked).toBe(true);
});

it.each(["```\ncode\n```", "- outer\n - inner", "> quoted"])(
"does not convert paragraphs inside %j",
(value) => {
const editor = createEditor(value);
let position = 0;
editor.state.doc.descendants((node, pos) => {
if (node.isText) position = pos;
});
const before: string[] = [];
editor.state.doc.descendants((node) => {
before.push(node.type.name);
});
editor.view.dispatch(editor.state.tr.insertText("- ", position));
const after: string[] = [];
editor.state.doc.descendants((node) => {
after.push(node.type.name);
});
expect(after).toEqual(before);
expect(editor.state.doc.textContent).toContain("- ");
},
);

it("waits for a non-composition transaction before changing paragraph structure", () => {
const editor = createEditor("");
editor.view.dispatch(editor.state.tr.insertText("- text").setMeta("composition", 1));
expect(editor.state.doc.firstChild?.type.name).toBe("paragraph");
editor.view.dispatch(editor.state.tr.insertText("日本語", 3, 7).setMeta("composition", 1));
expect(editor.state.doc.firstChild?.type.name).toBe("paragraph");
editor.view.dispatch(editor.state.tr.insertText("確定", 3, 6).setMeta("composition", 1));
expect(editor.state.doc.firstChild?.type.name).toBe("paragraph");
vi.useFakeTimers();
onTestFinished(() => {
vi.useRealTimers();
});
for (const plugin of editor.state.plugins) {
plugin.props.handleDOMEvents?.compositionend?.call(
plugin,
editor.view,
new Event("compositionend") as CompositionEvent,
);
}
vi.runAllTimers();
expect(editor.state.doc.firstChild?.type.name).toBe("bulletList");
expect(serializeEditorDoc(editor.state.doc).value).toBe("- 確定");
expect(undo(editor.state, editor.view.dispatch)).toBe(true);
expect(editor.state.doc.textContent).toBe("");
expect(redo(editor.state, editor.view.dispatch)).toBe(true);
expect(editor.state.doc.firstChild?.type.name).toBe("bulletList");
});

it("does not immediately reconvert a literal marker restored by undo", () => {
const editor = createEditor("");
editor.view.dispatch(editor.state.tr.insertText("- "));
expect(editor.state.doc.firstChild?.type.name).toBe("paragraph");
editor.view.dispatch(closeHistory(editor.state.tr).insertText("text"));
expect(editor.state.doc.firstChild?.type.name).toBe("bulletList");
expect(undo(editor.state, editor.view.dispatch)).toBe(true);
expect(editor.state.doc.firstChild?.type.name).toBe("paragraph");
expect(editor.state.doc.textContent).toBe("- ");
editor.view.dispatch(editor.state.tr.setSelection(TextSelection.atStart(editor.state.doc)));
expect(editor.state.doc.firstChild?.type.name).toBe("paragraph");
});

it("keeps the literal marker that Backspace restores from a list input rule", () => {
// Mirrors the composer's `- ` + first character rule; the test editor keeps Tiptap's `- ` rule.
const dashRule = Extension.create({
name: "dashRule",
addInputRules() {
return [
new InputRule({
find: /^- (?<carried>\S)$/,
handler: ({ range, match, chain }) => {
chain()
.deleteRange(range)
.wrapInList("bulletList")
.command(({ tr }) => {
tr.insertText(match.groups?.carried ?? "");
return true;
})
.run();
},
}),
];
},
});
const editor = createEditor("", [dashRule]);
const type = (text: string) => {
const { from, to } = editor.state.selection;
const insert = () => editor.state.tr.insertText(text, from, to);
const handled = editor.state.plugins.some((plugin) =>
plugin.props.handleTextInput?.call(plugin, editor.view, from, to, text, insert),
);
if (!handled) editor.view.dispatch(editor.state.tr.insertText(text, from, to));
};
editor.view.dispatch(editor.state.tr.insertText("- "));
type("g");
expect(editor.state.doc.firstChild?.type.name).toBe("bulletList");
const backspace = chord("Backspace");
expect(
editor.state.plugins.some((plugin) =>
plugin.props.handleKeyDown?.call(plugin, editor.view, backspace),
),
).toBe(true);
expect(editor.state.doc.firstChild?.type.name).toBe("paragraph");
expect(editor.state.doc.textContent).toBe("- g");
type("o");
expect(editor.state.doc.firstChild?.type.name).toBe("paragraph");
expect(editor.state.doc.textContent).toBe("- go");
});

it("preserves formatting and the caret when removing the literal prefix", () => {
const editor = createEditor("**text**");
editor.view.dispatch(editor.state.tr.insert(1, editor.schema.text("- ")));
expect(editor.state.selection.$from.parent.type.name).toBe("paragraph");
expect(editor.state.selection.$from.parentOffset).toBe(4);
expect(serializeEditorDoc(editor.state.doc).value).toBe("- **text**");
});

it("does not turn an inline code marker into a list", () => {
const editor = createEditor("`-text`");
editor.view.dispatch(editor.state.tr.insertText(" ", 2));
expect(editor.state.doc.firstChild?.type.name).toBe("paragraph");
expect(serializeEditorDoc(editor.state.doc).value).toBe("`- text`");
});

it.each(["**- x**", "*-* x", "~~- x~~", "a\n**1.** b"])(
"preserves styled markers when restoring %j",
(value) => {
const editor = createEditor("");
editor.commands.setContent(
buildDocJson(value, (name) => ({ label: name, description: null })),
{ emitUpdate: false },
);
expect(serializeEditorDoc(editor.state.doc).value).toBe(value);
expect(editor.state.doc.lastChild?.type.name).toBe("paragraph");
editor.view.dispatch(editor.state.tr.insertText("x", editor.state.doc.content.size - 1));
expect(editor.state.doc.lastChild?.type.name).toBe("paragraph");
},
);

it("only normalizes changed blocks", () => {
const editor = createEditor("");
editor.view.updateState(editor.state.reconfigure({ plugins: [] }));
editor.view.dispatch(editor.state.tr.insertText("- untouched"));
splitBlock(editor.state, editor.view.dispatch);
editor.view.updateState(editor.state.reconfigure({ plugins: editor.extensionManager.plugins }));
editor.view.dispatch(editor.state.tr.insertText("changed"));
expect(editor.state.doc.firstChild?.type.name).toBe("paragraph");
expect(editor.state.doc.firstChild?.textContent).toBe("- untouched");
});
});
Loading
Loading