/** * 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
/
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 (
///
) are not * enough by themselves, because those should still paste as Markdown source. */ import { Extension } from "@tiptap/core"; import { Plugin, PluginKey } from "@tiptap/pm/state"; import { Slice } 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 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("<", "<").replaceAll(">", ">"), ); } 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(); 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 carries meaningful rich-text semantics. A
 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("[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";
}

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;

              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") return false;

              if (mode === "literal") {
                view.dispatch(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) {
                view.dispatch(view.state.tr.insertText(text));
                return true;
              }

              const slice = Slice.maxOpen(node.content);
              const tr = view.state.tr.replaceSelection(slice);
              view.dispatch(tr);
              return true;
            },
          },
        }),
      ];
    },
  });
}