diff --git a/apps/web/src/composer-block-shortcuts.test.ts b/apps/web/src/composer-block-shortcuts.test.ts index eea43193dd75d..d0f369c4ff8da 100644 --- a/apps/web/src/composer-block-shortcuts.test.ts +++ b/apps/web/src/composer-block-shortcuts.test.ts @@ -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, @@ -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({ @@ -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) => @@ -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: /^- (?\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"); + }); +}); diff --git a/apps/web/src/composer-rich-text-doc.ts b/apps/web/src/composer-rich-text-doc.ts index 417397e88ccc5..6d308cd9226e1 100644 --- a/apps/web/src/composer-rich-text-doc.ts +++ b/apps/web/src/composer-rich-text-doc.ts @@ -18,6 +18,8 @@ import { Heading } from "@tiptap/extension-heading"; import { HorizontalRule } from "@tiptap/extension-horizontal-rule"; import { Italic, starInputRegex, starPasteRegex } from "@tiptap/extension-italic"; import { + combineTransactionSteps, + getChangedRanges, type Command, type Editor, Extension, @@ -26,6 +28,8 @@ import { markPasteRule, mergeAttributes, } from "@tiptap/core"; +import { isHistoryTransaction } from "@tiptap/pm/history"; +import { canJoin, findWrapping } from "@tiptap/pm/transform"; import { BulletList, ListItem, OrderedList } from "@tiptap/extension-list"; import { Strike, inputRegex as strikeInputRegex } from "@tiptap/extension-strike"; import { TaskItem } from "@tiptap/extension-task-item"; @@ -479,6 +483,12 @@ const ComposerListItemExtension = ListItem.extend({ export const ComposerListExtensions = [ BulletList.extend({ + addProseMirrorPlugins() { + return [ + ...(this.parent?.() ?? []), + listNormalizationPlugin(() => this.editor.view.composing), + ]; + }, addKeyboardShortcuts() { return withoutBlockChords(this.parent?.(), ["Mod-Shift-8"]); }, @@ -613,6 +623,145 @@ function parseListPrefix(line: string): { prefix: ListLinePrefix; markerLength: return null; } +/** Only changed top-level paragraphs can acquire a literal list prefix outside an input rule. */ +function listNormalizationPlugin(isComposing: () => boolean) { + let pending = new Set(); + const key = new PluginKey("composerListNormalization"); + return new Plugin({ + key, + props: { + handleDOMEvents: { + // ProseMirror tags the DOM flush after compositionend as composition, so nothing else would normalize. + compositionend(view) { + setTimeout(() => { + if (!view.isDestroyed && !view.composing && pending.size > 0) { + view.dispatch(view.state.tr); + } + }); + return false; + }, + }, + }, + appendTransaction(transactions, oldState, state) { + // Tiptap's undoInputRule (Backspace right after a rule) restores the typed text on purpose. + const undoesInputRule = oldState.plugins.some( + (plugin) => plugin.spec.isInputRules && plugin.getState(oldState), + ); + if (undoesInputRule || transactions.some(isHistoryTransaction)) { + pending.clear(); + return null; + } + if (pending.size === 0 && !transactions.some((tr) => tr.docChanged)) return null; + const changes = combineTransactionSteps(oldState.doc, [...transactions]); + pending = new Set( + [...pending].flatMap((pos) => { + const mapped = changes.mapping.mapResult(pos, 1); + return mapped.deleted ? [] : [mapped.pos]; + }), + ); + for (const { newRange } of getChangedRanges(changes)) { + state.doc.nodesBetween(newRange.from, newRange.to, (node, pos) => { + if (node.type.name !== "paragraph") return false; + const parsed = parseListPrefix(paragraphText(node)); + // Typing after a marker left literal keeps it literal; only an edit reaching the marker converts. + if (parsed && newRange.from <= pos + 1 + parsed.markerLength) pending.add(pos); + return false; + }); + } + // The next non-composition transaction can normalize after the browser finishes its DOM edits. + if (isComposing() || transactions.some((tr) => tr.getMeta("composition") != null)) + return null; + + const tr = state.tr; + for (const pos of [...pending].sort((a, b) => b - a)) { + pending.delete(pos); + if (state.doc.resolve(pos).depth !== 0) continue; + const node = state.doc.nodeAt(pos); + if (node?.type.name !== "paragraph") continue; + const text = paragraphText(node); + const parsed = parseListPrefix(text); + // Input rules only introduce unindented top-level lists; keep the same boundary here. + if (!parsed || parsed.prefix.indent) continue; + const { prefix, markerLength } = parsed; + if ( + Object.values(state.schema.marks).some((mark) => node.rangeHasMark(0, markerLength, mark)) + ) + continue; + const active = state.selection.$from.depth === 1 && state.selection.$from.before(1) === pos; + const content = text.slice(markerLength); + // Keep `- ` and unfinished task markers literal while the user is entering the gesture. + if ( + active && + (prefix.kind === "task" + ? !prefix.contentSpace + : prefix.kind === "bullet" && + prefix.marker === "-" && + (!content || /^\[(?:[ xX]\]?)?$/.test(content))) + ) { + pending.add(pos); + continue; + } + if (prefix.kind !== "task" && !prefix.space) continue; + const listType = + state.schema.nodes[ + prefix.kind === "task" + ? "taskList" + : prefix.kind === "ordered" + ? "orderedList" + : "bulletList" + ]; + if (!listType) continue; + const attrs = + prefix.kind === "ordered" ? { start: Number.parseInt(prefix.marker, 10) || 1 } : {}; + const $from = tr.doc.resolve(pos + 1); + const range = $from.blockRange(); + const wrapping = range && findWrapping(range, listType, attrs); + if (!range || !wrapping) continue; + tr.wrap(range, wrapping); + tr.setNodeMarkup( + pos + 1, + undefined, + prefix.kind === "task" + ? { + checked: prefix.checked, + indent: prefix.indent, + markerSpace: prefix.markerSpace, + contentSpace: prefix.contentSpace, + } + : { indent: prefix.indent, marker: prefix.marker, space: prefix.space }, + ); + tr.delete(pos + 3, pos + 3 + markerLength); + joinCompatibleLists(tr, pos + tr.doc.nodeAt(pos)!.nodeSize); + joinCompatibleLists(tr, pos); + } + pending = new Set([...pending].map((pos) => tr.mapping.map(pos, 1))); + return tr.docChanged ? tr : null; + }, + }); +} + +function paragraphText(node: ProseMirrorNode): string { + return node.textBetween(0, node.content.size, undefined, "\ufffc"); +} + +/** Rebuilding from Markdown merges adjacent lists of one type whose markers share a delimiter. */ +function joinCompatibleLists(tr: Transaction, pos: number): void { + const $pos = tr.doc.resolve(pos); + const before = $pos.nodeBefore; + const after = $pos.nodeAfter; + const delimiter = (list: ProseMirrorNode) => + String(list.firstChild?.attrs.marker ?? "").slice(-1); + if ( + before && + after && + before.type === after.type && + delimiter(before) === delimiter(after) && + canJoin(tr.doc, pos) + ) { + tr.join(pos); + } +} + function listKey(prefix: ListLinePrefix): string { if (prefix.kind === "task") return "task"; if (prefix.kind === "ordered") return `ordered:${prefix.marker.slice(-1)}`;