From 6585bcd54edb030923e3ab0b167ab680f7e690bf Mon Sep 17 00:00:00 2001 From: matthewlipski Date: Wed, 18 Sep 2024 13:49:29 +0200 Subject: [PATCH 1/4] Made it possible to select text in blocks without inline content --- .../core/src/api/exporters/copyExtension.ts | 116 ++++-------------- packages/core/src/editor/Block.css | 6 +- packages/core/src/editor/BlockNoteEditor.ts | 1 + .../core/src/editor/BlockNoteExtensions.ts | 5 +- packages/core/src/editor/tiptap.css | 77 ++++++++++++ .../FullySelectedNodeExtension.ts | 72 +++++++++++ packages/core/src/index.ts | 1 + packages/core/src/schema/blocks/createSpec.ts | 88 ++++++++++++- packages/core/src/schema/blocks/types.ts | 2 + packages/core/src/style.css | 1 + packages/react/src/schema/ReactBlockSpec.tsx | 18 ++- 11 files changed, 287 insertions(+), 100 deletions(-) create mode 100644 packages/core/src/editor/tiptap.css create mode 100644 packages/core/src/extensions/FullySelectedNode/FullySelectedNodeExtension.ts diff --git a/packages/core/src/api/exporters/copyExtension.ts b/packages/core/src/api/exporters/copyExtension.ts index 8a168387a0..0560bb3946 100644 --- a/packages/core/src/api/exporters/copyExtension.ts +++ b/packages/core/src/api/exporters/copyExtension.ts @@ -10,44 +10,6 @@ import { createExternalHTMLExporter } from "./html/externalHTMLExporter"; import { createInternalHTMLSerializer } from "./html/internalHTMLSerializer"; import { cleanHTMLToMarkdown } from "./markdown/markdownExporter"; -async function selectedFragmentToHTML< - BSchema extends BlockSchema, - I extends InlineContentSchema, - S extends StyleSchema ->( - view: EditorView, - editor: BlockNoteEditor -): Promise<{ - 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, - {} - ); - - await initializeESMDependencies(); - const externalHTMLExporter = createExternalHTMLExporter( - view.state.schema, - editor - ); - const externalHTML = externalHTMLExporter.exportProseMirrorFragment( - selectedFragment, - {} - ); - - const plainText = await cleanHTMLToMarkdown(externalHTML); - - return { internalHTML, externalHTML, plainText }; -} - const copyToClipboard = < BSchema extends BlockSchema, I extends InlineContentSchema, @@ -65,20 +27,35 @@ const copyToClipboard = < // 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 ( + const fragment = "node" in view.state.selection && (view.state.selection.node as Node).type.spec.group === "blockContent" - ) { - editor.dispatch( - editor._tiptapEditor.state.tr.setSelection( - new NodeSelection(view.state.doc.resolve(view.state.selection.from - 1)) - ) - ); - } + ? new NodeSelection( + view.state.doc.resolve(view.state.selection.from - 1) + ).content().content + : view.state.selection.content().content; (async () => { - const { internalHTML, externalHTML, plainText } = - await selectedFragmentToHTML(view, editor); + const internalHTMLSerializer = createInternalHTMLSerializer( + view.state.schema, + editor + ); + const internalHTML = internalHTMLSerializer.serializeProseMirrorFragment( + fragment, + {} + ); + + await initializeESMDependencies(); + const externalHTMLExporter = createExternalHTMLExporter( + view.state.schema, + editor + ); + const externalHTML = externalHTMLExporter.exportProseMirrorFragment( + fragment, + {} + ); + + const plainText = cleanHTMLToMarkdown(externalHTML); // TODO: Writing to other MIME types not working in Safari for // some reason. @@ -113,49 +90,6 @@ export const createCopyToClipboardExtension = < // 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. - editor.dispatch( - editor._tiptapEditor.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(); - - (async () => { - const { internalHTML, externalHTML, plainText } = - await 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/editor/Block.css b/packages/core/src/editor/Block.css index cc9df706d9..a2f2293c3b 100644 --- a/packages/core/src/editor/Block.css +++ b/packages/core/src/editor/Block.css @@ -268,11 +268,13 @@ NESTED BLOCKS } [data-file-block] .bn-file-block-content-wrapper { - cursor: pointer; display: flex; flex-direction: column; justify-content: stretch; - user-select: none; +} + +[data-file-block] .bn-visual-media-wrapper { + cursor: pointer; } [data-file-block] .bn-add-file-button { diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index 2551023ce9..b4af907ef9 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -393,6 +393,7 @@ export class BlockNoteEditor< } const tiptapOptions: BlockNoteTipTapEditorOptions = { + injectCSS: false, ...blockNoteTipTapOptions, ...newOptions._tiptapOptions, content: initialContent, diff --git a/packages/core/src/editor/BlockNoteExtensions.ts b/packages/core/src/editor/BlockNoteExtensions.ts index b64eccdbe9..c172bcce89 100644 --- a/packages/core/src/editor/BlockNoteExtensions.ts +++ b/packages/core/src/editor/BlockNoteExtensions.ts @@ -15,6 +15,7 @@ import { createCopyToClipboardExtension } from "../api/exporters/copyExtension"; import { createPasteFromClipboardExtension } from "../api/parsers/pasteExtension"; import { createDropFileExtension } from "../api/parsers/fileDropExtension"; import { BackgroundColorExtension } from "../extensions/BackgroundColor/BackgroundColorExtension"; +import { FullySelectedNodeExtension } from "../extensions/FullySelectedNode/FullySelectedNodeExtension"; import { TextAlignmentExtension } from "../extensions/TextAlignment/TextAlignmentExtension"; import { TextColorExtension } from "../extensions/TextColor/TextColorExtension"; import { TrailingNode } from "../extensions/TrailingNode/TrailingNodeExtension"; @@ -155,6 +156,8 @@ export const getBlockNoteExtensions = < ...(opts.trailingBlock === undefined || opts.trailingBlock ? [TrailingNode] : []), + + FullySelectedNodeExtension, ]; if (opts.collaboration) { @@ -197,5 +200,5 @@ export const getBlockNoteExtensions = < } const disableExtensions: string[] = opts.disableExtensions || []; - return ret.filter(ex => !disableExtensions.includes(ex.name)); + return ret.filter((ex) => !disableExtensions.includes(ex.name)); }; diff --git a/packages/core/src/editor/tiptap.css b/packages/core/src/editor/tiptap.css new file mode 100644 index 0000000000..705aa07072 --- /dev/null +++ b/packages/core/src/editor/tiptap.css @@ -0,0 +1,77 @@ +/* From https://github.com/ueberdosis/tiptap/blob/a170cf4057de98d0350e318c51e57e2998fac38e/packages/core/src/style.ts */ +.ProseMirror { + position: relative; +} + +.ProseMirror { + word-wrap: break-word; + white-space: pre-wrap; + white-space: break-spaces; + -webkit-font-variant-ligatures: none; + font-variant-ligatures: none; + font-feature-settings: "liga" 0; /* the above doesn't seem to work in Edge */ +} + +.ProseMirror [contenteditable="false"] { + white-space: normal; +} + +.ProseMirror [contenteditable="false"] [contenteditable="true"] { + white-space: pre-wrap; +} + +.ProseMirror pre { + white-space: pre-wrap; +} + +img.ProseMirror-separator { + display: inline !important; + border: none !important; + margin: 0 !important; + width: 1px !important; + height: 1px !important; +} + +.ProseMirror-gapcursor { + display: none; + pointer-events: none; + position: absolute; + margin: 0; +} + +.ProseMirror-gapcursor:after { + content: ""; + display: block; + position: absolute; + top: -2px; + width: 20px; + border-top: 1px solid black; + animation: ProseMirror-cursor-blink 1.1s steps(2, start) infinite; +} + +@keyframes ProseMirror-cursor-blink { + to { + visibility: hidden; + } +} + +/* Edited section to replace `.ProseMirror-hideselection` with `.ProseMirror-fullyselected */ +.ProseMirror-fullyselected *::selection { + background: transparent; +} + +.ProseMirror-fullyselected *::-moz-selection { + background: transparent; +} + +.ProseMirror-fullyselected * { + caret-color: transparent; +} + +.ProseMirror-focused .ProseMirror-gapcursor { + display: block; +} + +.tippy-box[data-animation=fade][data-state=hidden] { + opacity: 0 +} \ No newline at end of file diff --git a/packages/core/src/extensions/FullySelectedNode/FullySelectedNodeExtension.ts b/packages/core/src/extensions/FullySelectedNode/FullySelectedNodeExtension.ts new file mode 100644 index 0000000000..9e0c342f50 --- /dev/null +++ b/packages/core/src/extensions/FullySelectedNode/FullySelectedNodeExtension.ts @@ -0,0 +1,72 @@ +import { Editor, Extension } from "@tiptap/core"; +import { getBlockInfoFromPos } from "../../api/getBlockInfoFromPos"; + +// Removes the `ProseMirror-fullyselected` class name from the editor when a +// NodeSelection is active on a block without inline content, but the DOM +// selection is within the node, rather than fully wrapping it. These 2 +// scenarios look identical in the editor state, so we need to check the DOM +// selection to differentiate them. +const onSelectionChange = (editor: Editor) => { + const selection = document.getSelection(); + if (selection === null) { + return; + } + + // selectionchange events don't bubble, so we have to scope them in this way + // instead of setting the listener on the editor element. + if ( + !editor.view.dom.contains(selection.anchorNode) || + !editor.view.dom.contains(selection.focusNode) + ) { + return; + } + + // Node selection is active. + const isNodeSelection = "node" in editor.state.selection; + if (!isNodeSelection) { + editor.view.dom.classList.remove("ProseMirror-fullyselected"); + return; + } + + const blockInfo = getBlockInfoFromPos( + editor.state.doc, + editor.state.selection.from + ); + + // Selected block has no inline content. + const selectedNodeHasNoContent = + blockInfo.contentNode.type.spec.content === ""; + if (!selectedNodeHasNoContent) { + editor.view.dom.classList.remove("ProseMirror-fullyselected"); + return; + } + + const blockElement = editor.view.domAtPos(blockInfo.startPos).node; + + if ( + // Selection doesn't wrap this node. + selection.type !== "Range" || + selection.anchorNode !== blockElement || + selection.focusNode !== blockElement || + selection.anchorOffset !== 0 || + selection.focusOffset !== 1 + ) { + editor.view.dom.classList.remove("ProseMirror-fullyselected"); + } else if (!editor.view.dom.classList.contains("ProseMirror-fullyselected")) { + editor.view.dom.classList.add("ProseMirror-fullyselected"); + } +}; + +export const FullySelectedNodeExtension = Extension.create({ + name: "fullySelectedNode", + onCreate() { + document.addEventListener("selectionchange", () => { + onSelectionChange(this.editor); + }); + }, + onDestroy() { + document.removeEventListener("selectionchange", () => { + onSelectionChange(this.editor); + }); + }, +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9f352e54ba..ee6dce5b02 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,4 +1,5 @@ import * as locales from "./i18n/locales"; +export * from "./api/getBlockInfoFromPos"; export * from "./api/exporters/html/externalHTMLExporter"; export * from "./api/exporters/html/internalHTMLSerializer"; export * from "./api/getCurrentBlockContentType"; diff --git a/packages/core/src/schema/blocks/createSpec.ts b/packages/core/src/schema/blocks/createSpec.ts index 25fb94a9a5..58160697ed 100644 --- a/packages/core/src/schema/blocks/createSpec.ts +++ b/packages/core/src/schema/blocks/createSpec.ts @@ -1,4 +1,8 @@ +import { NodeViewRendererProps } from "@tiptap/core"; import { TagParseRule } from "@tiptap/pm/model"; +import { NodeSelection } from "@tiptap/pm/state"; +import { NodeView } from "@tiptap/pm/view"; + import type { BlockNoteEditor } from "../../editor/BlockNoteEditor"; import { InlineContentSchema } from "../inlineContent/types"; import { StyleSchema } from "../styles/types"; @@ -61,6 +65,75 @@ export type CustomBlockImplementation< ) => PartialBlockFromConfig["props"] | undefined; }; +export function fixNodeViewTextSelection( + props: NodeViewRendererProps, + nodeView: NodeView +) { + // Necessary for DOM to handle selections. + nodeView.ignoreMutation = () => true; + + // Prevents selecting the node from making it draggable, and prevents the DOM selection from being visible when it wraps the node. + nodeView.selectNode = () => { + (nodeView.dom as HTMLElement).classList.add("ProseMirror-selectednode"); + props.editor.view.dom.classList.add("ProseMirror-fullyselected"); + }; + + nodeView.stopEvent = (event) => { + // Let the browser handle copy events, as these only fire when the whole + // node isn't selected. + if (event.type === "cut" || event.type === "copy") { + return true; + } + + // Prevent all drag events. + if (event.type.startsWith("drag")) { + event.preventDefault(); + return true; + } + + // Keyboard events should be handled by the browser. This doesn't prevent + // BlockNote's own key handlers from firing. + if (event.type.startsWith("key")) { + return true; + } + + // Select the node on mouse down, if it isn't already selected. + if (event.type === "mousedown") { + if (typeof props.getPos !== "function") { + return false; + } + + const nodeStartPos = props.getPos(); + const nodeEndPos = nodeStartPos + props.node.nodeSize; + const selectionStartPos = props.editor.view.state.selection.from; + const selectionEndPos = props.editor.view.state.selection.to; + + // Node is selected in the editor state. + const nodeIsSelected = + nodeStartPos === selectionStartPos && nodeEndPos === selectionEndPos; + + if (!nodeIsSelected) { + // Select node in editor state if not already selected. + props.editor.view.dispatch( + props.editor.view.state.tr.setSelection( + NodeSelection.create(props.editor.view.state.doc, nodeStartPos) + ) + ); + } + + // If the target element contains only text, the browser should handle + // the event to update the selection. Otherwise, the event should be + // handled by BlockNote to select the block. + return ( + (event.target as HTMLElement)?.innerText === + (event.target as HTMLElement)?.innerHTML + ); + } + + return false; + }; +} + // Function that uses the 'parse' function of a blockConfig to create a // TipTap node's `parseHTML` property. This is only used for parsing content // from the clipboard. @@ -147,12 +220,12 @@ export function createBlockSpec< }, addNodeView() { - return ({ getPos }) => { + return (props) => { // Gets the BlockNote editor instance const editor = this.options.editor; // Gets the block const block = getBlockFromPos( - getPos, + props.getPos, editor, this.editor, blockConfig.type @@ -163,13 +236,22 @@ export function createBlockSpec< const output = blockImplementation.render(block as any, editor); - return wrapInBlockStructure( + const nodeView: NodeView = wrapInBlockStructure( output, block.type, block.props, blockConfig.propSchema, blockContentDOMAttributes ); + + if ( + blockConfig.content === "none" && + blockConfig.canSelectText === true + ) { + fixNodeViewTextSelection(props, nodeView); + } + + return nodeView; }; }, }); diff --git a/packages/core/src/schema/blocks/types.ts b/packages/core/src/schema/blocks/types.ts index 1caf78db8c..73bc02e47f 100644 --- a/packages/core/src/schema/blocks/types.ts +++ b/packages/core/src/schema/blocks/types.ts @@ -49,6 +49,7 @@ export type FileBlockConfig = { }; }; content: "none"; + canSelectText?: boolean; isFileBlock: true; fileBlockAccept?: string[]; }; @@ -60,6 +61,7 @@ export type BlockConfig = type: string; readonly propSchema: PropSchema; content: "inline" | "none" | "table"; + canSelectText?: boolean; isFileBlock?: false; } | FileBlockConfig; diff --git a/packages/core/src/style.css b/packages/core/src/style.css index 8d073cf1e0..214753051e 100644 --- a/packages/core/src/style.css +++ b/packages/core/src/style.css @@ -1,2 +1,3 @@ @import url("./editor/Block.css"); @import url("./editor/editor.css"); +@import url("./editor/tiptap.css"); diff --git a/packages/react/src/schema/ReactBlockSpec.tsx b/packages/react/src/schema/ReactBlockSpec.tsx index ba36009a62..b0a5d90f1a 100644 --- a/packages/react/src/schema/ReactBlockSpec.tsx +++ b/packages/react/src/schema/ReactBlockSpec.tsx @@ -6,6 +6,7 @@ import { createInternalBlockSpec, createStronglyTypedTiptapNode, CustomBlockConfig, + fixNodeViewTextSelection, getBlockFromPos, getParseRules, inheritedProps, @@ -18,6 +19,7 @@ import { StyleSchema, } from "@blocknote/core"; import { + NodeView, NodeViewContent, NodeViewProps, NodeViewWrapper, @@ -140,8 +142,8 @@ export function createReactBlockSpec< }, addNodeView() { - return (props) => - ReactNodeViewRenderer( + return (props) => { + const nodeView = ReactNodeViewRenderer( (props: NodeViewProps) => { // Gets the BlockNote editor instance const editor = this.options.editor! as BlockNoteEditor; @@ -178,7 +180,17 @@ export function createReactBlockSpec< { className: "bn-react-node-view-renderer", } - )(props); + )(props) as NodeView; + + if ( + blockConfig.content === "none" && + blockConfig.canSelectText === true + ) { + fixNodeViewTextSelection(props, nodeView); + } + + return nodeView; + }; }, }); From 408e3de78a31a39e3df1b11b16f5d938a4ff55d4 Mon Sep 17 00:00:00 2001 From: yousefed Date: Wed, 18 Sep 2024 20:06:00 +0200 Subject: [PATCH 2/4] fix bug? --- .../FullySelectedNodeExtension.ts | 24 ++++++++++++++++++- packages/core/src/schema/blocks/createSpec.ts | 8 +------ 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/packages/core/src/extensions/FullySelectedNode/FullySelectedNodeExtension.ts b/packages/core/src/extensions/FullySelectedNode/FullySelectedNodeExtension.ts index 9e0c342f50..dd6168532d 100644 --- a/packages/core/src/extensions/FullySelectedNode/FullySelectedNodeExtension.ts +++ b/packages/core/src/extensions/FullySelectedNode/FullySelectedNodeExtension.ts @@ -7,11 +7,33 @@ import { getBlockInfoFromPos } from "../../api/getBlockInfoFromPos"; // scenarios look identical in the editor state, so we need to check the DOM // selection to differentiate them. const onSelectionChange = (editor: Editor) => { + const isNodeSelection = "node" in editor.state.selection; const selection = document.getSelection(); if (selection === null) { return; } + if (selection.type === "None") { + if (isNodeSelection) { + // The browser just reset the selection to None (for example, after single clicking a piece of text within the block), + // we want to reset the selection to span the entire block + const blockInfo = getBlockInfoFromPos( + editor.state.doc, + editor.state.selection.from + ); + // TODO: check if fix should be applied for this node + + // set selection to blcok + const range = document.createRange(); + const blockElement = editor.view.domAtPos(blockInfo.startPos).node; + console.log("update selection", blockElement); + range.selectNode(blockElement.firstChild); + selection.removeAllRanges(); + selection.addRange(range); + } + return; + } + // selectionchange events don't bubble, so we have to scope them in this way // instead of setting the listener on the editor element. if ( @@ -22,7 +44,7 @@ const onSelectionChange = (editor: Editor) => { } // Node selection is active. - const isNodeSelection = "node" in editor.state.selection; + if (!isNodeSelection) { editor.view.dom.classList.remove("ProseMirror-fullyselected"); return; diff --git a/packages/core/src/schema/blocks/createSpec.ts b/packages/core/src/schema/blocks/createSpec.ts index 58160697ed..a2094d743b 100644 --- a/packages/core/src/schema/blocks/createSpec.ts +++ b/packages/core/src/schema/blocks/createSpec.ts @@ -121,13 +121,7 @@ export function fixNodeViewTextSelection( ); } - // If the target element contains only text, the browser should handle - // the event to update the selection. Otherwise, the event should be - // handled by BlockNote to select the block. - return ( - (event.target as HTMLElement)?.innerText === - (event.target as HTMLElement)?.innerHTML - ); + return true; } return false; From 38093f28ddac38d2d83088929f35ee2195edb269 Mon Sep 17 00:00:00 2001 From: matthewlipski Date: Thu, 19 Sep 2024 14:27:24 +0200 Subject: [PATCH 3/4] Implemented PR feedback & cleaned up code --- .../core/src/api/exporters/copyExtension.ts | 105 ++++++++++++++--- packages/core/src/editor/BlockNoteEditor.ts | 1 - .../core/src/editor/BlockNoteExtensions.ts | 24 +++- packages/core/src/editor/tiptap.css | 77 ------------ .../FullySelectedNodeExtension.ts | 94 --------------- .../TextSelection/TextSelectionExtension.ts | 111 ++++++++++++++++++ packages/core/src/schema/blocks/createSpec.ts | 19 ++- packages/core/src/schema/blocks/types.ts | 4 +- packages/core/src/style.css | 1 - packages/react/src/schema/ReactBlockSpec.tsx | 2 +- 10 files changed, 237 insertions(+), 201 deletions(-) delete mode 100644 packages/core/src/editor/tiptap.css delete mode 100644 packages/core/src/extensions/FullySelectedNode/FullySelectedNodeExtension.ts create mode 100644 packages/core/src/extensions/TextSelection/TextSelectionExtension.ts diff --git a/packages/core/src/api/exporters/copyExtension.ts b/packages/core/src/api/exporters/copyExtension.ts index 0560bb3946..2e8f0ca659 100644 --- a/packages/core/src/api/exporters/copyExtension.ts +++ b/packages/core/src/api/exporters/copyExtension.ts @@ -1,5 +1,5 @@ import { Extension } from "@tiptap/core"; -import { Node } from "prosemirror-model"; +import { Fragment, Node } from "prosemirror-model"; import { NodeSelection, Plugin } from "prosemirror-state"; import { EditorView } from "prosemirror-view"; @@ -10,6 +10,43 @@ import { createExternalHTMLExporter } from "./html/externalHTMLExporter"; import { createInternalHTMLSerializer } from "./html/internalHTMLSerializer"; import { cleanHTMLToMarkdown } from "./markdown/markdownExporter"; +async function fragmentToHTML< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>( + fragment: Fragment, + view: EditorView, + editor: BlockNoteEditor +): Promise<{ + internalHTML: string; + externalHTML: string; + plainText: string; +}> { + const internalHTMLSerializer = createInternalHTMLSerializer( + view.state.schema, + editor + ); + const internalHTML = internalHTMLSerializer.serializeProseMirrorFragment( + fragment, + {} + ); + + await initializeESMDependencies(); + const externalHTMLExporter = createExternalHTMLExporter( + view.state.schema, + editor + ); + const externalHTML = externalHTMLExporter.exportProseMirrorFragment( + fragment, + {} + ); + + const plainText = await cleanHTMLToMarkdown(externalHTML); + + return { internalHTML, externalHTML, plainText }; +} + const copyToClipboard = < BSchema extends BlockSchema, I extends InlineContentSchema, @@ -36,26 +73,11 @@ const copyToClipboard = < : view.state.selection.content().content; (async () => { - const internalHTMLSerializer = createInternalHTMLSerializer( - view.state.schema, - editor - ); - const internalHTML = internalHTMLSerializer.serializeProseMirrorFragment( + const { plainText, internalHTML, externalHTML } = await fragmentToHTML( fragment, - {} - ); - - await initializeESMDependencies(); - const externalHTMLExporter = createExternalHTMLExporter( - view.state.schema, + view, editor ); - const externalHTML = externalHTMLExporter.exportProseMirrorFragment( - fragment, - {} - ); - - const plainText = cleanHTMLToMarkdown(externalHTML); // TODO: Writing to other MIME types not working in Safari for // some reason. @@ -90,6 +112,53 @@ export const createCopyToClipboardExtension = < // 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. + editor.dispatch( + editor._tiptapEditor.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(); + + (async () => { + const { internalHTML, externalHTML, plainText } = + await fragmentToHTML( + view.state.selection.content().content, + 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/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index b4af907ef9..2551023ce9 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -393,7 +393,6 @@ export class BlockNoteEditor< } const tiptapOptions: BlockNoteTipTapEditorOptions = { - injectCSS: false, ...blockNoteTipTapOptions, ...newOptions._tiptapOptions, content: initialContent, diff --git a/packages/core/src/editor/BlockNoteExtensions.ts b/packages/core/src/editor/BlockNoteExtensions.ts index c172bcce89..3cd7a2951f 100644 --- a/packages/core/src/editor/BlockNoteExtensions.ts +++ b/packages/core/src/editor/BlockNoteExtensions.ts @@ -15,9 +15,12 @@ import { createCopyToClipboardExtension } from "../api/exporters/copyExtension"; import { createPasteFromClipboardExtension } from "../api/parsers/pasteExtension"; import { createDropFileExtension } from "../api/parsers/fileDropExtension"; import { BackgroundColorExtension } from "../extensions/BackgroundColor/BackgroundColorExtension"; -import { FullySelectedNodeExtension } from "../extensions/FullySelectedNode/FullySelectedNodeExtension"; import { TextAlignmentExtension } from "../extensions/TextAlignment/TextAlignmentExtension"; import { TextColorExtension } from "../extensions/TextColor/TextColorExtension"; +import { + TextSelectionExtension, + onSelectionChange, +} from "../extensions/TextSelection/TextSelectionExtension"; import { TrailingNode } from "../extensions/TrailingNode/TrailingNodeExtension"; import UniqueID from "../extensions/UniqueID/UniqueID"; import { BlockContainer, BlockGroup, Doc } from "../pm-nodes"; @@ -156,10 +159,25 @@ export const getBlockNoteExtensions = < ...(opts.trailingBlock === undefined || opts.trailingBlock ? [TrailingNode] : []), - - FullySelectedNodeExtension, ]; + if ( + Object.values(opts.editor.schema.blockSchema).find( + (blockConfig) => blockConfig.allowTextSelection + ) + ) { + ret.push( + TextSelectionExtension.configure({ + blockSchema: opts.editor.schema.blockSchema, + onSelectionChange: () => + onSelectionChange( + opts.editor._tiptapEditor, + opts.editor.schema.blockSchema + ), + }) + ); + } + if (opts.collaboration) { ret.push( Collaboration.configure({ diff --git a/packages/core/src/editor/tiptap.css b/packages/core/src/editor/tiptap.css deleted file mode 100644 index 705aa07072..0000000000 --- a/packages/core/src/editor/tiptap.css +++ /dev/null @@ -1,77 +0,0 @@ -/* From https://github.com/ueberdosis/tiptap/blob/a170cf4057de98d0350e318c51e57e2998fac38e/packages/core/src/style.ts */ -.ProseMirror { - position: relative; -} - -.ProseMirror { - word-wrap: break-word; - white-space: pre-wrap; - white-space: break-spaces; - -webkit-font-variant-ligatures: none; - font-variant-ligatures: none; - font-feature-settings: "liga" 0; /* the above doesn't seem to work in Edge */ -} - -.ProseMirror [contenteditable="false"] { - white-space: normal; -} - -.ProseMirror [contenteditable="false"] [contenteditable="true"] { - white-space: pre-wrap; -} - -.ProseMirror pre { - white-space: pre-wrap; -} - -img.ProseMirror-separator { - display: inline !important; - border: none !important; - margin: 0 !important; - width: 1px !important; - height: 1px !important; -} - -.ProseMirror-gapcursor { - display: none; - pointer-events: none; - position: absolute; - margin: 0; -} - -.ProseMirror-gapcursor:after { - content: ""; - display: block; - position: absolute; - top: -2px; - width: 20px; - border-top: 1px solid black; - animation: ProseMirror-cursor-blink 1.1s steps(2, start) infinite; -} - -@keyframes ProseMirror-cursor-blink { - to { - visibility: hidden; - } -} - -/* Edited section to replace `.ProseMirror-hideselection` with `.ProseMirror-fullyselected */ -.ProseMirror-fullyselected *::selection { - background: transparent; -} - -.ProseMirror-fullyselected *::-moz-selection { - background: transparent; -} - -.ProseMirror-fullyselected * { - caret-color: transparent; -} - -.ProseMirror-focused .ProseMirror-gapcursor { - display: block; -} - -.tippy-box[data-animation=fade][data-state=hidden] { - opacity: 0 -} \ No newline at end of file diff --git a/packages/core/src/extensions/FullySelectedNode/FullySelectedNodeExtension.ts b/packages/core/src/extensions/FullySelectedNode/FullySelectedNodeExtension.ts deleted file mode 100644 index dd6168532d..0000000000 --- a/packages/core/src/extensions/FullySelectedNode/FullySelectedNodeExtension.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { Editor, Extension } from "@tiptap/core"; -import { getBlockInfoFromPos } from "../../api/getBlockInfoFromPos"; - -// Removes the `ProseMirror-fullyselected` class name from the editor when a -// NodeSelection is active on a block without inline content, but the DOM -// selection is within the node, rather than fully wrapping it. These 2 -// scenarios look identical in the editor state, so we need to check the DOM -// selection to differentiate them. -const onSelectionChange = (editor: Editor) => { - const isNodeSelection = "node" in editor.state.selection; - const selection = document.getSelection(); - if (selection === null) { - return; - } - - if (selection.type === "None") { - if (isNodeSelection) { - // The browser just reset the selection to None (for example, after single clicking a piece of text within the block), - // we want to reset the selection to span the entire block - const blockInfo = getBlockInfoFromPos( - editor.state.doc, - editor.state.selection.from - ); - // TODO: check if fix should be applied for this node - - // set selection to blcok - const range = document.createRange(); - const blockElement = editor.view.domAtPos(blockInfo.startPos).node; - console.log("update selection", blockElement); - range.selectNode(blockElement.firstChild); - selection.removeAllRanges(); - selection.addRange(range); - } - return; - } - - // selectionchange events don't bubble, so we have to scope them in this way - // instead of setting the listener on the editor element. - if ( - !editor.view.dom.contains(selection.anchorNode) || - !editor.view.dom.contains(selection.focusNode) - ) { - return; - } - - // Node selection is active. - - if (!isNodeSelection) { - editor.view.dom.classList.remove("ProseMirror-fullyselected"); - return; - } - - const blockInfo = getBlockInfoFromPos( - editor.state.doc, - editor.state.selection.from - ); - - // Selected block has no inline content. - const selectedNodeHasNoContent = - blockInfo.contentNode.type.spec.content === ""; - if (!selectedNodeHasNoContent) { - editor.view.dom.classList.remove("ProseMirror-fullyselected"); - return; - } - - const blockElement = editor.view.domAtPos(blockInfo.startPos).node; - - if ( - // Selection doesn't wrap this node. - selection.type !== "Range" || - selection.anchorNode !== blockElement || - selection.focusNode !== blockElement || - selection.anchorOffset !== 0 || - selection.focusOffset !== 1 - ) { - editor.view.dom.classList.remove("ProseMirror-fullyselected"); - } else if (!editor.view.dom.classList.contains("ProseMirror-fullyselected")) { - editor.view.dom.classList.add("ProseMirror-fullyselected"); - } -}; - -export const FullySelectedNodeExtension = Extension.create({ - name: "fullySelectedNode", - onCreate() { - document.addEventListener("selectionchange", () => { - onSelectionChange(this.editor); - }); - }, - onDestroy() { - document.removeEventListener("selectionchange", () => { - onSelectionChange(this.editor); - }); - }, -}); diff --git a/packages/core/src/extensions/TextSelection/TextSelectionExtension.ts b/packages/core/src/extensions/TextSelection/TextSelectionExtension.ts new file mode 100644 index 0000000000..b7e9a1895d --- /dev/null +++ b/packages/core/src/extensions/TextSelection/TextSelectionExtension.ts @@ -0,0 +1,111 @@ +import { Editor, Extension } from "@tiptap/core"; +import { NodeSelection } from "prosemirror-state"; +import { getBlockInfoFromPos } from "../../api/getBlockInfoFromPos"; +import { BlockSchema } from "../../schema"; + +// Removes the `ProseMirror-hideselection` class name from the editor when a +// NodeSelection is active on a block with `allowTextSelection`, but the DOM +// selection is within the node, rather than fully wrapping it. These 2 +// scenarios look identical in the editor state, so we need to check the DOM +// selection to differentiate them. +export const onSelectionChange = (editor: Editor, blockSchema: BlockSchema) => { + const isNodeSelection = "node" in editor.state.selection; + if (!isNodeSelection) { + editor.view.dom.classList.remove("ProseMirror-hideselection"); + return; + } + + const selection = document.getSelection(); + if (selection === null) { + return; + } + + const blockInfo = getBlockInfoFromPos( + editor.state.doc, + editor.state.selection.from + ); + + const selectedBlockHasSelectableText = + blockSchema[blockInfo.contentType.name].allowTextSelection; + + // We want to ensure that the DOM selection and the editor selection + // remain in sync. This means that in cases where the editor is focused + // and a node selection is active, the DOM selection should be reset to + // wrap the selected node if it's set to None. + if (selection.type === "None") { + if (isNodeSelection && selectedBlockHasSelectableText) { + // Sets selection to wrap block. + const range = document.createRange(); + const blockElement = editor.view.domAtPos(blockInfo.startPos).node; + range.selectNode(blockElement.firstChild!); + selection.removeAllRanges(); + selection.addRange(range); + } + + return; + } + + // selectionchange events don't bubble, so we have to scope them in this way + // instead of setting the listener on the editor element. + if ( + !editor.view.dom.contains(selection.anchorNode) || + !editor.view.dom.contains(selection.focusNode) + ) { + return; + } + + // Replicates default behaviour if the block doesn't have selectable text, + // i.e. sets the `ProseMirror-hideselection` class whenever a NodeSelection + // is active. + if (!selectedBlockHasSelectableText) { + editor.view.dom.classList.add("ProseMirror-hideselection"); + + return; + } + + // Uses custom behaviour if the block has selectable text, i.e. only sets the + // `ProseMirror-hideselection` class when a NodeSelection is active and the + // DOM selection wraps the selected block. + const blockElement = editor.view.domAtPos(blockInfo.startPos).node; + + if ( + // Selection doesn't wrap this node. + selection.type !== "Range" || + selection.anchorNode !== blockElement || + selection.focusNode !== blockElement || + selection.anchorOffset !== 0 || + selection.focusOffset !== 1 + ) { + editor.view.dom.classList.remove("ProseMirror-hideselection"); + } else { + editor.view.dom.classList.add("ProseMirror-hideselection"); + } +}; + +export const TextSelectionExtension = Extension.create<{ + blockSchema: BlockSchema; + onSelectionChange: () => void; +}>({ + name: "textSelection", + addOptions() { + return { + blockSchema: {}, + onSelectionChange: () => { + // No-op + }, + }; + }, + onCreate() { + NodeSelection.prototype.visible = true; + document.addEventListener( + "selectionchange", + this.options.onSelectionChange + ); + }, + onDestroy() { + document.removeEventListener( + "selectionchange", + this.options.onSelectionChange + ); + }, +}); diff --git a/packages/core/src/schema/blocks/createSpec.ts b/packages/core/src/schema/blocks/createSpec.ts index a2094d743b..862797d7dc 100644 --- a/packages/core/src/schema/blocks/createSpec.ts +++ b/packages/core/src/schema/blocks/createSpec.ts @@ -72,15 +72,19 @@ export function fixNodeViewTextSelection( // Necessary for DOM to handle selections. nodeView.ignoreMutation = () => true; - // Prevents selecting the node from making it draggable, and prevents the DOM selection from being visible when it wraps the node. + // We need to override `selectNode` because the default implementation makes + // the node draggable. We do, however, want to still add the + // `ProseMirror-selectednode` class. nodeView.selectNode = () => { (nodeView.dom as HTMLElement).classList.add("ProseMirror-selectednode"); - props.editor.view.dom.classList.add("ProseMirror-fullyselected"); + // We also add the `ProseMirror-hideselection` class to prevent flickering + // as `selectNode` is called before any `selectionchange` listeners. + props.editor.view.dom.classList.add("ProseMirror-hideselection"); }; nodeView.stopEvent = (event) => { // Let the browser handle copy events, as these only fire when the whole - // node isn't selected. + // node isn't selected in the DOM. if (event.type === "cut" || event.type === "copy") { return true; } @@ -119,6 +123,13 @@ export function fixNodeViewTextSelection( NodeSelection.create(props.editor.view.state.doc, nodeStartPos) ) ); + } else { + // ProseMirror seems to remove the `ProseMirror-hideselection` class + // on mousedown, so we need to add it back to prevent flickering when + // clicking a node while it's selected. I have no idea why this is the + // case, since the class gets removed even if we don't remove it + // ourselves. + props.editor.view.dom.classList.add("ProseMirror-hideselection"); } return true; @@ -240,7 +251,7 @@ export function createBlockSpec< if ( blockConfig.content === "none" && - blockConfig.canSelectText === true + blockConfig.allowTextSelection === true ) { fixNodeViewTextSelection(props, nodeView); } diff --git a/packages/core/src/schema/blocks/types.ts b/packages/core/src/schema/blocks/types.ts index 73bc02e47f..303deca4b5 100644 --- a/packages/core/src/schema/blocks/types.ts +++ b/packages/core/src/schema/blocks/types.ts @@ -49,7 +49,7 @@ export type FileBlockConfig = { }; }; content: "none"; - canSelectText?: boolean; + allowTextSelection?: boolean; isFileBlock: true; fileBlockAccept?: string[]; }; @@ -61,7 +61,7 @@ export type BlockConfig = type: string; readonly propSchema: PropSchema; content: "inline" | "none" | "table"; - canSelectText?: boolean; + allowTextSelection?: boolean; isFileBlock?: false; } | FileBlockConfig; diff --git a/packages/core/src/style.css b/packages/core/src/style.css index 214753051e..8d073cf1e0 100644 --- a/packages/core/src/style.css +++ b/packages/core/src/style.css @@ -1,3 +1,2 @@ @import url("./editor/Block.css"); @import url("./editor/editor.css"); -@import url("./editor/tiptap.css"); diff --git a/packages/react/src/schema/ReactBlockSpec.tsx b/packages/react/src/schema/ReactBlockSpec.tsx index b0a5d90f1a..e98f319d69 100644 --- a/packages/react/src/schema/ReactBlockSpec.tsx +++ b/packages/react/src/schema/ReactBlockSpec.tsx @@ -184,7 +184,7 @@ export function createReactBlockSpec< if ( blockConfig.content === "none" && - blockConfig.canSelectText === true + blockConfig.allowTextSelection === true ) { fixNodeViewTextSelection(props, nodeView); } From 707208348a54ed14dd67d6d0b5a08eaa54add82c Mon Sep 17 00:00:00 2001 From: matthewlipski Date: Fri, 20 Sep 2024 03:34:03 +0200 Subject: [PATCH 4/4] - Refactored classes and CSS - Fixed copying when whole node is selected - Added formatting toolbar controller fix --- packages/core/src/editor/BlockNoteEditor.ts | 1 + packages/core/src/editor/tiptap.css | 77 +++++++++++++++++++ .../TextSelection/TextSelectionExtension.ts | 38 ++++----- packages/core/src/schema/blocks/createSpec.ts | 35 +++++---- packages/core/src/style.css | 1 + .../FormattingToolbarController.tsx | 4 +- 6 files changed, 119 insertions(+), 37 deletions(-) create mode 100644 packages/core/src/editor/tiptap.css diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index 2551023ce9..b4af907ef9 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -393,6 +393,7 @@ export class BlockNoteEditor< } const tiptapOptions: BlockNoteTipTapEditorOptions = { + injectCSS: false, ...blockNoteTipTapOptions, ...newOptions._tiptapOptions, content: initialContent, diff --git a/packages/core/src/editor/tiptap.css b/packages/core/src/editor/tiptap.css new file mode 100644 index 0000000000..4fe5cab2c6 --- /dev/null +++ b/packages/core/src/editor/tiptap.css @@ -0,0 +1,77 @@ +/* From https://github.com/ueberdosis/tiptap/blob/a170cf4057de98d0350e318c51e57e2998fac38e/packages/core/src/style.ts */ +.ProseMirror { + position: relative; +} + +.ProseMirror { + word-wrap: break-word; + white-space: pre-wrap; + white-space: break-spaces; + -webkit-font-variant-ligatures: none; + font-variant-ligatures: none; + font-feature-settings: "liga" 0; /* the above doesn't seem to work in Edge */ +} + +.ProseMirror [contenteditable="false"] { + white-space: normal; +} + +.ProseMirror [contenteditable="false"] [contenteditable="true"] { + white-space: pre-wrap; +} + +.ProseMirror pre { + white-space: pre-wrap; +} + +img.ProseMirror-separator { + display: inline !important; + border: none !important; + margin: 0 !important; + width: 1px !important; + height: 1px !important; +} + +.ProseMirror-gapcursor { + display: none; + pointer-events: none; + position: absolute; + margin: 0; +} + +.ProseMirror-gapcursor:after { + content: ""; + display: block; + position: absolute; + top: -2px; + width: 20px; + border-top: 1px solid black; + animation: ProseMirror-cursor-blink 1.1s steps(2, start) infinite; +} + +@keyframes ProseMirror-cursor-blink { + to { + visibility: hidden; + } +} + +/* Edited section */ +.ProseMirror-hideselection:not(.ProseMirror-forceshowselection) *::selection { + background: transparent; +} + +.ProseMirror-hideselection:not(.ProseMirror-forceshowselection) *::-moz-selection { + background: transparent; +} + +.ProseMirror-hideselection:not(.ProseMirror-forceshowselection) * { + caret-color: transparent; +} + +.ProseMirror-focused .ProseMirror-gapcursor { + display: block; +} + +.tippy-box[data-animation=fade][data-state=hidden] { + opacity: 0 +} \ No newline at end of file diff --git a/packages/core/src/extensions/TextSelection/TextSelectionExtension.ts b/packages/core/src/extensions/TextSelection/TextSelectionExtension.ts index b7e9a1895d..1419924ce9 100644 --- a/packages/core/src/extensions/TextSelection/TextSelectionExtension.ts +++ b/packages/core/src/extensions/TextSelection/TextSelectionExtension.ts @@ -1,5 +1,4 @@ import { Editor, Extension } from "@tiptap/core"; -import { NodeSelection } from "prosemirror-state"; import { getBlockInfoFromPos } from "../../api/getBlockInfoFromPos"; import { BlockSchema } from "../../schema"; @@ -11,12 +10,13 @@ import { BlockSchema } from "../../schema"; export const onSelectionChange = (editor: Editor, blockSchema: BlockSchema) => { const isNodeSelection = "node" in editor.state.selection; if (!isNodeSelection) { - editor.view.dom.classList.remove("ProseMirror-hideselection"); + editor.view.dom.classList.remove("ProseMirror-forceshowselection"); return; } const selection = document.getSelection(); if (selection === null) { + editor.view.dom.classList.remove("ProseMirror-forceshowselection"); return; } @@ -27,6 +27,10 @@ export const onSelectionChange = (editor: Editor, blockSchema: BlockSchema) => { const selectedBlockHasSelectableText = blockSchema[blockInfo.contentType.name].allowTextSelection; + if (!selectedBlockHasSelectableText) { + editor.view.dom.classList.remove("ProseMirror-forceshowselection"); + return; + } // We want to ensure that the DOM selection and the editor selection // remain in sync. This means that in cases where the editor is focused @@ -54,31 +58,20 @@ export const onSelectionChange = (editor: Editor, blockSchema: BlockSchema) => { return; } - // Replicates default behaviour if the block doesn't have selectable text, - // i.e. sets the `ProseMirror-hideselection` class whenever a NodeSelection - // is active. - if (!selectedBlockHasSelectableText) { - editor.view.dom.classList.add("ProseMirror-hideselection"); - - return; - } - - // Uses custom behaviour if the block has selectable text, i.e. only sets the - // `ProseMirror-hideselection` class when a NodeSelection is active and the - // DOM selection wraps the selected block. + // Sets/unsets the `ProseMirror-forceshowselection` class when the selection + // is inside the selected node. const blockElement = editor.view.domAtPos(blockInfo.startPos).node; if ( - // Selection doesn't wrap this node. - selection.type !== "Range" || - selection.anchorNode !== blockElement || - selection.focusNode !== blockElement || - selection.anchorOffset !== 0 || - selection.focusOffset !== 1 + // Selection is inside the selected node. + blockElement.contains(selection.anchorNode) && + blockElement.contains(selection.focusNode) && + selection.anchorNode !== blockElement && + selection.focusNode !== blockElement ) { - editor.view.dom.classList.remove("ProseMirror-hideselection"); + editor.view.dom.classList.add("ProseMirror-forceshowselection"); } else { - editor.view.dom.classList.add("ProseMirror-hideselection"); + editor.view.dom.classList.remove("ProseMirror-forceshowselection"); } }; @@ -96,7 +89,6 @@ export const TextSelectionExtension = Extension.create<{ }; }, onCreate() { - NodeSelection.prototype.visible = true; document.addEventListener( "selectionchange", this.options.onSelectionChange diff --git a/packages/core/src/schema/blocks/createSpec.ts b/packages/core/src/schema/blocks/createSpec.ts index 862797d7dc..45f9c4fea1 100644 --- a/packages/core/src/schema/blocks/createSpec.ts +++ b/packages/core/src/schema/blocks/createSpec.ts @@ -19,6 +19,7 @@ import { BlockSchemaWithBlock, PartialBlockFromConfig, } from "./types"; +import { getBlockInfoFromPos } from "../../api/getBlockInfoFromPos"; // restrict content to "inline" and "none" only export type CustomBlockConfig = BlockConfig & { @@ -77,16 +78,31 @@ export function fixNodeViewTextSelection( // `ProseMirror-selectednode` class. nodeView.selectNode = () => { (nodeView.dom as HTMLElement).classList.add("ProseMirror-selectednode"); - // We also add the `ProseMirror-hideselection` class to prevent flickering - // as `selectNode` is called before any `selectionchange` listeners. - props.editor.view.dom.classList.add("ProseMirror-hideselection"); }; nodeView.stopEvent = (event) => { - // Let the browser handle copy events, as these only fire when the whole - // node isn't selected in the DOM. + // Let the browser handle copy events, unless the selection wraps the + // selected node. if (event.type === "cut" || event.type === "copy") { - return true; + const selection = document.getSelection(); + if (selection === null) { + return false; + } + + const blockInfo = getBlockInfoFromPos( + props.editor.state.doc, + props.editor.state.selection.from + ); + + const blockElement = props.editor.view.domAtPos(blockInfo.startPos).node; + + return ( + selection.type !== "Range" || + selection.anchorNode !== blockElement || + selection.focusNode !== blockElement || + selection.anchorOffset !== 0 || + selection.focusOffset !== 1 + ); } // Prevent all drag events. @@ -123,13 +139,6 @@ export function fixNodeViewTextSelection( NodeSelection.create(props.editor.view.state.doc, nodeStartPos) ) ); - } else { - // ProseMirror seems to remove the `ProseMirror-hideselection` class - // on mousedown, so we need to add it back to prevent flickering when - // clicking a node while it's selected. I have no idea why this is the - // case, since the class gets removed even if we don't remove it - // ourselves. - props.editor.view.dom.classList.add("ProseMirror-hideselection"); } return true; diff --git a/packages/core/src/style.css b/packages/core/src/style.css index 8d073cf1e0..214753051e 100644 --- a/packages/core/src/style.css +++ b/packages/core/src/style.css @@ -1,2 +1,3 @@ @import url("./editor/Block.css"); @import url("./editor/editor.css"); +@import url("./editor/tiptap.css"); diff --git a/packages/react/src/components/FormattingToolbar/FormattingToolbarController.tsx b/packages/react/src/components/FormattingToolbar/FormattingToolbarController.tsx index fe44ff1142..67eda93c07 100644 --- a/packages/react/src/components/FormattingToolbar/FormattingToolbarController.tsx +++ b/packages/react/src/components/FormattingToolbar/FormattingToolbarController.tsx @@ -84,7 +84,9 @@ export const FormattingToolbarController = (props: { // console.log("change", event); if (!open) { editor.formattingToolbar.closeMenu(); - editor.focus(); + if (!editor.isFocused()) { + editor.focus(); + } } }, }