diff --git a/packages/core/src/api/exporters/copyExtension.ts b/packages/core/src/api/exporters/copyExtension.ts
index 8a168387a0..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,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 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 73462768ad..64d18a5c4a 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 fca81fb5a7..072b159e9e 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 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
new file mode 100644
index 0000000000..1419924ce9
--- /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 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..45f9c4fea1 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 1caf78db8c..303deca4b5 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 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();
+ }
}
},
}
diff --git a/packages/react/src/schema/ReactBlockSpec.tsx b/packages/react/src/schema/ReactBlockSpec.tsx
index ba36009a62..e98f319d69 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;
+ };
},
});