Skip to content

feat: markdown pasting & custom paste handlers #1490

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 21 commits into from
Mar 31, 2025
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export const acceptedMIMETypes = [
"vscode-editor-data",
"blocknote/html",
"text/markdown",
"text/html",
"text/plain",
"Files",
Expand Down
29 changes: 27 additions & 2 deletions packages/core/src/api/clipboard/fromClipboard/pasteExtension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ import {
StyleSchema,
} from "../../../schema/index.js";
import { nestedListsToBlockNoteStructure } from "../../parsers/html/util/nestedLists.js";
import { markdownToHTML } from "../../parsers/markdown/parseMarkdown.js";
import { acceptedMIMETypes } from "./acceptedMIMETypes.js";
import { handleFileInsertion } from "./handleFileInsertion.js";
import { handleVSCodePaste } from "./handleVSCodePaste.js";
import { is } from "../../parsers/markdown/is.js";

export const createPasteFromClipboardExtension = <
BSchema extends BlockSchema,
Expand Down Expand Up @@ -40,6 +42,7 @@ export const createPasteFromClipboardExtension = <
break;
}
}

if (!format) {
return true;
}
Expand All @@ -61,15 +64,37 @@ export const createPasteFromClipboardExtension = <
return true;
}

if (format === "text/markdown") {
markdownToHTML(data).then((html) => {
view.pasteHTML(html);
});
return true;
}

if (format === "text/html") {
if (editor.settings.pasteBehavior === "prefer-markdown") {
// Use plain text instead of HTML if it looks like Markdown
const plainText =
event.clipboardData!.getData("text/plain");

if (is(plainText)) {
// Convert Markdown to HTML first, then paste as HTML
markdownToHTML(plainText).then((html) => {
view.pasteHTML(html);
});
return true;
}
}
const htmlNode = nestedListsToBlockNoteStructure(data.trim());
data = htmlNode.innerHTML;
view.pasteHTML(data);
return true;
}

view.pasteText(data);

// Convert Markdown to HTML first, then paste as HTML
markdownToHTML(data).then((html) => {
view.pasteHTML(html);
});
return true;
},
},
Expand Down
60 changes: 60 additions & 0 deletions packages/core/src/api/parsers/markdown/is.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// Headings H1-H6.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

curious how many false positives we get. I didn't review the regexpes obviously.

Figured this might be useful: https://chatgpt.com/share/67d12910-3d78-8009-8bc0-ddd23dd7cba9

const h1 = /(^|\n) {0,3}#{1,6} {1,8}[^\n]{1,64}\r?\n\r?\n\s{0,32}\S/;

// Bold, italic, underline, strikethrough, highlight.
const bold = /(?:\s|^)(_|__|\*|\*\*|~~|==|\+\+)(?!\s).{1,64}(?<!\s)(?=\1)/;

// Basic inline link (also captures images).
const link = /\[[^\]]{1,128}\]\(https?:\/\/\S{1,999}\)/;

// Inline code.
const code = /(?:\s|^)`(?!\s)[^`]{1,48}(?<!\s)`([^\w]|$)/;

// Unordered list.
const ul = /(?:^|\n)\s{0,5}-\s{1}[^\n]+\n\s{0,15}-\s/;

// Ordered list.
const ol = /(?:^|\n)\s{0,5}\d+\.\s{1}[^\n]+\n\s{0,15}\d+\.\s/;

// Horizontal rule.
const hr = /\n{2} {0,3}-{2,48}\n{2}/;

// Fenced code block.
const fences =
/(?:\n|^)(```|~~~|\$\$)(?!`|~)[^\s]{0,64} {0,64}[^\n]{0,64}\n[\s\S]{0,9999}?\s*\1 {0,64}(?:\n+|$)/;

// Classical underlined H1 and H2 headings.
const title = /(?:\n|^)(?!\s)\w[^\n]{0,64}\r?\n(-|=)\1{0,64}\n\n\s{0,64}(\w|$)/;

// Blockquote.
const blockquote =
/(?:^|(\r?\n\r?\n))( {0,3}>[^\n]{1,333}\n){1,999}($|(\r?\n))/;

// Table Header
const tableHeader = /^\s*\|(.+\|)+\s*$/m;

// Table Divider
const tableDivider = /^\s*\|(\s*[-:]+[-:]\s*\|)+\s*$/m;

// Table Row
const tableRow = /^\s*\|(.+\|)+\s*$/m;

/**
* Returns `true` if the source text might be a markdown document.
*
* @param src Source text to analyze.
*/
export const is = (src: string): boolean =>
h1.test(src) ||
bold.test(src) ||
link.test(src) ||
code.test(src) ||
ul.test(src) ||
ol.test(src) ||
hr.test(src) ||
fences.test(src) ||
title.test(src) ||
blockquote.test(src) ||
tableHeader.test(src) ||
tableDivider.test(src) ||
tableRow.test(src);
36 changes: 18 additions & 18 deletions packages/core/src/api/parsers/markdown/parseMarkdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,17 +48,7 @@ function code(state: any, node: any) {
return result;
}

export async function markdownToBlocks<
BSchema extends BlockSchema,
I extends InlineContentSchema,
S extends StyleSchema
>(
markdown: string,
blockSchema: BSchema,
icSchema: I,
styleSchema: S,
pmSchema: Schema
): Promise<Block<BSchema, I, S>[]> {
export async function markdownToHTML(markdown: string): Promise<string> {
const deps = await initializeESMDependencies();
const htmlString = deps.unified
.unified()
Expand All @@ -73,11 +63,21 @@ export async function markdownToBlocks<
.use(deps.rehypeStringify.default)
.processSync(markdown);

return HTMLToBlocks(
htmlString.value as string,
blockSchema,
icSchema,
styleSchema,
pmSchema
);
return htmlString.value as string;
}

export async function markdownToBlocks<
BSchema extends BlockSchema,
I extends InlineContentSchema,
S extends StyleSchema
>(
markdown: string,
blockSchema: BSchema,
icSchema: I,
styleSchema: S,
pmSchema: Schema
): Promise<Block<BSchema, I, S>[]> {
const htmlString = await markdownToHTML(markdown);

return HTMLToBlocks(htmlString, blockSchema, icSchema, styleSchema, pmSchema);
}
10 changes: 10 additions & 0 deletions packages/core/src/editor/BlockNoteEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,14 @@ export type BlockNoteEditorOptions<
string | undefined
>;

/**
* Changes how to interpret reading data from the clipboard
* - `prefer-markdown` will attempt to detect markdown in the plain text representation and interpret the text as markdown
* - `prefer-html` will ovoid the markdown behavior and prefer to parse from HTML instead.
* @default 'prefer-markdown'
*/
pasteBehavior: "prefer-markdown" | "prefer-html";

/**
* Resolve a URL of a file block to one that can be displayed or downloaded. This can be used for creating authenticated URL or
* implementing custom protocols / schemes
Expand Down Expand Up @@ -442,6 +450,7 @@ export class BlockNoteEditor<
cellTextColor: boolean;
headers: boolean;
};
pasteBehavior: "prefer-markdown" | "prefer-html";
};

public static create<
Expand Down Expand Up @@ -489,6 +498,7 @@ export class BlockNoteEditor<
cellTextColor: options?.tables?.cellTextColor ?? false,
headers: options?.tables?.headers ?? false,
},
pasteBehavior: options.pasteBehavior ?? "prefer-markdown",
};

// apply defaults
Expand Down
Loading