import { Plugin } from 'prosemirror-state'; import { Slice, Fragment, Node as PMNode, Schema } from 'prosemirror-model'; import { markdownToHtml, looksLikeMarkdown } from '../markdown-parse'; import { looksLikeHtml } from '../html-detect'; import { htmlToDoc, htmlToSlice } from '../serializer'; import { NormalizedAllowedStyles } from '../email-css'; /** * Builds a slice of plain paragraphs from text, splitting on newlines. Open * depth 1 on both ends lets the first/last block merge into the surrounding * paragraph, matching how a normal plain-text paste behaves. */ export function plainTextSlice(schema: Schema, text: string): Slice { const blocks = text.split(/\r\n?|\n/); const nodes: PMNode[] = blocks.map(line => schema.nodes.paragraph.create(null, line ? schema.text(line) : null) ); return new Slice(Fragment.from(nodes), 1, 1); } /** Reduces a pasted slice to unstyled text, preserving block breaks. */ export function stripSliceFormatting(schema: Schema, slice: Slice): Slice { const text = slice.content.textBetween(0, slice.content.size, '\n', '\n'); return plainTextSlice(schema, text); } export interface PasteOptions { /** * Read live: when it returns true, every paste drops formatting (the * persistent "paste as plain text" toggle). The Ctrl+Shift+V shortcut is a * separate one-shot handled in the keymap. */ getForcePlainText?: () => boolean; /** * Read live: when false, plain-text pastes are never parsed as Markdown. * Defaults to on. */ getMarkdownPaste?: () => boolean; /** Read live: whether pasted HTML is sanitized. Defaults to on. */ getSanitize?: () => boolean; /** Read live: when true, HTML-looking plain text is parsed as HTML (email fidelity). */ getEmailFidelity?: () => boolean; /** Read live: the validated per-app email-fidelity style allowlist. */ getAllowedStyles?: () => NormalizedAllowedStyles; } /** * Paste behavior: plain-text pastes that look like Markdown are parsed into * rich content (headings, lists, code fences, tables, inline marks), and all * pastes drop formatting while the host's plain-text toggle is on. */ export function pastePlugin(schema: Schema, options: PasteOptions = {}): Plugin { return new Plugin({ props: { handlePaste(view, event) { // Only plain-text pastes qualify: an HTML flavor means the source // was already rich, and code blocks take text verbatim. const html = event.clipboardData?.getData('text/html') ?? ''; const text = event.clipboardData?.getData('text/plain') ?? ''; if (html.trim() || !text.trim()) return false; if (view.state.selection.$from.parent.type.spec.code) return false; const forcePlain = options.getForcePlainText?.() ?? false; const sanitize = options.getSanitize?.() ?? true; // Precedence: paste-as-plain-text → HTML detection (fidelity) → Markdown. if (looksLikeHtml(text)) { if (options.getEmailFidelity?.() && !forcePlain) { // Closed slice so the whole document (tables, blocks) lands intact // rather than being opened and merged into the target paragraph. const doc = htmlToDoc(schema, text, sanitize, { emailFidelity: true, allowedStyles: options.getAllowedStyles?.(), }); const slice = new Slice(doc.content, 0, 0); view.dispatch(view.state.tr.replaceSelection(slice).scrollIntoView()); } else { const slice = plainTextSlice(schema, text); view.dispatch(view.state.tr.replaceSelection(slice).scrollIntoView()); } return true; } if (forcePlain) return false; if (options.getMarkdownPaste && !options.getMarkdownPaste()) return false; if (!looksLikeMarkdown(text)) return false; const slice = htmlToSlice(schema, markdownToHtml(text), sanitize); view.dispatch(view.state.tr.replaceSelection(slice).scrollIntoView()); return true; }, transformPasted(slice) { return options.getForcePlainText?.() ? stripSliceFormatting(schema, slice) : slice; }, }, }); }