diff --git a/packages/core/src/api/exporters/copyExtension.ts b/packages/core/src/api/exporters/copyExtension.ts index 0326e24b5c..e226767d12 100644 --- a/packages/core/src/api/exporters/copyExtension.ts +++ b/packages/core/src/api/exporters/copyExtension.ts @@ -1,11 +1,46 @@ import { Extension } from "@tiptap/core"; -import { Plugin } from "prosemirror-state"; +import { NodeSelection, Plugin } from "prosemirror-state"; +import { Node } from "prosemirror-model"; import type { BlockNoteEditor } from "../../editor/BlockNoteEditor"; import { BlockSchema, InlineContentSchema, StyleSchema } from "../../schema"; import { createExternalHTMLExporter } from "./html/externalHTMLExporter"; import { createInternalHTMLSerializer } from "./html/internalHTMLSerializer"; import { cleanHTMLToMarkdown } from "./markdown/markdownExporter"; +import { EditorView } from "prosemirror-view"; + +function selectedFragmentToHTML< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>( + view: EditorView, + editor: BlockNoteEditor +): { + internalHTML: string; + externalHTML: string; + plainText: string; +} { + const selectedFragment = view.state.selection.content().content; + + const internalHTMLSerializer = createInternalHTMLSerializer( + view.state.schema, + editor + ); + const internalHTML = + internalHTMLSerializer.serializeProseMirrorFragment(selectedFragment); + + const externalHTMLExporter = createExternalHTMLExporter( + view.state.schema, + editor + ); + const externalHTML = + externalHTMLExporter.exportProseMirrorFragment(selectedFragment); + + const plainText = cleanHTMLToMarkdown(externalHTML); + + return { internalHTML, externalHTML, plainText }; +} export const createCopyToClipboardExtension = < BSchema extends BlockSchema, @@ -17,39 +52,35 @@ export const createCopyToClipboardExtension = < Extension.create<{ editor: BlockNoteEditor }, undefined>({ name: "copyToClipboard", addProseMirrorPlugins() { - const tiptap = this.editor; - const schema = this.editor.schema; return [ new Plugin({ props: { handleDOMEvents: { - copy(_view, event) { + copy(view, event) { // Stops the default browser copy behaviour. event.preventDefault(); event.clipboardData!.clearData(); - const selectedFragment = - tiptap.state.selection.content().content; - - const internalHTMLSerializer = createInternalHTMLSerializer( - schema, - editor - ); - const internalHTML = - internalHTMLSerializer.serializeProseMirrorFragment( - selectedFragment - ); - - const externalHTMLExporter = createExternalHTMLExporter( - schema, - editor - ); - const externalHTML = - externalHTMLExporter.exportProseMirrorFragment( - selectedFragment + // Checks if a `blockContent` node is being copied and expands + // the selection to the parent `blockContainer` node. This is + // for the use-case in which only a block without content is + // selected, e.g. an image block. + if ( + "node" in view.state.selection && + (view.state.selection.node as Node).type.spec.group === + "blockContent" + ) { + view.dispatch( + view.state.tr.setSelection( + new NodeSelection( + view.state.doc.resolve(view.state.selection.from - 1) + ) + ) ); + } - const plainText = cleanHTMLToMarkdown(externalHTML); + const { internalHTML, externalHTML, plainText } = + selectedFragmentToHTML(view, editor); // TODO: Writing to other MIME types not working in Safari for // some reason. @@ -57,6 +88,48 @@ export const createCopyToClipboardExtension = < event.clipboardData!.setData("text/html", externalHTML); event.clipboardData!.setData("text/plain", plainText); + // Prevent default PM handler to be called + return true; + }, + // This is for the use-case in which only a block without content + // is selected, e.g. an image block, and dragged (not using the + // drag handle). + dragstart(view, event) { + // Checks if a `NodeSelection` is active. + if (!("node" in view.state.selection)) { + return; + } + + // Checks if a `blockContent` node is being dragged. + if ( + (view.state.selection.node as Node).type.spec.group !== + "blockContent" + ) { + return; + } + + // Expands the selection to the parent `blockContainer` node. + view.dispatch( + view.state.tr.setSelection( + new NodeSelection( + view.state.doc.resolve(view.state.selection.from - 1) + ) + ) + ); + + // Stops the default browser drag start behaviour. + event.preventDefault(); + event.dataTransfer!.clearData(); + + const { internalHTML, externalHTML, plainText } = + selectedFragmentToHTML(view, editor); + + // TODO: Writing to other MIME types not working in Safari for + // some reason. + event.dataTransfer!.setData("blocknote/html", internalHTML); + event.dataTransfer!.setData("text/html", externalHTML); + event.dataTransfer!.setData("text/plain", plainText); + // Prevent default PM handler to be called return true; }, diff --git a/packages/core/src/api/exporters/html/__snapshots__/image/basic/internal.html b/packages/core/src/api/exporters/html/__snapshots__/image/basic/internal.html index 080ccf3ce4..218d0a5897 100644 --- a/packages/core/src/api/exporters/html/__snapshots__/image/basic/internal.html +++ b/packages/core/src/api/exporters/html/__snapshots__/image/basic/internal.html @@ -1 +1 @@ -
placeholder

\ No newline at end of file +
placeholder

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/image/button/internal.html b/packages/core/src/api/exporters/html/__snapshots__/image/button/internal.html index 39de1869c4..d646f2cc67 100644 --- a/packages/core/src/api/exporters/html/__snapshots__/image/button/internal.html +++ b/packages/core/src/api/exporters/html/__snapshots__/image/button/internal.html @@ -1 +1 @@ -

\ No newline at end of file +

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/image/nested/internal.html b/packages/core/src/api/exporters/html/__snapshots__/image/nested/internal.html index 5c81aa0f04..b03368d31a 100644 --- a/packages/core/src/api/exporters/html/__snapshots__/image/nested/internal.html +++ b/packages/core/src/api/exporters/html/__snapshots__/image/nested/internal.html @@ -1 +1 @@ -
placeholder

placeholder

\ No newline at end of file +
placeholder

placeholder

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/simpleImage/basic/external.html b/packages/core/src/api/exporters/html/__snapshots__/simpleImage/basic/external.html index b9aa7c2551..4619adfa9c 100644 --- a/packages/core/src/api/exporters/html/__snapshots__/simpleImage/basic/external.html +++ b/packages/core/src/api/exporters/html/__snapshots__/simpleImage/basic/external.html @@ -1 +1 @@ -
placeholder

\ No newline at end of file +
placeholder

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/simpleImage/basic/internal.html b/packages/core/src/api/exporters/html/__snapshots__/simpleImage/basic/internal.html index 305da277ef..7c3cd70a32 100644 --- a/packages/core/src/api/exporters/html/__snapshots__/simpleImage/basic/internal.html +++ b/packages/core/src/api/exporters/html/__snapshots__/simpleImage/basic/internal.html @@ -1 +1 @@ -
placeholder

\ No newline at end of file +
placeholder

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/simpleImage/button/external.html b/packages/core/src/api/exporters/html/__snapshots__/simpleImage/button/external.html index 68a66d027a..f2e492d987 100644 --- a/packages/core/src/api/exporters/html/__snapshots__/simpleImage/button/external.html +++ b/packages/core/src/api/exporters/html/__snapshots__/simpleImage/button/external.html @@ -1 +1 @@ -

\ No newline at end of file +

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/simpleImage/button/internal.html b/packages/core/src/api/exporters/html/__snapshots__/simpleImage/button/internal.html index 07a332a5f4..375ac4f5b7 100644 --- a/packages/core/src/api/exporters/html/__snapshots__/simpleImage/button/internal.html +++ b/packages/core/src/api/exporters/html/__snapshots__/simpleImage/button/internal.html @@ -1 +1 @@ -

\ No newline at end of file +

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/simpleImage/nested/external.html b/packages/core/src/api/exporters/html/__snapshots__/simpleImage/nested/external.html index c1cd943c29..f8e0c810de 100644 --- a/packages/core/src/api/exporters/html/__snapshots__/simpleImage/nested/external.html +++ b/packages/core/src/api/exporters/html/__snapshots__/simpleImage/nested/external.html @@ -1 +1 @@ -
placeholder

placeholder

\ No newline at end of file +
placeholder

placeholder

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/simpleImage/nested/internal.html b/packages/core/src/api/exporters/html/__snapshots__/simpleImage/nested/internal.html index 114116544a..c85a76f475 100644 --- a/packages/core/src/api/exporters/html/__snapshots__/simpleImage/nested/internal.html +++ b/packages/core/src/api/exporters/html/__snapshots__/simpleImage/nested/internal.html @@ -1 +1 @@ -
placeholder

placeholder

\ No newline at end of file +
placeholder

placeholder

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/util/sharedHTMLConversion.ts b/packages/core/src/api/exporters/html/util/sharedHTMLConversion.ts index 4c89a45ebe..7042e30bbb 100644 --- a/packages/core/src/api/exporters/html/util/sharedHTMLConversion.ts +++ b/packages/core/src/api/exporters/html/util/sharedHTMLConversion.ts @@ -32,6 +32,7 @@ export const serializeNodeInner = < if (!serializer.nodes[node.type.name]) { throw new Error("Serializer is missing a node type: " + node.type.name); } + const { dom, contentDOM } = DOMSerializer.renderSpec( doc(options), serializer.nodes[node.type.name](node) diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/simpleImage/button/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/simpleImage/button/markdown.md index d642ea87c6..e69de29bb2 100644 --- a/packages/core/src/api/exporters/markdown/__snapshots__/simpleImage/button/markdown.md +++ b/packages/core/src/api/exporters/markdown/__snapshots__/simpleImage/button/markdown.md @@ -1 +0,0 @@ -![placeholder]() diff --git a/packages/core/src/blocks/ImageBlockContent/ImageBlockContent.ts b/packages/core/src/blocks/ImageBlockContent/ImageBlockContent.ts index deecfe6a76..7043bae535 100644 --- a/packages/core/src/blocks/ImageBlockContent/ImageBlockContent.ts +++ b/packages/core/src/blocks/ImageBlockContent/ImageBlockContent.ts @@ -69,7 +69,6 @@ export const renderImage = ( // Button element that acts as a placeholder for images with no src. const addImageButton = document.createElement("div"); addImageButton.className = "bn-add-image-button"; - addImageButton.style.display = block.props.url === "" ? "" : "none"; // Icon for the add image button. const addImageButtonIcon = document.createElement("div"); @@ -83,12 +82,10 @@ export const renderImage = ( // Wrapper element for the image, resize handles and caption. const imageAndCaptionWrapper = document.createElement("div"); imageAndCaptionWrapper.className = "bn-image-and-caption-wrapper"; - imageAndCaptionWrapper.style.display = block.props.url !== "" ? "" : "none"; // Wrapper element for the image and resize handles. const imageWrapper = document.createElement("div"); imageWrapper.className = "bn-image-wrapper"; - imageWrapper.style.display = block.props.url !== "" ? "" : "none"; // Image element. const image = document.createElement("img"); @@ -205,8 +202,8 @@ export const renderImage = ( imageWrapper.contains(leftResizeHandle) && imageWrapper.contains(rightResizeHandle) ) { - leftResizeHandle.style.display = "none"; - rightResizeHandle.style.display = "none"; + imageWrapper.removeChild(leftResizeHandle); + imageWrapper.removeChild(rightResizeHandle); } resizeParams = undefined; @@ -236,11 +233,11 @@ export const renderImage = ( // Shows the resize handles when hovering over the image with the cursor. const imageMouseEnterHandler = () => { if (editor.isEditable) { - leftResizeHandle.style.display = "block"; - rightResizeHandle.style.display = "block"; + imageWrapper.appendChild(leftResizeHandle); + imageWrapper.appendChild(rightResizeHandle); } else { - leftResizeHandle.style.display = "none"; - rightResizeHandle.style.display = "none"; + imageWrapper.removeChild(leftResizeHandle); + imageWrapper.removeChild(rightResizeHandle); } }; // Hides the resize handles when the cursor leaves the image, unless the @@ -257,8 +254,8 @@ export const renderImage = ( return; } - leftResizeHandle.style.display = "none"; - rightResizeHandle.style.display = "none"; + imageWrapper.removeChild(leftResizeHandle); + imageWrapper.removeChild(rightResizeHandle); }; // Sets the resize params, allowing the user to begin resizing the image by @@ -266,8 +263,8 @@ export const renderImage = ( const leftResizeHandleMouseDownHandler = (event: MouseEvent) => { event.preventDefault(); - leftResizeHandle.style.display = "block"; - rightResizeHandle.style.display = "block"; + imageWrapper.appendChild(leftResizeHandle); + imageWrapper.appendChild(rightResizeHandle); resizeParams = { handleUsed: "left", @@ -278,8 +275,8 @@ export const renderImage = ( const rightResizeHandleMouseDownHandler = (event: MouseEvent) => { event.preventDefault(); - leftResizeHandle.style.display = "block"; - rightResizeHandle.style.display = "block"; + imageWrapper.appendChild(leftResizeHandle); + imageWrapper.appendChild(rightResizeHandle); resizeParams = { handleUsed: "right", @@ -288,16 +285,19 @@ export const renderImage = ( }; }; - wrapper.appendChild(addImageButton); addImageButton.appendChild(addImageButtonIcon); addImageButton.appendChild(addImageButtonText); - wrapper.appendChild(imageAndCaptionWrapper); + imageAndCaptionWrapper.appendChild(imageWrapper); imageWrapper.appendChild(image); - imageWrapper.appendChild(leftResizeHandle); - imageWrapper.appendChild(rightResizeHandle); imageAndCaptionWrapper.appendChild(caption); + if (block.props.url === "") { + wrapper.appendChild(addImageButton); + } else { + wrapper.appendChild(imageAndCaptionWrapper); + } + window.addEventListener("mousemove", windowMouseMoveHandler); window.addEventListener("mouseup", windowMouseUpHandler); addImageButton.addEventListener("mousedown", addImageButtonMouseDownHandler); diff --git a/packages/core/src/editor/Block.css b/packages/core/src/editor/Block.css index bc5ea21eb2..45f647b840 100644 --- a/packages/core/src/editor/Block.css +++ b/packages/core/src/editor/Block.css @@ -283,7 +283,6 @@ NESTED BLOCKS } [data-content-type="image"] .bn-image-resize-handle { - display: none; position: absolute; width: 8px; height: 30px; diff --git a/packages/core/src/extensions/NonEditableBlocks/NonEditableBlockPlugin.ts b/packages/core/src/extensions/NonEditableBlocks/NonEditableBlockPlugin.ts index b650dc3990..3cd6a0d673 100644 --- a/packages/core/src/extensions/NonEditableBlocks/NonEditableBlockPlugin.ts +++ b/packages/core/src/extensions/NonEditableBlocks/NonEditableBlockPlugin.ts @@ -8,8 +8,19 @@ export const NonEditableBlockPlugin = () => { key: PLUGIN_KEY, props: { handleKeyDown: (view, event) => { + // Checks for node selection if ("node" in view.state.selection) { - event.preventDefault(); + // Checks if key input will insert a character - we want to block this + // as it will convert the block into a paragraph. + if ( + event.key.length === 1 && + !event.ctrlKey && + !event.altKey && + !event.metaKey && + !event.shiftKey + ) { + event.preventDefault(); + } } }, }, diff --git a/tests/src/end-to-end/copypaste/copypaste.test.ts b/tests/src/end-to-end/copypaste/copypaste.test.ts index d39c357210..60b8ed638a 100644 --- a/tests/src/end-to-end/copypaste/copypaste.test.ts +++ b/tests/src/end-to-end/copypaste/copypaste.test.ts @@ -2,6 +2,7 @@ import { test } from "../../setup/setupScript"; import { BASE_URL } from "../../utils/const"; import { + copyPaste, copyPasteAll, insertHeading, insertListItems, @@ -185,12 +186,7 @@ test.describe("Check Copy/Paste Functionality", () => { await page.mouse.up(); - await page.click(`img`); - await page.keyboard.press("ArrowDown"); - await page.pause(); - - await copyPasteAll(page); - await page.pause(); + await copyPaste(page); await compareDocToSnapshot(page, "images.json"); }); diff --git a/tests/src/end-to-end/copypaste/copypaste.test.ts-snapshots/images-json-chromium-linux.json b/tests/src/end-to-end/copypaste/copypaste.test.ts-snapshots/images-json-chromium-linux.json index 04c7045205..5423a0fb6f 100644 --- a/tests/src/end-to-end/copypaste/copypaste.test.ts-snapshots/images-json-chromium-linux.json +++ b/tests/src/end-to-end/copypaste/copypaste.test.ts-snapshots/images-json-chromium-linux.json @@ -68,28 +68,6 @@ "textColor": "default", "backgroundColor": "default" }, - "content": [ - { - "type": "paragraph", - "attrs": { - "textAlignment": "left" - }, - "content": [ - { - "type": "text", - "text": "paragraph" - } - ] - } - ] - }, - { - "type": "blockContainer", - "attrs": { - "id": "5", - "textColor": "default", - "backgroundColor": "default" - }, "content": [ { "type": "image", @@ -105,7 +83,7 @@ { "type": "blockContainer", "attrs": { - "id": "6", + "id": "5", "textColor": "default", "backgroundColor": "default" }, diff --git a/tests/src/utils/copypaste.ts b/tests/src/utils/copypaste.ts index feb55176b6..d53756127c 100644 --- a/tests/src/utils/copypaste.ts +++ b/tests/src/utils/copypaste.ts @@ -2,16 +2,22 @@ import { Page } from "@playwright/test"; import { PASTE_ZONE_SELECTOR, TYPE_DELAY } from "./const"; import { focusOnEditor } from "./editor"; -export async function copyPasteAll(page: Page, os: "mac" | "linux" = "linux") { +export async function copyPaste(page: Page, os: "mac" | "linux" = "linux") { const modifierKey = os === "mac" ? "Meta" : "Control"; - await page.keyboard.press(`${modifierKey}+A`); await page.keyboard.press(`${modifierKey}+C`); await page.keyboard.press("ArrowDown", { delay: TYPE_DELAY }); await page.keyboard.press("Enter"); await page.keyboard.press(`${modifierKey}+V`); } +export async function copyPasteAll(page: Page, os: "mac" | "linux" = "linux") { + const modifierKey = os === "mac" ? "Meta" : "Control"; + + await page.keyboard.press(`${modifierKey}+A`); + await copyPaste(page, os); +} + export async function copyPasteAllExternal( page: Page, os: "mac" | "linux" = "linux"