import { docToMarkdown, editorSchema, markdownToDoc } from '@dorfteich/shared'; import { Extension } from '@tiptap/core'; import { Node as ProseMirrorNode } from '@tiptap/pm/model'; import { Plugin } from '@tiptap/pm/state'; const MARKDOWN_LINE_PATTERNS = [ /^#{1,6}\s+\S/, // heading /^[-*+]\s+\S/, // bullet list item /^\d+\.\s+\S/, // ordered list item /^>\s*\S/, // blockquote /^\|.+\|\s*$/, // table row ]; /** * Conservative "does this look like Markdown, not prose" heuristic (issue * #30). A single matching line is too weak a signal on its own — prose * often starts a line with a hyphen, a number, or a `>` — so two or more * are required, except a fenced code block, which is unambiguous by itself. */ export function looksLikeMarkdown(text: string): boolean { if (text.includes('```')) return true; const lines = text.split(/\r?\n/).filter((line) => line.trim().length > 0); const matches = lines.filter((line) => MARKDOWN_LINE_PATTERNS.some((pattern) => pattern.test(line)), ); return matches.length >= 2; } /** * Markdown on the clipboard, both ways (issue #30, ADR 0004/0009): copying * puts Markdown on `text/plain` alongside the browser's own HTML (so * pasting into a plain-text destination yields Markdown, not a naive text * dump), and pasting text that looks like a Markdown document converts it * to rich nodes instead of one flat paragraph. Clipboard content that * carries real HTML (anything copied from a rich-text source, including * Dorfteich itself) is left entirely to ProseMirror's own HTML-based * paste — the heuristic only ever applies to plain text. */ export const MarkdownClipboard = Extension.create({ name: 'markdownClipboard', addProseMirrorPlugins() { return [ new Plugin({ props: { clipboardTextSerializer(slice) { try { // Mirror image of the `handlePaste` re-hydration below: the // selected slice's nodes belong to the live view's own schema // instance, not the canonical `editorSchema` from // packages/shared — wrapping the fragment directly in an // `editorSchema` doc node fails schema-identity validation // (`RangeError: Invalid content for node doc`), so it has to // go through JSON first. const doc = ProseMirrorNode.fromJSON(editorSchema, { type: 'doc', content: slice.content.toJSON() ?? [], }); return docToMarkdown(doc); } catch { // Falls back to a plain-text join rather than ever throwing // out of a copy/cut — worst case, the clipboard just isn't // Markdown-formatted. return slice.content.textBetween(0, slice.content.size, '\n\n', ' '); } }, handlePaste(view, event) { const html = event.clipboardData?.getData('text/html'); if (html && html.trim() !== '') return false; const text = event.clipboardData?.getData('text/plain'); if (!text || !looksLikeMarkdown(text)) return false; let doc: ProseMirrorNode; try { // `markdownToDoc` builds nodes against the canonical schema // instance from packages/shared, but TipTap builds its own // separate (structurally identical) `Schema` object for the // live editor (see spec-utils.ts) — `tr.replaceWith` silently // drops content whose node types aren't `===` the state's own // schema, so the parsed doc has to be re-hydrated against // `view.state.schema` before it can be inserted. doc = ProseMirrorNode.fromJSON(view.state.schema, markdownToDoc(text).toJSON()); } catch { return false; } const { selection } = view.state; view.dispatch(view.state.tr.replaceWith(selection.from, selection.to, doc.content)); return true; }, }, }), ]; }, });