diff --git a/packages/core/src/extensions/FormattingToolbar/FormattingToolbar.test.ts b/packages/core/src/extensions/FormattingToolbar/FormattingToolbar.test.ts new file mode 100644 index 0000000000..5f8bd6d03f --- /dev/null +++ b/packages/core/src/extensions/FormattingToolbar/FormattingToolbar.test.ts @@ -0,0 +1,66 @@ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, +} from "vite-plus/test"; + +import { PartialBlock } from "../../blocks/defaultBlocks.js"; +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { formattingToolbarShouldShow } from "./FormattingToolbar.js"; + +describe("formattingToolbarShouldShow", () => { + let editor: BlockNoteEditor; + const div = document.createElement("div"); + + const testDocument: PartialBlock[] = [ + { id: "paragraph-0", type: "paragraph", content: "Paragraph 0" }, + { id: "paragraph-1", type: "paragraph", content: "Paragraph 1" }, + { id: "code-0", type: "codeBlock", content: "const x = 0;" }, + { id: "code-1", type: "codeBlock", content: "const y = 1;" }, + { id: "paragraph-2", type: "paragraph", content: "Paragraph 2" }, + ]; + + const shouldShow = () => + editor.transact((tr) => formattingToolbarShouldShow(tr)); + + beforeAll(() => { + editor = BlockNoteEditor.create(); + editor.mount(div); + }); + + afterAll(() => { + editor._tiptapEditor.destroy(); + editor = undefined as any; + }); + + beforeEach(() => { + editor.replaceBlocks(editor.document, testDocument); + }); + + it("does not show for an empty selection", () => { + editor.setTextCursorPosition("paragraph-0"); + + expect(shouldShow()).toBe(false); + }); + + it("shows for a selection spanning regular content", () => { + editor.setSelection("paragraph-0", "paragraph-1"); + + expect(shouldShow()).toBe(true); + }); + + it("does not show for a selection entirely within code", () => { + editor.setSelection("code-0", "code-1"); + + expect(shouldShow()).toBe(false); + }); + + it("shows for a selection spanning both a code block and regular content (#2865)", () => { + editor.setSelection("paragraph-1", "code-0"); + + expect(shouldShow()).toBe(true); + }); +}); diff --git a/packages/core/src/extensions/FormattingToolbar/FormattingToolbar.ts b/packages/core/src/extensions/FormattingToolbar/FormattingToolbar.ts index 84524eee2e..51c2a07c91 100644 --- a/packages/core/src/extensions/FormattingToolbar/FormattingToolbar.ts +++ b/packages/core/src/extensions/FormattingToolbar/FormattingToolbar.ts @@ -1,47 +1,66 @@ -import { TextSelection } from "prosemirror-state"; +import { TextSelection, type Transaction } from "prosemirror-state"; import { createExtension, createStore, } from "../../editor/BlockNoteExtension.js"; +/** + * Determines whether the formatting toolbar should be shown for the selection + * of the given transaction. + * + * Exported for testing. + */ +export function formattingToolbarShouldShow(tr: Transaction): boolean { + // Don't show if the selection is empty. + if (tr.selection.empty) { + return false; + } + + // Don't show if the selection is a text selection but contains no text. + if ( + tr.selection instanceof TextSelection && + tr.doc.textBetween(tr.selection.from, tr.selection.to).length === 0 + ) { + return false; + } + + // Inspects the content of the selection to see whether it spans a node with a + // code spec (e.g. a code block), and whether it also contains any non-code + // inline content that the toolbar could actually format. + let spansCode = false; + let spansNonCodeInlineContent = false; + tr.selection.content().content.descendants((node) => { + if (node.type.spec.code) { + spansCode = true; + // No need to descend into code content - it can't be formatted. + return false; + } + + if (node.isInline) { + spansNonCodeInlineContent = true; + } + + return true; + }); + + // Don't show if the selection is entirely within code, i.e. it spans a code + // node but has no other formattable inline content. A selection spanning both + // a code block and regular content (#2865) should still show the toolbar, + // since the non-code content can be formatted. + if (spansCode && !spansNonCodeInlineContent) { + return false; + } + + // Show toolbar otherwise. + return true; +} + export const FormattingToolbarExtension = createExtension(({ editor }) => { const store = createStore(false); const shouldShow = () => { - return editor.transact((tr) => { - // Don't show if the selection is empty, or is a text selection with no - // text. - if (tr.selection.empty) { - return false; - } - - // Don't show if the selection is a text selection but contains no text. - if ( - tr.selection instanceof TextSelection && - tr.doc.textBetween(tr.selection.from, tr.selection.to).length === 0 - ) { - return false; - } - - // Searches the content of the selection to see if it spans a node with a - // code spec. - let spansCode = false; - tr.selection.content().content.descendants((node) => { - if (node.type.spec.code) { - spansCode = true; - } - return !spansCode; // keep descending if we haven't found a code block - }); - - // Don't show if the selection spans a code block. - if (spansCode) { - return false; - } - - // Show toolbar otherwise. - return true; - }); + return editor.transact((tr) => formattingToolbarShouldShow(tr)); }; return {