Files
multica/packages/views/editor/extensions/markdown-paste.ts
Naiyuan Qing ba17fcf46a fix(editor): open mention and slash pickers only for typed triggers (MUL-5429) (#6072)
* fix(editor): stop pasted @ text from hijacking Enter and Escape (MUL-5429)

Pasting a line containing `@` into the create-issue composer left an empty
mention picker open that swallowed Enter, and Escape then closed the whole
dialog instead of just the picker.

Two independent causes:

1. The Tiptap suggestion plugin re-runs findSuggestionMatch on EVERY
   transaction, including the one that commits a paste, so pasted text
   containing `@` opened the picker. With `allowSpaces` the match runs from
   the `@` to the end of the line, so the entire pasted tail became one query
   that matched nothing — and the empty popup deliberately captures Enter.
   Gate the mention picker with `shouldShow`: skip paste/drop transactions,
   and skip queries longer than any real mention target (60 chars). The
   empty-list Enter capture is intentional and is left alone; the fix stops
   the picker opening instead.

2. ProseMirror calls preventDefault() for a handled key but never stops
   propagation, and Base UI's dismiss layer listens for Escape on `document`
   in the bubble phase without consulting defaultPrevented. So the Escape
   that closed the picker also closed the host dialog. Stop propagation in
   the shared suggestion popup handler — the only layer that knows a picker
   is open — which fixes every host at once.

The slash pickers get the same paste guard so a pasted path no longer opens
the command menu; they were never affected by the Enter capture.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(editor): open mention and slash pickers only for typed triggers (MUL-5429)

Pasting a line containing `@` opened an empty mention picker that swallowed
Enter. The first attempt at this fix aimed at the wrong target and did not
work in the product; this replaces it.

The picker also opened with no paste involved at all: placing the caret at the
end of a line that already contained `@` was enough. Tiptap's Suggestion plugin
is state-derived, not event-driven — it re-runs findSuggestionMatch on EVERY
transaction and asks whether the text before the cursor looks like a trigger,
never whether the user just typed one. Pasted, dropped, undone and
server-loaded text produce an identical document, so it cannot tell them apart.
With allowSpaces the match then runs from the `@` to the end of the line, so
the whole pasted tail became one query that matched nothing, and the empty
popup deliberately captures Enter.

That is upstream's known, unfixed design: ueberdosis/tiptap#4183 (open since
2023, labelled `complexity: hard`, reopened in January after `shouldShow`
proved insufficient) and #7371. Upstream's position is that applications supply
the missing provenance themselves. `shouldShow` alone cannot: it receives the
transaction without `prev.active`, while `allow` receives `prev.active` without
the transaction, so no transaction-inspecting predicate can express "only on
the transaction that opened the picker".

Add a trigger-arming plugin instead. `handleTextInput` is the one ProseMirror
hook that fires for real keyboard and IME input only — paste goes through
handlePaste/doPaste and drop through handleDrop, neither of which reaches it.
Typing a trigger character arms its document position; the pickers' shouldShow
opens only for a match anchored there; a deliberate caret move, or losing the
trigger character, disarms it. This is the same signal Tiptap's own InputRules
run on, which is why typing `- ` makes a list but pasting it does not.
Suggestion is the one Tiptap feature that does not use it.

Also stamp the paste metas markdownPaste was dropping. ProseMirror's doPaste
returns early once a handlePaste prop claims the event, and markdownPaste is a
catch-all that claims nearly every paste, so the transaction that commits a
paste carried no `paste` / `uiEvent` mark. That is why the previous fix never
fired in the product, and it independently broke issueIdentifierAutolink, which
reads exactly that mark: pasting `See MUL-2 now` autolinked nothing, because
the unmarked transaction sent it down the typing path that only inspects the
token before the caret.

Both features' paste tests passed throughout because they hand-built
transactions carrying a meta the real paste path never produces. Every paste
case now fires a real paste event, and typing is routed through
handleTextInput the way readDOMChange does, so a test cannot be green while the
product is broken.

The Escape containment from the previous attempt is unrelated to all of this
and is kept as is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-29 10:43:21 +08:00

506 lines
15 KiB
TypeScript

/**
* Markdown paste extension — ensures pasted text is parsed as Markdown.
*
* Problem: The browser clipboard can contain BOTH text/plain and text/html.
* ProseMirror always prefers text/html when present (hardcoded in
* parseFromClipboard: `let asText = !html`). When copying from VS Code,
* text editors, or .md files, the OS wraps text in <pre>/<div> HTML tags.
* ProseMirror parses these as code blocks — wrong.
*
* Solution: Use `handlePaste` (the only ProseMirror prop that runs for ALL
* paste events and has access to raw ClipboardEvent). We check for
* `data-pm-slice` in the HTML — this attribute is added by ProseMirror's
* own clipboard serializer. If present, the source is another ProseMirror
* editor and its HTML is structurally correct — let ProseMirror handle it.
* Otherwise, classify text/plain into one of three paths:
* - native: let ProseMirror or another extension handle it
* - literal: insert exact text without Markdown parsing
* - markdown: parse text/plain as Markdown
*
* Why not clipboardTextParser? It only runs when there's NO text/html on
* the clipboard (ProseMirror source: `let asText = !!text && !html`).
*
* HTML/text classification is intentionally conservative. Rich semantic HTML
* should stay native so links, lists, emphasis, and inline code survive.
* Syntax-highlight wrappers from editors (<pre>/<code>/<span>/<div>) are not
* enough by themselves, because those should still paste as Markdown source.
*/
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey, type Transaction } from "@tiptap/pm/state";
import type { EditorView } from "@tiptap/pm/view";
import {
Fragment,
Slice,
type Node as ProseMirrorNode,
} from "@tiptap/pm/model";
const LARGE_PASTE_TEXT_THRESHOLD = 50_000;
const SEMANTIC_RICH_HTML_SELECTOR = [
"a[href]",
"b",
"blockquote",
"del",
"details",
"em",
"figcaption",
"figure",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"hr",
"i",
"img",
"li",
"mark",
"ol",
"s",
"strong",
"sub",
"summary",
"sup",
"table",
"tbody",
"td",
"tfoot",
"th",
"thead",
"tr",
"u",
"ul",
].join(",");
const RAW_HTML_TAG_RE = /<(\/?[a-zA-Z][a-zA-Z0-9-]*)(?:\s[^>]*)?\/?>/g;
// CommonMark treats <word> as raw HTML regardless of whether "word" is a real
// HTML element. For plain-text paste, the user's text is the source of truth, so
// escape tag-like runs before the Markdown lexer can classify them as HTML.
function escapeRawHtmlTagsInSegment(segment: string): string {
return segment.replace(
RAW_HTML_TAG_RE,
(match) => match.replaceAll("<", "&lt;").replaceAll(">", "&gt;"),
);
}
function collectRawHtmlTagsInSegment(segment: string): string[] {
return segment.match(RAW_HTML_TAG_RE) ?? [];
}
function escapeTagsOutsideCodeSpans(line: string): string {
const parts: string[] = [];
let i = 0;
while (i < line.length) {
if (line[i] === "`") {
let count = 0;
while (i + count < line.length && line[i + count] === "`") count++;
const delimiter = "`".repeat(count);
const afterOpener = i + count;
let closerIdx = afterOpener;
let found = false;
while (closerIdx <= line.length - count) {
const idx = line.indexOf(delimiter, closerIdx);
if (idx === -1) break;
if (
(idx + count >= line.length || line[idx + count] !== "`") &&
(idx === 0 || line[idx - 1] !== "`")
) {
parts.push(line.slice(i, idx + count));
i = idx + count;
found = true;
break;
}
closerIdx = idx + 1;
}
if (!found) {
parts.push(escapeRawHtmlTagsInSegment(delimiter));
i = afterOpener;
}
continue;
}
const nextBacktick = line.indexOf("`", i);
const end = nextBacktick === -1 ? line.length : nextBacktick;
parts.push(escapeRawHtmlTagsInSegment(line.slice(i, end)));
i = end;
}
return parts.join("");
}
function collectTagsOutsideCodeSpans(line: string): string[] {
const tags: string[] = [];
let i = 0;
while (i < line.length) {
if (line[i] === "`") {
let count = 0;
while (i + count < line.length && line[i + count] === "`") count++;
const delimiter = "`".repeat(count);
const afterOpener = i + count;
let closerIdx = afterOpener;
let found = false;
while (closerIdx <= line.length - count) {
const idx = line.indexOf(delimiter, closerIdx);
if (idx === -1) break;
if (
(idx + count >= line.length || line[idx + count] !== "`") &&
(idx === 0 || line[idx - 1] !== "`")
) {
i = idx + count;
found = true;
break;
}
closerIdx = idx + 1;
}
if (!found) {
i = afterOpener;
}
continue;
}
const nextBacktick = line.indexOf("`", i);
const end = nextBacktick === -1 ? line.length : nextBacktick;
tags.push(...collectRawHtmlTagsInSegment(line.slice(i, end)));
i = end;
}
return tags;
}
export function escapeRawHtmlTagsOutsideCode(text: string): string {
const lines = text.split("\n");
let inFencedBlock = false;
let fenceChar = "";
let fenceLen = 0;
const processed = lines.map((line) => {
const fenceMatch = line.match(/^ {0,3}(`{3,}|~{3,})/);
const fence = fenceMatch?.[1];
if (fence) {
if (!inFencedBlock) {
inFencedBlock = true;
fenceChar = fence.charAt(0);
fenceLen = fence.length;
return line;
}
const isClosingFence =
fence.charAt(0) === fenceChar &&
fence.length >= fenceLen &&
/^ {0,3}(`{3,}|~{3,})[ \t]*$/.test(line);
if (isClosingFence) {
inFencedBlock = false;
return line;
}
}
if (inFencedBlock) return line;
return escapeTagsOutsideCodeSpans(line);
});
return processed.join("\n");
}
function findRawHtmlTagsOutsideCode(text: string): string[] {
const lines = text.split("\n");
const tags: string[] = [];
let inFencedBlock = false;
let fenceChar = "";
let fenceLen = 0;
for (const line of lines) {
const fenceMatch = line.match(/^ {0,3}(`{3,}|~{3,})/);
const fence = fenceMatch?.[1];
if (fence) {
if (!inFencedBlock) {
inFencedBlock = true;
fenceChar = fence.charAt(0);
fenceLen = fence.length;
continue;
}
const isClosingFence =
fence.charAt(0) === fenceChar &&
fence.length >= fenceLen &&
/^ {0,3}(`{3,}|~{3,})[ \t]*$/.test(line);
if (isClosingFence) {
inFencedBlock = false;
continue;
}
}
if (!inFencedBlock) {
tags.push(...collectTagsOutsideCodeSpans(line));
}
}
return tags;
}
type PasteMode = "native" | "literal" | "markdown";
interface PasteClassificationInput {
text: string;
html: string;
hasFiles: boolean;
isInsideCodeBlock: boolean;
}
function isJsonDocumentText(text: string): boolean {
const trimmed = text.trim();
if (!trimmed) return false;
const startsLikeJson =
(trimmed.startsWith("{") && trimmed.endsWith("}")) ||
(trimmed.startsWith("[") && trimmed.endsWith("]"));
if (!startsLikeJson) return false;
try {
JSON.parse(trimmed);
return true;
} catch {
return false;
}
}
function isStructuredPlainText(text: string): boolean {
return isJsonDocumentText(text);
}
function hasRichStyle(style: string): boolean {
const normalized = style.toLowerCase();
return (
/font-weight\s*:\s*(bold|[6-9]00)\b/.test(normalized) ||
/font-style\s*:\s*italic\b/.test(normalized) ||
/text-decoration[^;]*(line-through|underline)/.test(normalized)
);
}
function countOccurrences(text: string, needle: string): number {
if (!needle) return 0;
let count = 0;
let index = text.indexOf(needle);
while (index !== -1) {
count++;
index = text.indexOf(needle, index + needle.length);
}
return count;
}
function htmlPreservesRawTagsFromPlainText(html: string, text: string): boolean {
const tags = findRawHtmlTagsOutsideCode(text);
if (tags.length === 0) return true;
if (typeof DOMParser === "undefined") return false;
const doc = new DOMParser().parseFromString(html, "text/html");
const htmlText = doc.body?.textContent ?? "";
const expectedCounts = new Map<string, number>();
for (const tag of tags) {
expectedCounts.set(tag, (expectedCounts.get(tag) ?? 0) + 1);
}
for (const [tag, expectedCount] of expectedCounts) {
if (countOccurrences(htmlText, tag) < expectedCount) return false;
}
return true;
}
function hasSemanticRichHtml(html: string, text: string): boolean {
if (!html.trim()) return false;
if (typeof DOMParser === "undefined") return false;
if (!htmlPreservesRawTagsFromPlainText(html, text)) return false;
const doc = new DOMParser().parseFromString(html, "text/html");
const { body } = doc;
if (!body) return false;
if (body.querySelector(SEMANTIC_RICH_HTML_SELECTOR)) return true;
// Inline <code> carries meaningful rich-text semantics. A <pre><code> pair
// alone is often just a syntax-highlight wrapper from editors, so keep that
// path available for Markdown parsing.
for (const code of Array.from(body.querySelectorAll("code"))) {
if (!code.closest("pre")) return true;
}
for (const el of Array.from(body.querySelectorAll<HTMLElement>("[style]"))) {
if (hasRichStyle(el.getAttribute("style") ?? "")) return true;
}
return false;
}
function classifyPaste({
text,
html,
hasFiles,
isInsideCodeBlock,
}: PasteClassificationInput): PasteMode {
if (hasFiles) return "native";
if (!text) return "native";
if (isInsideCodeBlock) return "literal";
if (html && html.includes("data-pm-slice")) return "native";
if (html && hasSemanticRichHtml(html, text)) return "native";
if (text.length > LARGE_PASTE_TEXT_THRESHOLD) return "literal";
if (isStructuredPlainText(text)) return "literal";
return "markdown";
}
function canJoinOrderedLists(
left: ProseMirrorNode,
right: ProseMirrorNode,
): boolean {
const leftType = left.attrs.type ?? "1";
const rightType = right.attrs.type ?? "1";
if (
left.type.name !== "orderedList" ||
right.type !== left.type ||
leftType !== rightType
) {
return false;
}
const parsedLeftStart = Number(left.attrs.start);
const parsedRightStart = Number(right.attrs.start);
const leftStart = Number.isFinite(parsedLeftStart) ? parsedLeftStart : 1;
const rightStart = Number.isFinite(parsedRightStart) ? parsedRightStart : 1;
const expectedContinuation = leftStart + left.childCount;
// Some rich-text sources put every visual item in its own <ol> without a
// start value, so the parsed slice becomes adjacent one-item lists that all
// restart at 1. An explicit continuation value is the same structure with a
// better HTML hint. Both forms should become one list in our document model.
return rightStart === 1 || rightStart === expectedContinuation;
}
function repairOrderedListsInFragment(fragment: Fragment): Fragment {
const repaired: ProseMirrorNode[] = [];
fragment.forEach((node) => {
const content = node.isLeaf
? node.content
: repairOrderedListsInFragment(node.content);
const repairedNode = content.eq(node.content) ? node : node.copy(content);
const previous = repaired.at(-1);
if (previous && canJoinOrderedLists(previous, repairedNode)) {
repaired[repaired.length - 1] = previous.copy(
previous.content.append(repairedNode.content),
);
return;
}
repaired.push(repairedNode);
});
return Fragment.fromArray(repaired);
}
function repairFragmentedOrderedLists(slice: Slice): Slice {
const content = repairOrderedListsInFragment(slice.content);
if (content.eq(slice.content)) return slice;
return new Slice(content, slice.openStart, slice.openEnd);
}
/**
* Marks a transaction as the commit of a paste, the way ProseMirror marks its
* own.
*
* `doPaste` stamps `paste` / `uiEvent` on the transaction it dispatches, but it
* returns early once a `handlePaste` prop claims the event — and this extension
* is a catch-all that claims nearly every paste. Anything downstream that asks
* "did this text arrive by paste?" therefore sees an unmarked transaction and
* treats a paste as typing. `issueIdentifierAutolink` reads exactly that, and
* without the mark it only ever inspects the token before the caret, so pasting
* `See MUL-2 now` autolinks nothing (MUL-5429).
*
* ProseMirror's author describes these metas as the contract third-party code
* relies on to tell user events apart, so any custom `handlePaste` that
* dispatches its own transaction owes them.
*/
function dispatchPaste(view: EditorView, tr: Transaction): void {
view.dispatch(tr.setMeta("paste", true).setMeta("uiEvent", "paste"));
}
export function createMarkdownPasteExtension() {
return Extension.create({
name: "markdownPaste",
addProseMirrorPlugins() {
const { editor } = this;
return [
new Plugin({
key: new PluginKey("markdownPaste"),
props: {
handlePaste(view, event, slice) {
if (!editor.markdown) return false;
const clipboard = event.clipboardData;
if (!clipboard) return false;
const text = clipboard.getData("text/plain");
const html = clipboard.getData("text/html");
const { $from } = view.state.selection;
const mode = classifyPaste({
text,
html,
hasFiles: Boolean(clipboard.files?.length),
isInsideCodeBlock: $from.parent.type.name === "codeBlock",
});
if (mode === "native") {
// ProseMirror-owned clipboard HTML already represents our exact
// document structure. Only repair external rich HTML, where
// adjacent <ol> fragments commonly stand for one visual list.
if (html && !html.includes("data-pm-slice")) {
const repaired = repairFragmentedOrderedLists(slice);
if (repaired !== slice) {
dispatchPaste(
view,
view.state.tr.replaceSelection(repaired).scrollIntoView(),
);
return true;
}
}
return false;
}
if (mode === "literal") {
dispatchPaste(view, view.state.tr.insertText(text));
return true;
}
// Everything else (VS Code, text editors, .md files, terminals,
// web pages): parse text/plain as Markdown.
const preprocessed = escapeRawHtmlTagsOutsideCode(text);
const json = editor.markdown.parse(preprocessed);
const node = editor.schema.nodeFromJSON(json);
// Safety net: if parsing still produces an empty doc despite
// non-empty input, fall back to literal insertion.
const first = node.content.firstChild;
const parsedEmpty =
node.content.childCount === 0 ||
(node.content.childCount === 1 &&
first?.type.name === "paragraph" &&
first.content.size === 0);
if (text.trim() && parsedEmpty) {
dispatchPaste(view, view.state.tr.insertText(text));
return true;
}
const parsedSlice = Slice.maxOpen(node.content);
dispatchPaste(view, view.state.tr.replaceSelection(parsedSlice));
return true;
},
},
}),
];
},
});
}