mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-28 22:17:48 +02:00
StarterKit registers Tiptap's Underline extension by default. It maps both `<u>` and `text-decoration: underline` to an underline mark and serializes that mark as `++text++` — a syntax neither CommonMark nor GFM defines. The display layer (ReadonlyContent: react-markdown + remark-gfm) has no rule for it, so a saved comment rendered the delimiters literally. Disable the extension in the shared StarterKit config rather than filtering `u` out of the rich-HTML paste selector: the mark is also reachable via the extension's own Mod-u shortcut, so a paste-only fix would leave Cmd+U able to produce the same unrenderable Markdown. Removing the extension also removes its `++text++` markdown tokenizer, so existing text containing `++` now round-trips as literal text instead of being silently reinterpreted as an underline mark. This is shared editor config: it applies to every ContentEditor surface (issue comments, issue descriptions, chat, agent prompts), not just the comment box. No toolbar control, command, or stylesheet referenced the mark. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai>
133 lines
4.5 KiB
TypeScript
133 lines
4.5 KiB
TypeScript
import { afterEach, describe, expect, it } from "vitest";
|
|
import { Editor } from "@tiptap/core";
|
|
import { createEditorExtensions } from ".";
|
|
|
|
/**
|
|
* Underline has no Markdown representation. Tiptap's Underline extension
|
|
* serializes the mark as `++text++`, which neither CommonMark nor GFM defines,
|
|
* so ReadonlyContent (react-markdown + remark-gfm) renders the delimiters
|
|
* literally and the user sees `++text++` in a saved comment.
|
|
*
|
|
* StarterKit registers Underline by default, and it maps BOTH `<u>` and
|
|
* `text-decoration: underline` to the mark, so rich-text paste was enough to
|
|
* produce unrenderable Markdown. These tests pin the mark out of the schema.
|
|
*/
|
|
|
|
let editor: Editor | null = null;
|
|
|
|
afterEach(() => {
|
|
editor?.destroy();
|
|
editor = null;
|
|
document.body.innerHTML = "";
|
|
});
|
|
|
|
function makeProductionEditor(): Editor {
|
|
const element = document.createElement("div");
|
|
document.body.appendChild(element);
|
|
|
|
return new Editor({
|
|
element,
|
|
extensions: createEditorExtensions({
|
|
placeholder: "",
|
|
disableMentions: true,
|
|
enableSlashCommands: false,
|
|
onUploadFileRef: { current: undefined },
|
|
}),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Dispatches a real `paste` event at the editor DOM, which is where
|
|
* ProseMirror binds its own paste handler. That runs the genuine production
|
|
* chain — clipboard parse against the editor schema, then the handlePaste
|
|
* props — rather than a reimplementation of it.
|
|
*
|
|
* The path exercised here is the native one: markdownPaste classifies semantic
|
|
* rich HTML (`<u>`, styled spans) as "native" and declines it, so ProseMirror
|
|
* parses the HTML against the schema, which is exactly where a mark that no
|
|
* registered extension claims gets dropped.
|
|
*
|
|
* `bubbles: false` keeps ProseMirror's eventBelongsToView check trivially
|
|
* satisfied; it does not affect its own listener on view.dom.
|
|
*/
|
|
function paste(ed: Editor, text: string, html: string): void {
|
|
const event = new Event("paste", { bubbles: false, cancelable: true });
|
|
Object.defineProperty(event, "clipboardData", {
|
|
value: {
|
|
files: [],
|
|
getData: (type: string) =>
|
|
type === "text/plain" ? text : type === "text/html" ? html : "",
|
|
},
|
|
});
|
|
ed.view.dom.dispatchEvent(event);
|
|
}
|
|
|
|
describe("underline is not in the editor schema", () => {
|
|
it("registers no underline mark, so Cmd+U cannot produce one", () => {
|
|
editor = makeProductionEditor();
|
|
|
|
// The mark type backs both the Mod-u shortcut and HTML parsing. Absent
|
|
// from the schema, neither entry point can create `++text++`.
|
|
expect(editor.schema.marks.underline).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("pasting underlined rich text", () => {
|
|
it("keeps the text of a pasted <u> tag and writes no ++ delimiters", () => {
|
|
editor = makeProductionEditor();
|
|
|
|
paste(
|
|
editor,
|
|
"before underlined after",
|
|
'<meta charset="utf-8"><p>before <u>underlined</u> after</p>',
|
|
);
|
|
|
|
expect(editor.getText()).toContain("underlined");
|
|
expect(editor.getMarkdown()).toContain("underlined");
|
|
expect(editor.getMarkdown()).not.toContain("++");
|
|
});
|
|
|
|
it("keeps the text of a pasted text-decoration: underline span and writes no ++ delimiters", () => {
|
|
editor = makeProductionEditor();
|
|
|
|
paste(
|
|
editor,
|
|
"before underlined after",
|
|
'<meta charset="utf-8"><p>before <span style="text-decoration: underline">underlined</span> after</p>',
|
|
);
|
|
|
|
expect(editor.getText()).toContain("underlined");
|
|
expect(editor.getMarkdown()).toContain("underlined");
|
|
expect(editor.getMarkdown()).not.toContain("++");
|
|
});
|
|
|
|
it("preserves sibling formatting when dropping the underline mark", () => {
|
|
editor = makeProductionEditor();
|
|
|
|
paste(
|
|
editor,
|
|
"bold underlined",
|
|
'<meta charset="utf-8"><p><strong>bold</strong> <u>underlined</u></p>',
|
|
);
|
|
|
|
const markdown = editor.getMarkdown();
|
|
expect(markdown).toContain("**bold**");
|
|
expect(markdown).toContain("underlined");
|
|
expect(markdown).not.toContain("++");
|
|
});
|
|
});
|
|
|
|
describe("existing content containing ++", () => {
|
|
it("round-trips literal ++ text unchanged", () => {
|
|
// Historical comments already contain `++` (both from this bug and from
|
|
// legitimate text like `g++`). Disabling Underline also removes its
|
|
// markdown tokenizer, so `++x++` is now plain text — editing and re-saving
|
|
// such a comment must not rewrite or re-escape it.
|
|
editor = makeProductionEditor();
|
|
editor.commands.setContent(editor.markdown!.parse("a ++b++ c"));
|
|
|
|
expect(editor.getText()).toBe("a ++b++ c");
|
|
expect(editor.getMarkdown().trim()).toBe("a ++b++ c");
|
|
});
|
|
});
|