Files
multica/packages/views/editor/extensions/markdown-paste.ts
Naiyuan Qing 6a451c1ce7 fix(editor): rewrite bubble menu and link preview with useFloating
Replace Tiptap's BubbleMenu plugin with @floating-ui/react-dom for
all floating editor UI (formatting toolbar, link preview cards).

Architecture:
- useFloating({ strategy:"fixed" }) + createPortal(body) escapes
  all overflow:hidden ancestors (Card component, scroll containers)
- autoUpdate + contextElement monitors all scroll ancestors for
  repositioning; manual update() on transaction for virtual ref changes
- open prop resets isPositioned on visibility change (no stale-position
  flash at 0,0)
- display:none for hiding (not return null which causes blur/focus
  cycle, not visibility:hidden which leaves transition artifacts)
- No blur listener — portal DOM updates cause false editor blurs;
  outside-click + scroll + resize + Escape handle all close cases

Bug fixes:
- BubbleMenu: remove all custom visibility hacks, let selection state
  drive show/hide
- Link preview: new shared card (Copy + Open) for editable editor and
  readonly markdown, portaled to body with fixed positioning
- TitleEditor: use JSON content format (not HTML interpolation that
  loses < > characters)
- Blob URLs: strip from getMarkdown output during upload
- Markdown paste: check clipboard.files first to avoid intercepting
  file paste events
- FileCard: escape HTML attributes in preprocessing
- Link extension: enable linkOnPaste, set defaultProtocol to https,
  switch URL normalization to protocol blocklist (only block
  javascript:/data:/vbscript:)

Dependencies: add @floating-ui/react-dom, remove @tiptap/extension-bubble-menu

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 12:00:17 +08:00

71 lines
2.9 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, ignore the HTML and 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`).
*
* Why not heuristic detection (looksLikeMarkdown / hasRichHtml)? Unreliable.
* VS Code's HTML contains <code> tags that fool rich-content detectors.
* Markdown pattern matching has too many edge cases. The data-pm-slice
* check is deterministic — no false positives.
*/
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { Slice } from "@tiptap/pm/model";
export function createMarkdownPasteExtension() {
return Extension.create({
name: "markdownPaste",
addProseMirrorPlugins() {
const { editor } = this;
return [
new Plugin({
key: new PluginKey("markdownPaste"),
props: {
handlePaste(view, event) {
if (!editor.markdown) return false;
const clipboard = event.clipboardData;
if (!clipboard) return false;
// If clipboard has files, defer to the fileUpload extension.
if (clipboard.files?.length) return false;
const text = clipboard.getData("text/plain");
if (!text) return false;
const html = clipboard.getData("text/html");
// If HTML contains data-pm-slice, the source is another
// ProseMirror editor — let ProseMirror use its native HTML
// clipboard path to preserve exact node structure.
if (html && html.includes("data-pm-slice")) return false;
// Everything else (VS Code, text editors, .md files, terminals,
// web pages): parse text/plain as Markdown.
const json = editor.markdown.parse(text);
const node = editor.schema.nodeFromJSON(json);
const slice = Slice.maxOpen(node.content);
const tr = view.state.tr.replaceSelection(slice);
view.dispatch(tr);
return true;
},
},
}),
];
},
});
}