Implement markdown parsing for copy paste
Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
@@ -7,7 +7,7 @@ import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
|
||||
import { MermaidNodeView } from "./MermaidNodeView";
|
||||
|
||||
export const MermaidCodeBlock = CodeBlock.extend({
|
||||
export const CodeBlockExtension = CodeBlock.extend({
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(MermaidNodeView);
|
||||
},
|
||||
334
packages/ui/src/RichEditor/MarkdownPasteExtension.ts
Normal file
334
packages/ui/src/RichEditor/MarkdownPasteExtension.ts
Normal file
@@ -0,0 +1,334 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
// Use of this source code is governed by the ISC license
|
||||
// that can be found in the LICENSE file.
|
||||
|
||||
import { Extension } from "@tiptap/core";
|
||||
import {
|
||||
Fragment,
|
||||
type Node as ProseMirrorNode,
|
||||
type Schema,
|
||||
Slice,
|
||||
} from "@tiptap/pm/model";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
|
||||
const markdownPasteKey = new PluginKey("markdownPaste");
|
||||
|
||||
const codeBlockOpenPattern = /^```(\w*)$/;
|
||||
const tableSeparatorPattern
|
||||
= /^\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)*\|?\s*$/m;
|
||||
const markdownLinePattern
|
||||
= /(?:^```\w*$|^#{1,6}\s|^>\s|^[-+*]\s|^\d+\.\s|^(?:---|___|\*\*\*)\s*$)/m;
|
||||
|
||||
function parseCells(line: string): string[] {
|
||||
return line
|
||||
.replace(/^\|/, "")
|
||||
.replace(/\|\s*$/, "")
|
||||
.split("|")
|
||||
.map(cell => cell.trim());
|
||||
}
|
||||
|
||||
function applyMarks(
|
||||
schema: Schema,
|
||||
markNames: string[],
|
||||
inner: string,
|
||||
): ProseMirrorNode[] {
|
||||
const marks = markNames.map(name => schema.marks[name].create());
|
||||
return parseInlineContent(schema, inner).map((node) => {
|
||||
let combined = node.marks;
|
||||
for (const mark of marks) {
|
||||
combined = mark.addToSet(combined);
|
||||
}
|
||||
return node.mark(combined);
|
||||
});
|
||||
}
|
||||
|
||||
function parseInlineContent(
|
||||
schema: Schema,
|
||||
text: string,
|
||||
): ProseMirrorNode[] {
|
||||
const nodes: ProseMirrorNode[] = [];
|
||||
const pattern
|
||||
= /`([^`]+)`|\*\*\*(.+?)\*\*\*|\*\*(.+?)\*\*|~~(.+?)~~|<u>(.+?)<\/u>|\[([^\]]+)\]\(([^)]+)\)|\*(.+?)\*/g;
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
while ((match = pattern.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
nodes.push(schema.text(text.slice(lastIndex, match.index)));
|
||||
}
|
||||
|
||||
if (match[1] !== undefined) {
|
||||
nodes.push(schema.text(match[1], [schema.marks.code.create()]));
|
||||
} else if (match[2] !== undefined) {
|
||||
nodes.push(...applyMarks(schema, ["bold", "italic"], match[2]));
|
||||
} else if (match[3] !== undefined) {
|
||||
nodes.push(...applyMarks(schema, ["bold"], match[3]));
|
||||
} else if (match[4] !== undefined) {
|
||||
nodes.push(...applyMarks(schema, ["strike"], match[4]));
|
||||
} else if (match[5] !== undefined) {
|
||||
nodes.push(...applyMarks(schema, ["underline"], match[5]));
|
||||
} else if (match[6] !== undefined) {
|
||||
const linkMark = schema.marks.link.create({ href: match[7] });
|
||||
for (const node of parseInlineContent(schema, match[6])) {
|
||||
nodes.push(node.mark(linkMark.addToSet(node.marks)));
|
||||
}
|
||||
} else if (match[8] !== undefined) {
|
||||
nodes.push(...applyMarks(schema, ["italic"], match[8]));
|
||||
}
|
||||
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
nodes.push(schema.text(text.slice(lastIndex)));
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function buildTable(
|
||||
schema: Schema,
|
||||
headerLine: string,
|
||||
dataLines: string[],
|
||||
): ProseMirrorNode {
|
||||
const headers = parseCells(headerLine);
|
||||
const columnCount = headers.length;
|
||||
|
||||
const headerCells = headers.map((h) => {
|
||||
const content = h ? parseInlineContent(schema, h) : [];
|
||||
return schema.nodes.tableHeader.create(
|
||||
null,
|
||||
schema.nodes.paragraph.create(
|
||||
null,
|
||||
content.length > 0 ? content : undefined,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
const tableRows: ProseMirrorNode[] = [
|
||||
schema.nodes.tableRow.create(null, headerCells),
|
||||
];
|
||||
|
||||
for (const line of dataLines) {
|
||||
const cells = parseCells(line);
|
||||
const rowCells: ProseMirrorNode[] = [];
|
||||
for (let c = 0; c < columnCount; c++) {
|
||||
const value = cells[c]?.trim() ?? "";
|
||||
const content = value ? parseInlineContent(schema, value) : [];
|
||||
rowCells.push(
|
||||
schema.nodes.tableCell.create(
|
||||
null,
|
||||
schema.nodes.paragraph.create(
|
||||
null,
|
||||
content.length > 0 ? content : undefined,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
tableRows.push(schema.nodes.tableRow.create(null, rowCells));
|
||||
}
|
||||
|
||||
return schema.nodes.table.create(null, tableRows);
|
||||
}
|
||||
|
||||
function hasMarkdown(text: string): boolean {
|
||||
return markdownLinePattern.test(text) || tableSeparatorPattern.test(text);
|
||||
}
|
||||
|
||||
function parseMarkdown(
|
||||
text: string,
|
||||
schema: Schema,
|
||||
): ProseMirrorNode[] {
|
||||
const lines = text.split("\n");
|
||||
const nodes: ProseMirrorNode[] = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
|
||||
// Code blocks
|
||||
const codeMatch = line.match(codeBlockOpenPattern);
|
||||
if (codeMatch) {
|
||||
const language = codeMatch[1] || null;
|
||||
const contentLines: string[] = [];
|
||||
i++;
|
||||
while (i < lines.length && !lines[i].startsWith("```")) {
|
||||
contentLines.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
i++;
|
||||
|
||||
const codeContent = contentLines.join("\n").replace(/\n$/, "");
|
||||
nodes.push(
|
||||
schema.nodes.codeBlock.create(
|
||||
{ language },
|
||||
codeContent ? schema.text(codeContent) : undefined,
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Tables
|
||||
if (
|
||||
line.includes("|")
|
||||
&& i + 1 < lines.length
|
||||
&& tableSeparatorPattern.test(lines[i + 1].trim())
|
||||
) {
|
||||
const headerLine = line;
|
||||
i += 2;
|
||||
const dataLines: string[] = [];
|
||||
while (i < lines.length) {
|
||||
const row = lines[i].trim();
|
||||
if (!row || !row.includes("|")) break;
|
||||
dataLines.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
nodes.push(buildTable(schema, headerLine, dataLines));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Horizontal rules (before bullet lists since --- / *** could overlap)
|
||||
const trimmed = line.trim();
|
||||
if (trimmed === "---" || trimmed === "___" || trimmed === "***") {
|
||||
nodes.push(schema.nodes.horizontalRule.create());
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Headings
|
||||
const headingMatch = line.match(/^(#{1,6})\s(.+)$/);
|
||||
if (headingMatch) {
|
||||
const level = headingMatch[1].length;
|
||||
const content = parseInlineContent(schema, headingMatch[2]);
|
||||
nodes.push(
|
||||
schema.nodes.heading.create(
|
||||
{ level },
|
||||
content.length > 0 ? content : undefined,
|
||||
),
|
||||
);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Blockquotes (group consecutive lines)
|
||||
if (line.startsWith("> ")) {
|
||||
const paragraphs: ProseMirrorNode[] = [];
|
||||
while (i < lines.length) {
|
||||
const bqMatch = lines[i].match(/^>\s(.*)$/);
|
||||
if (!bqMatch) break;
|
||||
const bqText = bqMatch[1];
|
||||
const content = bqText
|
||||
? parseInlineContent(schema, bqText)
|
||||
: [];
|
||||
paragraphs.push(
|
||||
schema.nodes.paragraph.create(
|
||||
null,
|
||||
content.length > 0 ? content : undefined,
|
||||
),
|
||||
);
|
||||
i++;
|
||||
}
|
||||
nodes.push(schema.nodes.blockquote.create(null, paragraphs));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Bullet lists (group consecutive items)
|
||||
if (
|
||||
line.startsWith("- ")
|
||||
|| line.startsWith("+ ")
|
||||
|| line.startsWith("* ")
|
||||
) {
|
||||
const items: ProseMirrorNode[] = [];
|
||||
while (i < lines.length) {
|
||||
const blMatch = lines[i].match(/^[-+*]\s(.*)$/);
|
||||
if (!blMatch) break;
|
||||
const blText = blMatch[1];
|
||||
const content = blText
|
||||
? parseInlineContent(schema, blText)
|
||||
: [];
|
||||
items.push(
|
||||
schema.nodes.listItem.create(
|
||||
null,
|
||||
schema.nodes.paragraph.create(
|
||||
null,
|
||||
content.length > 0 ? content : undefined,
|
||||
),
|
||||
),
|
||||
);
|
||||
i++;
|
||||
}
|
||||
nodes.push(schema.nodes.bulletList.create(null, items));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ordered lists (group consecutive items)
|
||||
const olMatch = line.match(/^(\d+)\.\s(.*)$/);
|
||||
if (olMatch) {
|
||||
const start = +olMatch[1];
|
||||
const items: ProseMirrorNode[] = [];
|
||||
while (i < lines.length) {
|
||||
const itemMatch = lines[i].match(/^\d+\.\s(.*)$/);
|
||||
if (!itemMatch) break;
|
||||
const olText = itemMatch[1];
|
||||
const content = olText
|
||||
? parseInlineContent(schema, olText)
|
||||
: [];
|
||||
items.push(
|
||||
schema.nodes.listItem.create(
|
||||
null,
|
||||
schema.nodes.paragraph.create(
|
||||
null,
|
||||
content.length > 0 ? content : undefined,
|
||||
),
|
||||
),
|
||||
);
|
||||
i++;
|
||||
}
|
||||
nodes.push(schema.nodes.orderedList.create({ start }, items));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Plain paragraph
|
||||
if (line.trim()) {
|
||||
const content = parseInlineContent(schema, line);
|
||||
nodes.push(
|
||||
schema.nodes.paragraph.create(
|
||||
null,
|
||||
content.length > 0 ? content : undefined,
|
||||
),
|
||||
);
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
export const MarkdownPasteExtension = Extension.create({
|
||||
name: "markdownPaste",
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
key: markdownPasteKey,
|
||||
props: {
|
||||
handlePaste(view, event) {
|
||||
const text = event.clipboardData?.getData("text/plain") ?? "";
|
||||
if (!hasMarkdown(text)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const schema = view.state.schema;
|
||||
const nodes = parseMarkdown(text, schema);
|
||||
if (nodes.length === 0) return false;
|
||||
|
||||
const slice = new Slice(Fragment.from(nodes), 0, 0);
|
||||
const tr = view.state.tr.replaceSelection(slice);
|
||||
view.dispatch(tr);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -4,13 +4,14 @@
|
||||
|
||||
import { Blockquote } from "@tiptap/extension-blockquote";
|
||||
import { Bold } from "@tiptap/extension-bold";
|
||||
import { BulletList } from "@tiptap/extension-bullet-list";
|
||||
import { Code } from "@tiptap/extension-code";
|
||||
import { Document } from "@tiptap/extension-document";
|
||||
import { HardBreak } from "@tiptap/extension-hard-break";
|
||||
import { Heading } from "@tiptap/extension-heading";
|
||||
import { HorizontalRule } from "@tiptap/extension-horizontal-rule";
|
||||
import { Italic } from "@tiptap/extension-italic";
|
||||
import { BulletList, ListItem, OrderedList } from "@tiptap/extension-list";
|
||||
import { ListItem, OrderedList } from "@tiptap/extension-list";
|
||||
import { Paragraph } from "@tiptap/extension-paragraph";
|
||||
import { Strike } from "@tiptap/extension-strike";
|
||||
import { TableKit } from "@tiptap/extension-table";
|
||||
@@ -23,8 +24,9 @@ import { tv } from "tailwind-variants";
|
||||
|
||||
import { BlockMenu } from "./BlockMenu/BlockMenu";
|
||||
import { BubbleMenu } from "./BubbleMenu";
|
||||
import { CodeBlockExtension } from "./CodeBlockExtension";
|
||||
import { LinkExtension } from "./LinkExtension";
|
||||
import { MermaidCodeBlock } from "./MermaidExtension";
|
||||
import { MarkdownPasteExtension } from "./MarkdownPasteExtension";
|
||||
import { OptionsMenu } from "./OptionsMenu/OptionsMenu";
|
||||
import { PlaceholderExtension } from "./PlaceholderExtension";
|
||||
import { SlashCommandExtension } from "./SlashCommandExtension";
|
||||
@@ -43,7 +45,7 @@ const extensions = [
|
||||
Strike,
|
||||
Underline,
|
||||
Code,
|
||||
MermaidCodeBlock,
|
||||
CodeBlockExtension,
|
||||
LinkExtension,
|
||||
SlashCommandExtension,
|
||||
PlaceholderExtension,
|
||||
@@ -61,6 +63,7 @@ const extensions = [
|
||||
TableKit.configure({
|
||||
table: { resizable: true },
|
||||
}),
|
||||
MarkdownPasteExtension,
|
||||
];
|
||||
|
||||
const richEditorVariants = tv({
|
||||
|
||||
@@ -21,18 +21,10 @@
|
||||
|
||||
ul {
|
||||
@apply list-disc list-outside my-2 pl-4;
|
||||
|
||||
li {
|
||||
p { @apply my-0; }
|
||||
}
|
||||
}
|
||||
|
||||
ol {
|
||||
@apply list-decimal list-outside my-2 pl-4;
|
||||
|
||||
li {
|
||||
p { @apply inline-block my-0; }
|
||||
}
|
||||
}
|
||||
|
||||
a { @apply text-txt-info underline font-normal; }
|
||||
|
||||
Reference in New Issue
Block a user