\ 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
Numbered List Item
Bullet List Item
Check List Item
console.log("Hello World");
Table Cell
Table Cell
Table Cell
Table Cell
Table Cell
Table Cell
Table Cell
Table Cell
Table Cell
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": [
{