Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Selectable non-editable text in blocks without inline content #1087

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 20 additions & 17 deletions packages/core/src/api/exporters/copyExtension.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -10,26 +10,25 @@ 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<BSchema, I, S>
): 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,
fragment,
{}
);

Expand All @@ -39,7 +38,7 @@ async function selectedFragmentToHTML<
editor
);
const externalHTML = externalHTMLExporter.exportProseMirrorFragment(
selectedFragment,
fragment,
{}
);

Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
6 changes: 4 additions & 2 deletions packages/core/src/editor/Block.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/editor/BlockNoteEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,7 @@ export class BlockNoteEditor<
}

const tiptapOptions: BlockNoteTipTapEditorOptions = {
injectCSS: false,
...blockNoteTipTapOptions,
...newOptions._tiptapOptions,
content: initialContent,
Expand Down
21 changes: 21 additions & 0 deletions packages/core/src/editor/BlockNoteExtensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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({
Expand Down
77 changes: 77 additions & 0 deletions packages/core/src/editor/tiptap.css
Original file line number Diff line number Diff line change
@@ -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
}
103 changes: 103 additions & 0 deletions packages/core/src/extensions/TextSelection/TextSelectionExtension.ts
Original file line number Diff line number Diff line change
@@ -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
);
},
});
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
Loading
Loading