feat: support pasting markdown (#606)

This commit is contained in:
Philip Okugbe
2025-01-04 16:57:36 +00:00
committed by GitHub
parent 0cbbcb8eb1
commit 287b833838
12 changed files with 56 additions and 7 deletions

View File

@ -15,4 +15,4 @@ export * from "./lib/custom-code-block"
export * from "./lib/drawio";
export * from "./lib/excalidraw";
export * from "./lib/embed";
export * from "./lib/markdown";

View File

@ -0,0 +1,2 @@
export * from "./markdown-clipboard";
export * from "./utils/marked.utils";

View File

@ -0,0 +1,43 @@
// adapted from: https://github.com/aguingand/tiptap-markdown/blob/main/src/extensions/tiptap/clipboard.js - MIT
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { DOMParser } from "@tiptap/pm/model";
import { markdownToHtml } from "./utils/marked.utils";
export const MarkdownClipboard = Extension.create({
name: "markdownClipboard",
addOptions() {
return {
transformPastedText: false,
};
},
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey("markdownClipboard"),
props: {
clipboardTextParser: (text, context, plainText) => {
if (plainText || !this.options.transformPastedText) {
return null; // pasting with shift key prevents formatting
}
const parsed = markdownToHtml(text);
return DOMParser.fromSchema(this.editor.schema).parseSlice(
elementFromString(parsed),
{
preserveWhitespace: true,
context,
},
);
},
},
}),
];
},
});
function elementFromString(value) {
// add a wrapper to preserve leading and trailing whitespace
const wrappedValue = `<body>${value}</body>`;
return new window.DOMParser().parseFromString(wrappedValue, "text/html").body;
}

View File

@ -0,0 +1,41 @@
import { Token, marked } from 'marked';
interface CalloutToken {
type: 'callout';
calloutType: string;
text: string;
raw: string;
}
export const calloutExtension = {
name: 'callout',
level: 'block',
start(src: string) {
return src.match(/:::/)?.index ?? -1;
},
tokenizer(src: string): CalloutToken | undefined {
const rule = /^:::([a-zA-Z0-9]+)\s+([\s\S]+?):::/;
const match = rule.exec(src);
const validCalloutTypes = ['info', 'success', 'warning', 'danger'];
if (match) {
let type = match[1];
if (!validCalloutTypes.includes(type)) {
type = 'info';
}
return {
type: 'callout',
calloutType: type,
raw: match[0],
text: match[2].trim(),
};
}
},
renderer(token: Token) {
const calloutToken = token as CalloutToken;
const body = marked.parse(calloutToken.text);
return `<div data-type="callout" data-callout-type="${calloutToken.calloutType}">${body}</div>`;
},
};

View File

@ -0,0 +1,41 @@
import { marked } from 'marked';
import { calloutExtension } from './callout.marked';
import { mathBlockExtension } from './math-block.marked';
import { mathInlineExtension } from "./math-inline.marked";
marked.use({
renderer: {
// @ts-ignore
list(body: string, isOrdered: boolean, start: number) {
if (isOrdered) {
const startAttr = start !== 1 ? ` start="${start}"` : '';
return `<ol ${startAttr}>\n${body}</ol>\n`;
}
const dataType = body.includes(`<input`) ? ' data-type="taskList"' : '';
return `<ul${dataType}>\n${body}</ul>\n`;
},
// @ts-ignore
listitem({ text, raw, task: isTask, checked: isChecked }): string {
if (!isTask) {
return `<li>${text}</li>\n`;
}
const checkedAttr = isChecked
? 'data-checked="true"'
: 'data-checked="false"';
return `<li data-type="taskItem" ${checkedAttr}>${text}</li>\n`;
},
},
});
marked.use({ extensions: [calloutExtension, mathBlockExtension, mathInlineExtension] });
export function markdownToHtml(markdownInput: string): string | Promise<string> {
const YAML_FONT_MATTER_REGEX = /^\s*---[\s\S]*?---\s*/;
const markdown = markdownInput
.replace(YAML_FONT_MATTER_REGEX, '')
.trimStart();
return marked.parse(markdown);
}

View File

@ -0,0 +1,37 @@
import { Token, marked } from 'marked';
interface MathBlockToken {
type: 'mathBlock';
text: string;
raw: string;
}
export const mathBlockExtension = {
name: 'mathBlock',
level: 'block',
start(src: string) {
return src.match(/\$\$/)?.index ?? -1;
},
tokenizer(src: string): MathBlockToken | undefined {
const rule = /^\$\$(?!(\$))([\s\S]+?)\$\$/;
const match = rule.exec(src);
if (match) {
return {
type: 'mathBlock',
raw: match[0],
text: match[2]?.trim(),
};
}
},
renderer(token: Token) {
const mathBlockToken = token as MathBlockToken;
// parse to prevent escaping slashes
const latex = marked
.parse(mathBlockToken.text)
.toString()
.replace(/<(\/)?p>/g, '');
return `<div data-type="${mathBlockToken.type}" data-katex="true">${latex}</div>`;
},
};

View File

@ -0,0 +1,55 @@
import { Token, marked } from 'marked';
interface MathInlineToken {
type: 'mathInline';
text: string;
raw: string;
}
const inlineMathRegex = /^\$(?!\s)(.+?)(?<!\s)\$(?!\d)/;
export const mathInlineExtension = {
name: 'mathInline',
level: 'inline',
start(src: string) {
let index: number;
let indexSrc = src;
while (indexSrc) {
index = indexSrc.indexOf('$');
if (index === -1) {
return;
}
const f = index === 0 || indexSrc.charAt(index - 1) === ' ';
if (f) {
const possibleKatex = indexSrc.substring(index);
if (possibleKatex.match(inlineMathRegex)) {
return index;
}
}
indexSrc = indexSrc.substring(index + 1).replace(/^\$+/, '');
}
},
tokenizer(src: string): MathInlineToken | undefined {
const match = inlineMathRegex.exec(src);
if (match) {
return {
type: 'mathInline',
raw: match[0],
text: match[1]?.trim(),
};
}
},
renderer(token: Token) {
const mathInlineToken = token as MathInlineToken;
// parse to prevent escaping slashes
const latex = marked
.parse(mathInlineToken.text)
.toString()
.replace(/<(\/)?p>/g, '');
return `<span data-type="${mathInlineToken.type}" data-katex="true">${latex}</span>`;
},
};