diff --git a/packages/core/src/api/exporters/copyExtension.ts b/packages/core/src/api/exporters/copyExtension.ts index 8a168387a..2e8f0ca65 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,11 +10,12 @@ import { createExternalHTMLExporter } from "./html/externalHTMLExporter"; import { createInternalHTMLSerializer } from "./html/internalHTMLSerializer"; import { cleanHTMLToMarkdown } from "./markdown/markdownExporter"; -async function selectedFragmentToHTML< +async function fragmentToHTML< BSchema extends BlockSchema, I extends InlineContentSchema, S extends StyleSchema >( + fragment: Fragment, view: EditorView, editor: BlockNoteEditor ): Promise<{ @@ -22,14 +23,12 @@ async function selectedFragmentToHTML< externalHTML: string; plainText: string; }> { - const selectedFragment = view.state.selection.content().content; - const internalHTMLSerializer = createInternalHTMLSerializer( view.state.schema, editor ); const internalHTML = internalHTMLSerializer.serializeProseMirrorFragment( - selectedFragment, + fragment, {} ); @@ -39,7 +38,7 @@ async function selectedFragmentToHTML< editor ); const externalHTML = externalHTMLExporter.exportProseMirrorFragment( - selectedFragment, + fragment, {} ); @@ -65,20 +64,20 @@ 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 { plainText, internalHTML, externalHTML } = await fragmentToHTML( + fragment, + view, + editor + ); // TODO: Writing to other MIME types not working in Safari for // some reason. @@ -145,7 +144,11 @@ export const createCopyToClipboardExtension = < (async () => { const { internalHTML, externalHTML, plainText } = - await selectedFragmentToHTML(view, editor); + await fragmentToHTML( + view.state.selection.content().content, + view, + editor + ); // TODO: Writing to other MIME types not working in Safari for // some reason. diff --git a/packages/core/src/editor/Block.css b/packages/core/src/editor/Block.css index cc9df706d..a2f2293c3 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 73462768a..64d18a5c4 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -436,6 +436,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 fca81fb5a..072b159e9 100644 --- a/packages/core/src/editor/BlockNoteExtensions.ts +++ b/packages/core/src/editor/BlockNoteExtensions.ts @@ -17,6 +17,10 @@ import { createPasteFromClipboardExtension } from "../api/parsers/pasteExtension import { BackgroundColorExtension } from "../extensions/BackgroundColor/BackgroundColorExtension"; 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"; @@ -159,6 +163,23 @@ export const getBlockNoteExtensions = < : []), ]; + 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 new file mode 100644 index 000000000..4fe5cab2c --- /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 new file mode 100644 index 000000000..1419924ce --- /dev/null +++ b/packages/core/src/extensions/TextSelection/TextSelectionExtension.ts @@ -0,0 +1,103 @@ +import { Editor, Extension } from "@tiptap/core"; +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-forceshowselection"); + return; + } + + const selection = document.getSelection(); + if (selection === null) { + editor.view.dom.classList.remove("ProseMirror-forceshowselection"); + return; + } + + const blockInfo = getBlockInfoFromPos( + editor.state.doc, + editor.state.selection.from + ); + + 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 + // 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; + } + + // Sets/unsets the `ProseMirror-forceshowselection` class when the selection + // is inside the selected node. + const blockElement = editor.view.domAtPos(blockInfo.startPos).node; + + if ( + // Selection is inside the selected node. + blockElement.contains(selection.anchorNode) && + blockElement.contains(selection.focusNode) && + selection.anchorNode !== blockElement && + selection.focusNode !== blockElement + ) { + editor.view.dom.classList.add("ProseMirror-forceshowselection"); + } else { + editor.view.dom.classList.remove("ProseMirror-forceshowselection"); + } +}; + +export const TextSelectionExtension = Extension.create<{ + blockSchema: BlockSchema; + onSelectionChange: () => void; +}>({ + name: "textSelection", + addOptions() { + return { + blockSchema: {}, + onSelectionChange: () => { + // No-op + }, + }; + }, + onCreate() { + document.addEventListener( + "selectionchange", + this.options.onSelectionChange + ); + }, + onDestroy() { + document.removeEventListener( + "selectionchange", + this.options.onSelectionChange + ); + }, +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9f352e54b..ee6dce5b0 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 25fb94a9a..45f9c4fea 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"; @@ -15,6 +19,7 @@ import { BlockSchemaWithBlock, PartialBlockFromConfig, } from "./types"; +import { getBlockInfoFromPos } from "../../api/getBlockInfoFromPos"; // restrict content to "inline" and "none" only export type CustomBlockConfig = BlockConfig & { @@ -61,6 +66,88 @@ export type CustomBlockImplementation< ) => PartialBlockFromConfig["props"] | undefined; }; +export function fixNodeViewTextSelection( + props: NodeViewRendererProps, + nodeView: NodeView +) { + // Necessary for DOM to handle selections. + nodeView.ignoreMutation = () => true; + + // 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"); + }; + + nodeView.stopEvent = (event) => { + // Let the browser handle copy events, unless the selection wraps the + // selected node. + if (event.type === "cut" || event.type === "copy") { + 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. + 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) + ) + ); + } + + return true; + } + + 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 +234,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 +250,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.allowTextSelection === 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 1caf78db8..303deca4b 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"; + allowTextSelection?: boolean; isFileBlock: true; fileBlockAccept?: string[]; }; @@ -60,6 +61,7 @@ export type BlockConfig = type: string; readonly propSchema: PropSchema; content: "inline" | "none" | "table"; + allowTextSelection?: boolean; isFileBlock?: false; } | FileBlockConfig; diff --git a/packages/core/src/style.css b/packages/core/src/style.css index 8d073cf1e..214753051 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 fe44ff114..67eda93c0 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(); + } } }, } diff --git a/packages/react/src/schema/ReactBlockSpec.tsx b/packages/react/src/schema/ReactBlockSpec.tsx index ba36009a6..e98f319d6 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.allowTextSelection === true + ) { + fixNodeViewTextSelection(props, nodeView); + } + + return nodeView; + }; }, });