diff --git a/examples/01-basic/04-all-blocks/App.tsx b/examples/01-basic/04-all-blocks/App.tsx index 73681ebcf0..5ae6d024f6 100644 --- a/examples/01-basic/04-all-blocks/App.tsx +++ b/examples/01-basic/04-all-blocks/App.tsx @@ -1,207 +1,203 @@ import { + BlockNoteEditorOptions, BlockNoteSchema, - combineByGroup, - filterSuggestionItems, locales, } from "@blocknote/core"; import "@blocknote/core/fonts/inter.css"; import { BlockNoteView } from "@blocknote/mantine"; import "@blocknote/mantine/style.css"; +import { useCreateBlockNote } from "@blocknote/react"; import { - SuggestionMenuController, - getDefaultReactSlashMenuItems, - useCreateBlockNote, -} from "@blocknote/react"; -import { - getMultiColumnSlashMenuItems, multiColumnDropCursor, locales as multiColumnLocales, withMultiColumn, } from "@blocknote/xl-multi-column"; -import { useMemo } from "react"; -export default function App() { - // Creates a new editor instance. - const editor = useCreateBlockNote({ - schema: withMultiColumn(BlockNoteSchema.create()), - dropCursor: multiColumnDropCursor, - dictionary: { - ...locales.en, - multi_column: multiColumnLocales.en, - }, - initialContent: [ - { - type: "paragraph", - content: "Welcome to this demo!", - }, - { - type: "paragraph", - }, - { - type: "paragraph", - content: [ - { - type: "text", - text: "Blocks:", - styles: { bold: true }, - }, - ], - }, - { - type: "paragraph", - content: "Paragraph", - }, - { - type: "columnList", - children: [ - { - type: "column", - props: { - width: 0.8, - }, - children: [ - { - type: "paragraph", - content: "Hello to the left!", - }, - ], - }, - { - type: "column", - props: { - width: 1.2, - }, - children: [ - { - type: "paragraph", - content: "Hello to the right!", - }, - ], + +const schema = withMultiColumn(BlockNoteSchema.create()); +const options = { + schema: withMultiColumn(BlockNoteSchema.create()), + dropCursor: multiColumnDropCursor, + dictionary: { + ...locales.en, + multi_column: multiColumnLocales.en, + }, + initialContent: [ + { + type: "paragraph", + content: "Welcome to this demo!", + }, + { + type: "paragraph", + }, + { + type: "paragraph", + content: [ + { + type: "text", + text: "Blocks:", + styles: { bold: true }, + }, + ], + }, + { + type: "paragraph", + content: "Paragraph", + }, + { + type: "columnList", + children: [ + { + type: "column", + props: { + width: 0.8, }, - ], - }, - { - type: "heading", - content: "Heading", - }, - { - type: "bulletListItem", - content: "Bullet List Item", - }, - { - type: "numberedListItem", - content: "Numbered List Item", - }, - { - type: "checkListItem", - content: "Check List Item", - }, - { - type: "codeBlock", - props: { language: "javascript" }, - content: "console.log('Hello, world!');", - }, - { - type: "table", - content: { - type: "tableContent", - rows: [ - { - cells: ["Table Cell", "Table Cell", "Table Cell"], - }, + children: [ { - cells: ["Table Cell", "Table Cell", "Table Cell"], + type: "paragraph", + content: "Hello to the left!", }, + ], + }, + { + type: "column", + props: { + width: 1.2, + }, + children: [ { - cells: ["Table Cell", "Table Cell", "Table Cell"], + type: "paragraph", + content: "Hello to the right!", }, ], }, - }, - { - type: "file", - }, - { - type: "image", - props: { - url: "https://interactive-examples.mdn.mozilla.net/media/cc0-images/grapefruit-slice-332-332.jpg", - caption: - "From https://interactive-examples.mdn.mozilla.net/media/cc0-images/grapefruit-slice-332-332.jpg", - }, - }, - { - type: "video", - props: { - url: "https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm", - caption: - "From https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm", - }, - }, - { - type: "audio", - props: { - url: "https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3", - caption: - "From https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3", - }, - }, - { - type: "paragraph", - }, - { - type: "paragraph", - content: [ - { - type: "text", - text: "Inline Content:", - styles: { bold: true }, - }, - ], - }, - { - type: "paragraph", - content: [ + ], + }, + { + type: "heading", + content: "Heading", + }, + { + type: "bulletListItem", + content: "Bullet List Item", + }, + { + type: "numberedListItem", + content: "Numbered List Item", + }, + { + type: "checkListItem", + content: "Check List Item", + }, + { + type: "codeBlock", + props: { language: "javascript" }, + content: "console.log('Hello, world!');", + }, + { + type: "table", + content: { + type: "tableContent", + rows: [ { - type: "text", - text: "Styled Text", - styles: { - bold: true, - italic: true, - textColor: "red", - backgroundColor: "blue", - }, + cells: ["Table Cell", "Table Cell", "Table Cell"], }, { - type: "text", - text: " ", - styles: {}, + cells: ["Table Cell", "Table Cell", "Table Cell"], }, { - type: "link", - content: "Link", - href: "https://www.blocknotejs.org", + cells: ["Table Cell", "Table Cell", "Table Cell"], }, ], }, - { - type: "paragraph", + }, + { + type: "file", + }, + { + type: "image", + props: { + url: "https://interactive-examples.mdn.mozilla.net/media/cc0-images/grapefruit-slice-332-332.jpg", + caption: + "From https://interactive-examples.mdn.mozilla.net/media/cc0-images/grapefruit-slice-332-332.jpg", + }, + }, + { + type: "video", + props: { + url: "https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm", + caption: + "From https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm", + }, + }, + { + type: "audio", + props: { + url: "https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3", + caption: + "From https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3", }, - ], - }); + }, + { + type: "paragraph", + }, + { + type: "paragraph", + content: [ + { + type: "text", + text: "Inline Content:", + styles: { bold: true }, + }, + ], + }, + { + type: "paragraph", + content: [ + { + type: "text", + text: "Styled Text", + styles: { + bold: true, + italic: true, + textColor: "red", + backgroundColor: "blue", + }, + }, + { + type: "text", + text: " ", + styles: {}, + }, + { + type: "link", + content: "Link", + href: "https://www.blocknotejs.org", + }, + ], + }, + { + type: "paragraph", + }, + ], + // sideMenuDetection: "editor", +} satisfies Partial< + BlockNoteEditorOptions< + typeof schema.blockSchema, + typeof schema.inlineContentSchema, + typeof schema.styleSchema + > +>; - const slashMenuItems = useMemo(() => { - return combineByGroup( - getDefaultReactSlashMenuItems(editor), - getMultiColumnSlashMenuItems(editor) - ); - }, [editor]); +export default function App() { + // Creates a new editor instance. + const editor1 = useCreateBlockNote(options); + const editor2 = useCreateBlockNote(options); // Renders the editor instance using a React component. return ( - - filterSuggestionItems(slashMenuItems, query)} - /> - +
+ + {/**/} +
); } diff --git a/packages/core/src/api/clipboard/__snapshots__/internal/basicBlocks.html b/packages/core/src/api/clipboard/__snapshots__/internal/basicBlocks.html new file mode 100644 index 0000000000..8c7757e46a --- /dev/null +++ b/packages/core/src/api/clipboard/__snapshots__/internal/basicBlocks.html @@ -0,0 +1 @@ +

Paragraph

Heading

  1. Numbered List Item

console.log("Hello World");

Table Cell

Table Cell

Table Cell

Table Cell

Table Cell

Table Cell

Table Cell

Table Cell

Table Cell

Add image

\ No newline at end of file diff --git a/packages/core/src/api/clipboard/__snapshots__/internal/basicBlocksWithProps.html b/packages/core/src/api/clipboard/__snapshots__/internal/basicBlocksWithProps.html new file mode 100644 index 0000000000..4397413824 --- /dev/null +++ b/packages/core/src/api/clipboard/__snapshots__/internal/basicBlocksWithProps.html @@ -0,0 +1 @@ +

Paragraph

Heading

  1. Numbered List Item

console.log("Hello World");

Table Cell

Table Cell

Table Cell

Table Cell

Table Cell

Table Cell

Table Cell

Table Cell

Table Cell

1280px-Placeholder_view_vector.svg.png
Placeholder

\ No newline at end of file diff --git a/packages/core/src/api/clipboard/clipboardInternal.test.ts b/packages/core/src/api/clipboard/clipboardInternal.test.ts index 29b5393f07..0fb136575e 100644 --- a/packages/core/src/api/clipboard/clipboardInternal.test.ts +++ b/packages/core/src/api/clipboard/clipboardInternal.test.ts @@ -148,6 +148,122 @@ describe("Test ProseMirror selection clipboard HTML", () => { type: "customParagraph", content: "Paragraph", }, + { + type: "paragraph", + content: "Paragraph", + }, + { + type: "heading", + content: "Heading", + }, + { + type: "numberedListItem", + content: "Numbered List Item", + }, + { + type: "bulletListItem", + content: "Bullet List Item", + }, + { + type: "checkListItem", + content: "Check List Item", + }, + { + type: "codeBlock", + content: 'console.log("Hello World");', + }, + { + type: "table", + content: { + type: "tableContent", + rows: [ + { + cells: [["Table Cell"], ["Table Cell"], ["Table Cell"]], + }, + { + cells: [["Table Cell"], ["Table Cell"], ["Table Cell"]], + }, + { + cells: [["Table Cell"], ["Table Cell"], ["Table Cell"]], + }, + ], + }, + }, + { + type: "image", + }, + { + type: "paragraph", + props: { + textColor: "red", + }, + content: "Paragraph", + }, + { + type: "heading", + props: { + level: 2, + }, + content: "Heading", + }, + { + type: "numberedListItem", + props: { + start: 2, + }, + content: "Numbered List Item", + }, + { + type: "bulletListItem", + props: { + backgroundColor: "red", + }, + content: "Bullet List Item", + }, + { + type: "checkListItem", + props: { + checked: true, + }, + content: "Check List Item", + }, + { + type: "codeBlock", + props: { + language: "typescript", + }, + content: 'console.log("Hello World");', + }, + { + type: "table", + content: { + type: "tableContent", + rows: [ + { + cells: [["Table Cell"], ["Table Cell"], ["Table Cell"]], + }, + { + cells: [["Table Cell"], ["Table Cell"], ["Table Cell"]], + }, + { + cells: [["Table Cell"], ["Table Cell"], ["Table Cell"]], + }, + ], + }, + }, + { + type: "image", + props: { + name: "1280px-Placeholder_view_vector.svg.png", + url: "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3f/Placeholder_view_vector.svg/1280px-Placeholder_view_vector.svg.png", + caption: "Placeholder", + showPreview: true, + previewWidth: 256, + }, + }, + { + type: "paragraph", + }, ]; let editor: BlockNoteEditor; @@ -299,6 +415,16 @@ describe("Test ProseMirror selection clipboard HTML", () => { createCopySelection: (doc) => TextSelection.create(doc, 277, 286), createPasteSelection: (doc) => TextSelection.create(doc, 290, 299), }, + // Copy/paste basic blocks. + { + testName: "basicBlocks", + createCopySelection: (doc) => TextSelection.create(doc, 303, 558), + }, + // Copy/paste basic blocks with props. + { + testName: "basicBlocksWithProps", + createCopySelection: (doc) => TextSelection.create(doc, 558, 813), + }, ]; for (const testCase of testCases) { diff --git a/packages/core/src/blocks/CodeBlockContent/CodeBlockContent.ts b/packages/core/src/blocks/CodeBlockContent/CodeBlockContent.ts index ce71bba1bc..9d94601670 100644 --- a/packages/core/src/blocks/CodeBlockContent/CodeBlockContent.ts +++ b/packages/core/src/blocks/CodeBlockContent/CodeBlockContent.ts @@ -93,6 +93,7 @@ const CodeBlockContent = createStronglyTypedTiptapNode({ ); }, renderHTML: (attributes) => { + // TODO: Use `data-language="..."` instead for easier parsing return attributes.language && attributes.language !== "text" ? { class: `language-${attributes.language}`, @@ -106,9 +107,11 @@ const CodeBlockContent = createStronglyTypedTiptapNode({ return [ { tag: "div[data-content-type=" + this.name + "]", + contentElement: "code", }, { tag: "pre", + contentElement: "code", preserveWhitespace: "full", }, ]; diff --git a/packages/core/src/blocks/HeadingBlockContent/HeadingBlockContent.ts b/packages/core/src/blocks/HeadingBlockContent/HeadingBlockContent.ts index 2bf825dd6f..f20e0b4197 100644 --- a/packages/core/src/blocks/HeadingBlockContent/HeadingBlockContent.ts +++ b/packages/core/src/blocks/HeadingBlockContent/HeadingBlockContent.ts @@ -124,15 +124,6 @@ const HeadingBlockContent = createStronglyTypedTiptapNode({ return [ { tag: "div[data-content-type=" + this.name + "]", - getAttrs: (element) => { - if (typeof element === "string") { - return false; - } - - return { - level: element.getAttribute("data-level"), - }; - }, }, { tag: "h1", diff --git a/packages/core/src/blocks/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent.ts b/packages/core/src/blocks/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent.ts index 06259794fe..e373c95242 100644 --- a/packages/core/src/blocks/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent.ts +++ b/packages/core/src/blocks/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent.ts @@ -79,7 +79,7 @@ const BulletListItemBlockContent = createStronglyTypedTiptapNode({ return [ // Case for regular HTML list structure. { - tag: "div[data-content-type=" + this.name + "]", // TODO: remove if we can't come up with test case that needs this + tag: "div[data-content-type=" + this.name + "]", }, { tag: "li", diff --git a/packages/core/src/blocks/ListItemBlockContent/CheckListItemBlockContent/CheckListItemBlockContent.ts b/packages/core/src/blocks/ListItemBlockContent/CheckListItemBlockContent/CheckListItemBlockContent.ts index 628df671a4..3c2b2a209d 100644 --- a/packages/core/src/blocks/ListItemBlockContent/CheckListItemBlockContent/CheckListItemBlockContent.ts +++ b/packages/core/src/blocks/ListItemBlockContent/CheckListItemBlockContent/CheckListItemBlockContent.ts @@ -118,7 +118,7 @@ const checkListItemBlockContent = createStronglyTypedTiptapNode({ parseHTML() { return [ { - tag: "div[data-content-type=" + this.name + "]", // TODO: remove if we can't come up with test case that needs this + tag: "div[data-content-type=" + this.name + "]", }, // Checkbox only. { diff --git a/packages/core/src/blocks/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts b/packages/core/src/blocks/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts index f4de5a8ac7..d637122727 100644 --- a/packages/core/src/blocks/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts +++ b/packages/core/src/blocks/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts @@ -106,7 +106,7 @@ const NumberedListItemBlockContent = createStronglyTypedTiptapNode({ parseHTML() { return [ { - tag: "div[data-content-type=" + this.name + "]", // TODO: remove if we can't come up with test case that needs this + tag: "div[data-content-type=" + this.name + "]", }, // Case for regular HTML list structure. // (e.g.: when pasting from other apps) diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index f974af072f..bbfc6388a1 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -245,6 +245,15 @@ export type BlockNoteEditorOptions< @default "prefer-navigate-ui" */ tabBehavior: "prefer-navigate-ui" | "prefer-indent"; + + /** + * The detection mode for showing the side menu - "viewport" always shows the + * side menu for the block next to the mouse cursor, while "editor" only shows + * it when hovering the editor or the side menu itself. + * + * @default "viewport" + */ + sideMenuDetection: "viewport" | "editor"; }; const blockNoteTipTapOptions = { @@ -423,6 +432,7 @@ export class BlockNoteEditor< dropCursor: this.options.dropCursor ?? dropCursor, placeholders: newOptions.placeholders, tabBehavior: newOptions.tabBehavior, + sideMenuDetection: newOptions.sideMenuDetection || "viewport", }); // add extensions from _tiptapOptions diff --git a/packages/core/src/editor/BlockNoteExtensions.ts b/packages/core/src/editor/BlockNoteExtensions.ts index 354ec85bda..ac826dce26 100644 --- a/packages/core/src/editor/BlockNoteExtensions.ts +++ b/packages/core/src/editor/BlockNoteExtensions.ts @@ -72,6 +72,7 @@ type ExtensionOptions< dropCursor: (opts: any) => Plugin; placeholders: Record; tabBehavior?: "prefer-navigate-ui" | "prefer-indent"; + sideMenuDetection: "viewport" | "editor"; }; /** @@ -97,7 +98,10 @@ export const getBlockNoteExtensions = < opts.editor ); ret["linkToolbar"] = new LinkToolbarProsemirrorPlugin(opts.editor); - ret["sideMenu"] = new SideMenuProsemirrorPlugin(opts.editor); + ret["sideMenu"] = new SideMenuProsemirrorPlugin( + opts.editor, + opts.sideMenuDetection + ); ret["suggestionMenus"] = new SuggestionMenuProseMirrorPlugin(opts.editor); ret["filePanel"] = new FilePanelProsemirrorPlugin(opts.editor as any); ret["placeholder"] = new PlaceholderPlugin(opts.editor, opts.placeholders); diff --git a/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts b/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts index eb50f42487..62a1541c8e 100644 --- a/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts +++ b/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts @@ -1,6 +1,6 @@ -import { PluginView } from "@tiptap/pm/state"; -import { EditorState, Plugin, PluginKey } from "prosemirror-state"; -import { EditorView } from "prosemirror-view"; +import { DOMParser, Slice } from "@tiptap/pm/model"; +import { EditorState, Plugin, PluginKey, PluginView } from "@tiptap/pm/state"; +import { EditorView } from "@tiptap/pm/view"; import { Block } from "../../blocks/defaultBlocks.js"; import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; @@ -14,6 +14,7 @@ import { EventEmitter } from "../../util/EventEmitter.js"; import { initializeESMDependencies } from "../../util/esmDependencies.js"; import { getDraggableBlockFromElement } from "../getDraggableBlockFromElement.js"; import { dragStart, unsetDragImage } from "./dragging.js"; + export type SideMenuState< BSchema extends BlockSchema, I extends InlineContentSchema, @@ -28,9 +29,14 @@ const PERCENTAGE_OF_BLOCK_WIDTH_CONSIDERED_SIDE_DROP = 0.1; function getBlockFromCoords( view: EditorView, coords: { left: number; top: number }, + sideMenuDetection: "viewport" | "editor", adjustForColumns = true ) { - const elements = view.root.elementsFromPoint(coords.left, coords.top); + const elements = view.root.elementsFromPoint( + // bit hacky - offset x position to right to account for the width of sidemenu itself + coords.left + (sideMenuDetection === "editor" ? 50 : 0), + coords.top + ); for (const element of elements) { if (!view.dom.contains(element)) { @@ -46,6 +52,7 @@ function getBlockFromCoords( left: coords.left + 50, // bit hacky, but if we're inside a column, offset x position to right to account for the width of sidemenu itself top: coords.top, }, + sideMenuDetection, false ); } @@ -60,7 +67,8 @@ function getBlockFromMousePos( x: number; y: number; }, - view: EditorView + view: EditorView, + sideMenuDetection: "viewport" | "editor" ): { node: HTMLElement; id: string } | undefined { // Editor itself may have padding or other styling which affects // size/position, so we get the boundingRect of the first child (i.e. the @@ -76,7 +84,7 @@ function getBlockFromMousePos( // this.horizontalPosAnchor = editorBoundingBox.x; - // Gets block at mouse cursor's vertical position. + // Gets block at mouse cursor's position. const coords = { left: mousePos.x, top: mousePos.y, @@ -85,15 +93,18 @@ function getBlockFromMousePos( const mouseLeftOfEditor = coords.left < editorBoundingBox.left; const mouseRightOfEditor = coords.left > editorBoundingBox.right; - if (mouseLeftOfEditor) { - coords.left = editorBoundingBox.left + 10; - } + // Clamps the x position to the editor's bounding box. + if (sideMenuDetection === "viewport") { + if (mouseLeftOfEditor) { + coords.left = editorBoundingBox.left + 10; + } - if (mouseRightOfEditor) { - coords.left = editorBoundingBox.right - 10; + if (mouseRightOfEditor) { + coords.left = editorBoundingBox.right - 10; + } } - let block = getBlockFromCoords(view, coords); + let block = getBlockFromCoords(view, coords, sideMenuDetection); if (!mouseRightOfEditor && block) { // note: this case is not necessary when we're on the right side of the editor @@ -101,14 +112,14 @@ function getBlockFromMousePos( /* Now, because blocks can be nested | BlockA | x | BlockB y| - + hovering over position x (the "margin of block B") will return block A instead of block B. to fix this, we get the block from the right side of block A (position y, which will fall in BlockB correctly) */ const rect = block.node.getBoundingClientRect(); coords.left = rect.right - 10; - block = getBlockFromCoords(view, coords, false); + block = getBlockFromCoords(view, coords, "viewport", false); } return block; @@ -132,8 +143,11 @@ export class SideMenuView< public menuFrozen = false; + public isDragOrigin = false; + constructor( private readonly editor: BlockNoteEditor, + private readonly sideMenuDetection: "viewport" | "editor", private readonly pmView: EditorView, emitUpdate: (state: SideMenuState) => void ) { @@ -146,14 +160,18 @@ export class SideMenuView< }; this.pmView.root.addEventListener( - "drop", - this.onDrop as EventListener, - true + "dragstart", + this.onDragStart as EventListener ); this.pmView.root.addEventListener( "dragover", this.onDragOver as EventListener ); + this.pmView.root.addEventListener( + "drop", + this.onDrop as EventListener, + true + ); initializeESMDependencies(); // Shows or updates menu position whenever the cursor moves, if the menu isn't frozen. @@ -181,7 +199,11 @@ export class SideMenuView< return; } - const block = getBlockFromMousePos(this.mousePos, this.pmView); + const block = getBlockFromMousePos( + this.mousePos, + this.pmView, + this.sideMenuDetection + ); // Closes the menu if the mouse cursor is beyond the editor vertically. if (!block || !this.editor.isEditable) { @@ -249,7 +271,16 @@ export class SideMenuView< onDrop = (event: DragEvent) => { this.editor._tiptapEditor.commands.blur(); + // ProseMirror doesn't remove the dragged content if it's dropped outside + // the editor (e.g. to other editors), so we need to do it manually. Since + // the dragged content is the same as the selected content, we can just + // delete the selection. + if (this.isDragOrigin && !this.pmView.dom.contains(event.target as Node)) { + this.pmView.dispatch(this.pmView.state.tr.deleteSelection()); + } + if ( + this.sideMenuDetection === "editor" || (event as any).synthetic || !event.dataTransfer?.types.includes("blocknote/html") ) { @@ -268,6 +299,46 @@ export class SideMenuView< } }; + /** + * If a block is being dragged, ProseMirror usually gets the context of what's + * being dragged from `view.dragging`, which is automatically set when a + * `dragstart` event fires in the editor. However, if the user tries to drag + * and drop blocks between multiple editors, only the one in which the drag + * began has that context, so we need to set it on the others manually. This + * ensures that PM always drops the blocks in between other blocks, and not + * inside them. + * + * After the `dragstart` event fires on the drag handle, it sets + * `blocknote/html` data on the clipboard. This handler fires right after, + * parsing the `blocknote/html` data into nodes and setting them on + * `view.dragging`. + * + * Note: Setting `view.dragging` on `dragover` would be better as the user + * could then drag between editors in different windows, but you can only + * access `dataTransfer` contents on `dragstart` and `drop` events. + */ + onDragStart = (event: DragEvent) => { + if (!this.pmView.dragging) { + const html = event.dataTransfer?.getData("blocknote/html"); + if (!html) { + return; + } + + const element = document.createElement("div"); + element.innerHTML = html; + + const parser = DOMParser.fromSchema(this.pmView.state.schema); + const node = parser.parse(element, { + topNode: this.pmView.state.schema.nodes["blockGroup"].create(), + }); + + this.pmView.dragging = { + slice: new Slice(node.content, 0, 0), + move: true, + }; + } + }; + /** * If the event is outside the editor contents, * we dispatch a fake event, so that we can still drop the content @@ -275,11 +346,13 @@ export class SideMenuView< */ onDragOver = (event: DragEvent) => { if ( + this.sideMenuDetection === "editor" || (event as any).synthetic || !event.dataTransfer?.types.includes("blocknote/html") ) { return; } + const pos = this.pmView.posAtCoords({ left: event.clientX, top: event.clientY, @@ -424,11 +497,14 @@ export class SideMenuView< this.onMouseMove as EventListener, true ); + this.pmView.root.removeEventListener( + "dragstart", + this.onDragStart as EventListener + ); this.pmView.root.removeEventListener( "dragover", this.onDragOver as EventListener ); - this.pmView.root.removeEventListener( "drop", this.onDrop as EventListener, @@ -452,14 +528,22 @@ export class SideMenuProsemirrorPlugin< public view: SideMenuView | undefined; public readonly plugin: Plugin; - constructor(private readonly editor: BlockNoteEditor) { + constructor( + private readonly editor: BlockNoteEditor, + sideMenuDetection: "viewport" | "editor" + ) { super(); this.plugin = new Plugin({ key: sideMenuPluginKey, view: (editorView) => { - this.view = new SideMenuView(editor, editorView, (state) => { - this.emit("update", state); - }); + this.view = new SideMenuView( + editor, + sideMenuDetection, + editorView, + (state) => { + this.emit("update", state); + } + ); return this.view; }, }); @@ -479,6 +563,10 @@ export class SideMenuProsemirrorPlugin< }, block: Block ) => { + if (this.view) { + this.view.isDragOrigin = true; + } + dragStart(event, block, this.editor); }; @@ -489,6 +577,10 @@ export class SideMenuProsemirrorPlugin< if (this.editor.prosemirrorView) { unsetDragImage(this.editor.prosemirrorView.root); } + + if (this.view) { + this.view.isDragOrigin = false; + } }; /** * Freezes the side menu. When frozen, the side menu will stay diff --git a/packages/core/src/extensions/SideMenu/dragging.ts b/packages/core/src/extensions/SideMenu/dragging.ts index 2c8a4bb5d1..1dba4f462e 100644 --- a/packages/core/src/extensions/SideMenu/dragging.ts +++ b/packages/core/src/extensions/SideMenu/dragging.ts @@ -202,6 +202,5 @@ export function dragStart< e.dataTransfer.setData("text/plain", plainText); e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setDragImage(dragImageElement!, 0, 0); - view.dragging = { slice: selectedSlice, move: true }; } } diff --git a/tests/src/end-to-end/copypaste/copypaste.test.ts-snapshots/headings-json-chromium-linux.json b/tests/src/end-to-end/copypaste/copypaste.test.ts-snapshots/headings-json-chromium-linux.json index a777ca1166..efc45cfa6f 100644 --- a/tests/src/end-to-end/copypaste/copypaste.test.ts-snapshots/headings-json-chromium-linux.json +++ b/tests/src/end-to-end/copypaste/copypaste.test.ts-snapshots/headings-json-chromium-linux.json @@ -101,7 +101,7 @@ "type": "heading", "attrs": { "textAlignment": "left", - "level": null + "level": 1 }, "content": [ {