Skip to content
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

feat: markdown pasting & custom paste handlers #1490

Open
wants to merge 15 commits into
base: main
Choose a base branch
from
Open
12 changes: 12 additions & 0 deletions docs/pages/docs/editor-basics/setup.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,16 @@ type BlockNoteEditorOptions = {
class?: string;
}) => Plugin;
initialContent?: PartialBlock[];
pasteHandler?: (context: {
view: EditorView;
event: ClipboardEvent;
editor: BlockNoteEditor;
defaultPasteHandler: (context: {
pasteBehavior?: "prefer-markdown" | "prefer-html";
}) => boolean | undefined;
convertHtmlToBlockNoteHtml: (html: string) => string;
Copy link
Collaborator

Choose a reason for hiding this comment

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

API feedback:

  • what about exposing pasteHTML pasteMarkdown and pasteText on the editor instead of the conversion functions? this way consumers also don't need to call into prosemirror view.paste... functions.
  • I'm not sure I like passing the defaultPasteHandler vs exporting that function directly (still in doubt)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

  • I like exposing the methods, to keep it simple for users, I exposed pasteHTML, pasteText & pasteMarkdown methods
  • I think having too many things exposed on a top-level API make it hard for discoverability, this function would only be useful for pasting content, so it makes sense to expose on the handler you'd use it at.

convertMarkdownToBlockNoteHtml: (markdown: string) => Promise<string>;
}) => boolean | undefined;
resolveFileUrl: (url: string) => Promise<string>
schema?: BlockNoteSchema;
setIdAttribute?: boolean;
Expand Down Expand Up @@ -66,6 +76,8 @@ The hook takes two optional parameters:

`initialContent:` The content that should be in the editor when it's created, represented as an array of [Partial Blocks](/docs/manipulating-blocks#partial-blocks).

`pasteHandler`: A function that can be used to override the default paste behavior. See [Custom Paste Behavior](/docs/advanced/custom-paste-behavior) for more.

`resolveFileUrl:` Function to resolve file URLs for display/download. Useful for creating authenticated URLs or implementing custom protocols.

`resolveUsers`: Function to resolve user information for comments. See [Comments](/docs/collaboration/comments) for more.
Expand Down
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
145 changes: 107 additions & 38 deletions packages/core/src/api/clipboard/fromClipboard/pasteExtension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,101 @@ 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 { isMarkdown } from "../../parsers/markdown/detectMarkdown.js";
import { EditorView } from "prosemirror-view";

function convertHtmlToBlockNoteHtml(html: string) {
const htmlNode = nestedListsToBlockNoteStructure(html.trim());
return htmlNode.innerHTML;
}

function convertMarkdownToBlockNoteHtml(markdown: string) {
return markdownToHTML(markdown).then((html) => {
return convertHtmlToBlockNoteHtml(html);
});
}

function defaultPasteHandler({
view,
event,
editor,
pasteBehavior = "prefer-markdown",
}: {
view: EditorView;
event: ClipboardEvent;
editor: BlockNoteEditor<any, any, any>;
pasteBehavior?: "prefer-markdown" | "prefer-html";
}) {
let format: (typeof acceptedMIMETypes)[number] | undefined;
for (const mimeType of acceptedMIMETypes) {
if (event.clipboardData!.types.includes(mimeType)) {
format = mimeType;
break;
}
}

if (!format) {
return true;
}

if (format === "vscode-editor-data") {
handleVSCodePaste(event, view);
return true;
}

if (format === "Files") {
handleFileInsertion(event, editor);
return true;
}

const data = event.clipboardData!.getData(format);

if (format === "blocknote/html") {
// Is blocknote/html, so no need to convert it
view.pasteHTML(data);
return true;
}

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

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

if (isMarkdown(plainText)) {
// Convert Markdown to HTML first, then paste as HTML
convertMarkdownToBlockNoteHtml(plainText).then((html) => {
view.pasteHTML(html);
});
return true;
}
}

view.pasteHTML(convertHtmlToBlockNoteHtml(data));
return true;
}

if (pasteBehavior === "prefer-markdown" && isMarkdown(data)) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm not sure if this single flag is what we want. We now always turn on "detection" and for both markdown and HTML. is that desirable?

Other options I can think of what the user might want:

  • always paste plain text as markdown (i.e.: paste markdown instead of plain text at L103), regardless of detection
  • Only run the detection for plain text, not for HTML (i.e.: always prefer regular HTML paste)?
  • (Other combinations like only run the detection for HTML, not for plain text. Not sure if that makes sense)

Happy to think this through together. I don't think we need all options to be possible via different flags, especially since users can now add their own paste handlers. But I do think there should at least be a way to not rely on the auto-detection yet still paste text as markdown

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I refactored it to make it clearer how it was being used, if prefer-markdown is used, it will try to use the plaintext version if it can, otherwise it falls back to the previous behavior.

// Convert Markdown to HTML first, then paste as HTML
convertMarkdownToBlockNoteHtml(data).then((html) => {
view.pasteHTML(html);
});
return true;
}

view.pasteText(data);
return true;
}

export const createPasteFromClipboardExtension = <
BSchema extends BlockSchema,
Expand All @@ -33,44 +125,21 @@ export const createPasteFromClipboardExtension = <
return;
}

let format: (typeof acceptedMIMETypes)[number] | undefined;
for (const mimeType of acceptedMIMETypes) {
if (event.clipboardData!.types.includes(mimeType)) {
format = mimeType;
break;
}
}
if (!format) {
return true;
}

if (format === "vscode-editor-data") {
handleVSCodePaste(event, view);
return true;
}

if (format === "Files") {
handleFileInsertion(event, editor);
return true;
}

let data = event.clipboardData!.getData(format);

if (format === "blocknote/html") {
view.pasteHTML(data);
return true;
}

if (format === "text/html") {
const htmlNode = nestedListsToBlockNoteStructure(data.trim());
data = htmlNode.innerHTML;
view.pasteHTML(data);
return true;
}

view.pasteText(data);

return true;
return editor.settings.pasteHandler({
view,
event,
editor,
defaultPasteHandler: ({ pasteBehavior }) => {
return defaultPasteHandler({
view,
event,
editor,
pasteBehavior,
});
},
convertHtmlToBlockNoteHtml,
convertMarkdownToBlockNoteHtml,
});
},
},
},
Expand Down
60 changes: 60 additions & 0 deletions packages/core/src/api/parsers/markdown/detectMarkdown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// Headings H1-H6.
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 isMarkdown = (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);
}
57 changes: 57 additions & 0 deletions packages/core/src/editor/BlockNoteEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,51 @@ export type BlockNoteEditorOptions<
string | undefined
>;

/**
* Custom paste handler that can be used to override the default paste behavior.
* @returns The function should return `true` if the paste event was handled, otherwise it should return `false` if it should be canceled or `undefined` if it should be handled by another handler.
*
* @example
* ```ts
* pasteHandler: ({ defaultPasteHandler }) => {
* return defaultPasteHandler({ pasteBehavior: "prefer-html" });
* }
* ```
*/
pasteHandler?: (context: {
Comment on lines +219 to +230
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I also was considering making this into an onPaste, and expose it on the blocknoteview, but it would make it a lot harder to implement than the existing onChange stuff, because you expect there to only be one entity which is handling a paste.

I started doing it here:

diff --git i/docs/pages/docs/editor-basics/setup.mdx w/docs/pages/docs/editor-basics/setup.mdx
index 9c8bfef1f..07aa4e5cc 100644
--- i/docs/pages/docs/editor-basics/setup.mdx
+++ w/docs/pages/docs/editor-basics/setup.mdx
@@ -33,16 +33,6 @@ type BlockNoteEditorOptions = {
     class?: string;
   }) => Plugin;
   initialContent?: PartialBlock[];
-  pasteHandler?: (context: {
-    view: EditorView;
-    event: ClipboardEvent;
-    editor: BlockNoteEditor;
-    defaultPasteHandler: (context: {
-      pasteBehavior?: "prefer-markdown" | "prefer-html";
-    }) => boolean | undefined;
-    convertHtmlToBlockNoteHtml: (html: string) => string;
-    convertMarkdownToBlockNoteHtml: (markdown: string) => Promise<string>;
-  }) => boolean | undefined;
   resolveFileUrl: (url: string) => Promise<string>
   schema?: BlockNoteSchema;
   setIdAttribute?: boolean;
@@ -76,8 +66,6 @@ The hook takes two optional parameters:
 
 `initialContent:` The content that should be in the editor when it's created, represented as an array of [Partial Blocks](/docs/manipulating-blocks#partial-blocks).
 
-`pasteHandler`: A function that can be used to override the default paste behavior. See [Custom Paste Behavior](/docs/advanced/custom-paste-behavior) for more.
-
 `resolveFileUrl:` Function to resolve file URLs for display/download. Useful for creating authenticated URLs or implementing custom protocols.
 
 `resolveUsers`: Function to resolve user information for comments. See [Comments](/docs/collaboration/comments) for more.
@@ -130,6 +118,16 @@ export type BlockNoteViewProps = {
   editable?: boolean;
   onSelectionChange?: () => void;
   onChange?: () => void;
+  onPaste?: (context: {
+    view: EditorView;
+    event: ClipboardEvent;
+    editor: BlockNoteEditor;
+    defaultPasteHandler: (context: {
+      pasteBehavior?: "prefer-markdown" | "prefer-html";
+    }) => boolean | undefined;
+    convertHtmlToBlockNoteHtml: (html: string) => string;
+    convertMarkdownToBlockNoteHtml: (markdown: string) => Promise<string>;
+  }) => boolean | undefined;
   theme?:
     | "light"
     | "dark"
@@ -158,6 +156,8 @@ export type BlockNoteViewProps = {
 
 `onChange`: Callback fired when the editor content (document) changes.
 
+`onPaste`: A function that can be used to override the default paste behavior.
+
 `theme`: The editor's theme, see [Themes](/docs/styling-theming/themes) for more about this.
 
 `formattingToolbar`: Whether the [Formatting Toolbar](/docs/ui-components/formatting-toolbar) should be enabled.
diff --git i/packages/core/src/api/clipboard/fromClipboard/pasteExtension.ts w/packages/core/src/api/clipboard/fromClipboard/pasteExtension.ts
index 1f6e8bd1c..40901a3e7 100644
--- i/packages/core/src/api/clipboard/fromClipboard/pasteExtension.ts
+++ w/packages/core/src/api/clipboard/fromClipboard/pasteExtension.ts
@@ -125,7 +125,7 @@ export const createPasteFromClipboardExtension = <
                   return;
                 }
 
-                return editor.settings.pasteHandler({
+                return editor.onPaste({
                   view,
                   event,
                   editor,
diff --git i/packages/core/src/editor/BlockNoteEditor.ts w/packages/core/src/editor/BlockNoteEditor.ts
index aab8824c0..2c07f1d52 100644
--- i/packages/core/src/editor/BlockNoteEditor.ts
+++ w/packages/core/src/editor/BlockNoteEditor.ts
@@ -218,12 +218,12 @@ export type BlockNoteEditorOptions<
    *
    * @example
    * ```ts
-   * pasteHandler: ({ defaultPasteHandler }) => {
+   * onPaste: ({ defaultPasteHandler }) => {
    *   return defaultPasteHandler({ pasteBehavior: "prefer-html" });
    * }
    * ```
    */
-  pasteHandler?: (context: {
+  onPaste?: (context: {
     view: EditorView;
     event: ClipboardEvent;
     editor: BlockNoteEditor<BSchema, ISchema, SSchema>;
@@ -475,6 +475,14 @@ export class BlockNoteEditor<
   private onUploadStartCallbacks: ((blockId?: string) => void)[] = [];
   private onUploadEndCallbacks: ((blockId?: string) => void)[] = [];
 
+  /**
+   * Custom paste handler that can be used to override the default paste behavior.
+   */
+  public readonly onPaste: Exclude<
+    BlockNoteEditorOptions<BSchema, ISchema, SSchema>["onPaste"],
+    undefined
+  >;
+
   public readonly resolveFileUrl?: (url: string) => Promise<string>;
   public readonly resolveUsers?: (userIds: string[]) => Promise<User[]>;
   /**
@@ -487,10 +495,6 @@ export class BlockNoteEditor<
       cellTextColor: boolean;
       headers: boolean;
     };
-    pasteHandler: Exclude<
-      BlockNoteEditorOptions<any, any, any>["pasteHandler"],
-      undefined
-    >;
   };
 
   public static create<
@@ -538,14 +542,6 @@ export class BlockNoteEditor<
         cellTextColor: options?.tables?.cellTextColor ?? false,
         headers: options?.tables?.headers ?? false,
       },
-      pasteHandler:
-        options.pasteHandler ||
-        ((context: {
-          defaultPasteHandler: (context: {
-            pasteBehavior?: "prefer-markdown" | "prefer-html";
-          }) => boolean | undefined;
-        }) =>
-          context.defaultPasteHandler({ pasteBehavior: "prefer-markdown" })),
     };
 
     // apply defaults
@@ -630,6 +626,11 @@ export class BlockNoteEditor<
       };
     }
 
+    this.onPaste =
+      newOptions.onPaste ||
+      ((context) =>
+        context.defaultPasteHandler({ pasteBehavior: "prefer-markdown" }));
+
     this.resolveFileUrl = newOptions.resolveFileUrl;
     this.headless = newOptions._headless;

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Like, it is somewhere between the purely runtime event of onChange, and something you'd need to know upfront like uploadFile.

This distinction isn't super clear to me what should be on the view side or not

Copy link
Collaborator

Choose a reason for hiding this comment

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

I think current approach is ok. You could do something like return true / false to enable chaining but at this point I'm not sure it's worthwhile (don't see a lot of scenarios where that would be useful)

view: EditorView;
event: ClipboardEvent;
editor: BlockNoteEditor<BSchema, ISchema, SSchema>;
/**
* Convert HTML into BlockNote-compatible HTML
* There can be cases where normal HTML is not supported by BlockNote, so this function can be used to convert the HTML into a format that is supported.
* @param html The HTML to convert
* @returns The converted HTML
*/
convertHtmlToBlockNoteHtml: (html: string) => string;
/**
* Convert Markdown into BlockNote-compatible HTML
* There can be cases where normal Markdown is not supported by BlockNote, so this function can be used to convert the Markdown into a format that is supported.
* @param markdown The Markdown to convert
* @returns The converted HTML
*/
convertMarkdownToBlockNoteHtml: (markdown: string) => Promise<string>;
/**
* The default paste handler
* @param context The context object
* @returns Whether the paste event was handled or not
*/
defaultPasteHandler: (context: {
/**
* 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";
}) => boolean | undefined;
}) => boolean | undefined;

/**
* 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 +487,10 @@ export class BlockNoteEditor<
cellTextColor: boolean;
headers: boolean;
};
pasteHandler: Exclude<
BlockNoteEditorOptions<any, any, any>["pasteHandler"],
undefined
>;
};

public static create<
Expand Down Expand Up @@ -489,6 +538,14 @@ export class BlockNoteEditor<
cellTextColor: options?.tables?.cellTextColor ?? false,
headers: options?.tables?.headers ?? false,
},
pasteHandler:
options.pasteHandler ||
((context: {
defaultPasteHandler: (context: {
pasteBehavior?: "prefer-markdown" | "prefer-html";
}) => boolean | undefined;
}) =>
context.defaultPasteHandler({ pasteBehavior: "prefer-markdown" })),
};

// apply defaults
Expand Down
Loading