Skip to content

Commit 63f353b

Browse files
feat(editor): enable markdown pasting in Tiptap editor (#4702)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: [email protected] <[email protected]>
1 parent 967f60e commit 63f353b

File tree

2 files changed

+100
-0
lines changed

2 files changed

+100
-0
lines changed

packages/fern-dashboard/src/components/editor/TiptapEditor.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { cn } from "@/utils/utils";
2121
import { createCodeBlockComponent } from "./extension-code-block/CodeBlockComponent";
2222
import CustomElement from "./extension-custom-element";
2323
import { FVEAttributesExtension } from "./extension-fve-attributes";
24+
import { MarkdownPasteExtension } from "./extension-markdown-paste";
2425
import { SelectBlockExtension } from "./extension-select-block/select-block-extension";
2526
import FloatingMenu from "./FloatingMenu";
2627
import NodeHoverHandle from "./NodeHoverHandle";
@@ -74,6 +75,7 @@ const extensions = [
7475
FVEAttributesExtension.configure({
7576
types: dataAttributeNodeTypes
7677
}),
78+
MarkdownPasteExtension,
7779
SelectBlockExtension,
7880
CustomElement,
7981
Placeholder.configure({
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { mdxToHtml } from "@fern-docs/mdx";
2+
import { Extension } from "@tiptap/core";
3+
import { DOMParser as PMDOMParser } from "@tiptap/pm/model";
4+
import { Plugin, PluginKey } from "@tiptap/pm/state";
5+
6+
function looksLikeMarkdown(text: string): boolean {
7+
const trimmed = text.trim();
8+
return (
9+
trimmed.startsWith("#") || // Headings
10+
/^[-*+]\s/.test(trimmed) || // Unordered lists
11+
/^\d+\.\s/.test(trimmed) || // Ordered lists
12+
/^```/.test(trimmed) || // Code blocks
13+
/^>/.test(trimmed) || // Blockquotes
14+
/\[.+\]\(.+\)/.test(trimmed) // Links
15+
);
16+
}
17+
18+
export const MarkdownPasteExtension = Extension.create({
19+
name: "markdownPaste",
20+
21+
addProseMirrorPlugins() {
22+
let forcePlainTextNextPaste = false;
23+
24+
return [
25+
new Plugin({
26+
key: new PluginKey("markdownPaste"),
27+
props: {
28+
handleKeyDown: (view, event) => {
29+
if ((event.metaKey || event.ctrlKey) && event.shiftKey && event.key === "v") {
30+
forcePlainTextNextPaste = true;
31+
}
32+
return false;
33+
},
34+
handlePaste: (view, event) => {
35+
const clipboardData = event.clipboardData;
36+
if (!clipboardData) {
37+
return false;
38+
}
39+
40+
const types = Array.from(clipboardData.types);
41+
if (types.includes("text/x-prosemirror-slice")) {
42+
return false;
43+
}
44+
45+
const text = clipboardData.getData("text/plain");
46+
const html = clipboardData.getData("text/html");
47+
48+
if (forcePlainTextNextPaste) {
49+
forcePlainTextNextPaste = false;
50+
if (text) {
51+
event.preventDefault();
52+
const { tr } = view.state;
53+
tr.insertText(text);
54+
view.dispatch(tr);
55+
return true;
56+
}
57+
return false;
58+
}
59+
60+
const shouldTreatAsMarkdown = text && (!html || looksLikeMarkdown(text));
61+
62+
if (!shouldTreatAsMarkdown) {
63+
return false;
64+
}
65+
66+
try {
67+
const { html: convertedHtml } = mdxToHtml(text);
68+
69+
if (convertedHtml) {
70+
event.preventDefault();
71+
const tempDiv = document.createElement("div");
72+
tempDiv.innerHTML = convertedHtml;
73+
74+
const parser = PMDOMParser.fromSchema(view.state.schema);
75+
const parsedSlice = parser.parseSlice(tempDiv);
76+
77+
if (parsedSlice) {
78+
const { tr } = view.state;
79+
tr.replaceSelection(parsedSlice);
80+
view.dispatch(tr);
81+
return true;
82+
}
83+
}
84+
} catch {
85+
event.preventDefault();
86+
const { tr } = view.state;
87+
tr.insertText(text);
88+
view.dispatch(tr);
89+
return true;
90+
}
91+
92+
return false;
93+
}
94+
}
95+
})
96+
];
97+
}
98+
});

0 commit comments

Comments
 (0)