-
-
Notifications
You must be signed in to change notification settings - Fork 514
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
base: main
Are you sure you want to change the base?
Changes from 9 commits
1ced711
f60d856
29162fa
7f61926
c060eee
1681c62
1f3ad10
aee8dcc
2045027
52ce922
f20e37d
5ce1571
913916d
f3822e5
c892e7f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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") { | ||
nperez0111 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
// 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)) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||
|
@@ -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, | ||
}); | ||
}, | ||
}, | ||
}, | ||
|
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); |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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;
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think current approach is ok. You could do something like |
||
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 | ||
|
@@ -442,6 +487,10 @@ export class BlockNoteEditor< | |
cellTextColor: boolean; | ||
headers: boolean; | ||
}; | ||
pasteHandler: Exclude< | ||
BlockNoteEditorOptions<any, any, any>["pasteHandler"], | ||
undefined | ||
>; | ||
}; | ||
|
||
public static create< | ||
|
@@ -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 | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
API feedback:
pasteHTML
pasteMarkdown
andpasteText
on the editor instead of the conversion functions? this way consumers also don't need to call into prosemirrorview.paste...
functions.defaultPasteHandler
vs exporting that function directly (still in doubt)There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.