From 8bc7dbb2a70d92f800add29e75c509fd9a65354d Mon Sep 17 00:00:00 2001 From: yousefed Date: Wed, 30 Sep 2026 14:54:10 +0200 Subject: [PATCH 1/4] feat(core): drop a block into a toggle (BLO-956) A block dragged onto a toggle's title, chevron or "Add block" button becomes its first child, as in Notion. The drop cursor shows the place and the toggle is highlighted like a selected block. - `meta.dropsIntoChildren` marks a block that takes drops this way. - The drop cursor finds the target and a `handleDrop` plugin drops the blocks where the cursor shows them. - Drag-and-drop events on frame chrome now reach ProseMirror. - A toggle stays open when its last child is removed or moved out. --- packages/core/src/blocks/Heading/block.ts | 3 + .../blocks/ListItem/ToggleListItem/block.ts | 2 + .../blocks/ToggleWrapper/createToggleFrame.ts | 5 +- .../toggleBlocks.browser.test.ts | 138 +++++++++++++++++- packages/core/src/editor/Block.css | 7 +- .../src/extensions/DropCursor/DropCursor.ts | 79 +++++++++- .../extensions/DropCursor/dropIntoChildren.ts | 80 ++++++++++ packages/core/src/pm-nodes/BlockContainer.ts | 7 +- packages/core/src/schema/blocks/types.ts | 8 + .../toggleblocks/toggleblocks.test.tsx | 122 +++++++++++----- 10 files changed, 405 insertions(+), 46 deletions(-) create mode 100644 packages/core/src/extensions/DropCursor/dropIntoChildren.ts diff --git a/packages/core/src/blocks/Heading/block.ts b/packages/core/src/blocks/Heading/block.ts index 4e84967d9e..3aaad271d1 100644 --- a/packages/core/src/blocks/Heading/block.ts +++ b/packages/core/src/blocks/Heading/block.ts @@ -73,6 +73,9 @@ export const createHeadingBlockSpec = createBlockSpec( ({ allowToggleHeadings = true }: HeadingOptions = {}) => ({ meta: { isolating: false, + // A block dragged onto a toggle heading becomes its first child. + dropsIntoChildren: (block) => + allowToggleHeadings && block.props.isToggleable === true, }, // A toggle heading resets to a regular heading, which in turn resets to a // paragraph. While a toggle heading is open, Enter in its text starts its diff --git a/packages/core/src/blocks/ListItem/ToggleListItem/block.ts b/packages/core/src/blocks/ListItem/ToggleListItem/block.ts index bc4c0a8d95..cda0d7e88f 100644 --- a/packages/core/src/blocks/ListItem/ToggleListItem/block.ts +++ b/packages/core/src/blocks/ListItem/ToggleListItem/block.ts @@ -43,6 +43,8 @@ export const createToggleListItemBlockSpec = createBlockSpec( }, meta: { isolating: false, + // A block dragged onto the toggle becomes its first child. + dropsIntoChildren: () => true, }, parse(element) { if (element.tagName === "DETAILS") { diff --git a/packages/core/src/blocks/ToggleWrapper/createToggleFrame.ts b/packages/core/src/blocks/ToggleWrapper/createToggleFrame.ts index 0907ea8a34..f77b837eee 100644 --- a/packages/core/src/blocks/ToggleWrapper/createToggleFrame.ts +++ b/packages/core/src/blocks/ToggleWrapper/createToggleFrame.ts @@ -107,14 +107,13 @@ export function createToggleFrame( dom, slot, // Keeps the frame, and so its open state, when the block changes. Adding - // a child opens the toggle, and removing the last one closes it. + // a child opens the toggle. Removing the last child keeps it open, showing + // the "Add block" button, as in Notion. update(updated: Block) { const newChildCount = updated.children.length; const wasOpen = open; if (newChildCount > childCount) { open = true; - } else if (newChildCount === 0 && childCount > 0) { - open = false; } if (open !== wasOpen) { toggledState.set(updated, open); diff --git a/packages/core/src/blocks/ToggleWrapper/toggleBlocks.browser.test.ts b/packages/core/src/blocks/ToggleWrapper/toggleBlocks.browser.test.ts index 2b15ae1bfa..a6361f76b9 100644 --- a/packages/core/src/blocks/ToggleWrapper/toggleBlocks.browser.test.ts +++ b/packages/core/src/blocks/ToggleWrapper/toggleBlocks.browser.test.ts @@ -1,3 +1,4 @@ +import { Fragment, Slice } from "prosemirror-model"; import { TextSelection } from "prosemirror-state"; import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; @@ -144,6 +145,48 @@ async function open(id: string) { await userEvent.click(toggleButton(id)!); } +/** + * Drags block `id` over the center of `target`, as the side menu starts a + * block drag. The events are synthetic: an emulated mouse drag does not reach + * every target reliably. `drop()` drops the block there. + */ +function dragBlockOver(id: string, target: Element) { + const view = editor.prosemirrorView; + const { node } = getNodeById(id, view.state.doc)!; + view.dragging = { slice: new Slice(Fragment.from(node), 0, 0), move: true }; + // The side menu also puts the blocks in the drag data, which marks the drop + // as a block drop for the editor's drop handlers. + const dataTransfer = new DataTransfer(); + dataTransfer.setData("blocknote/html", ""); + const rect = target.getBoundingClientRect(); + const init: DragEventInit = { + bubbles: true, + cancelable: true, + clientX: rect.x + rect.width / 2, + clientY: rect.y + rect.height / 2, + dataTransfer, + }; + const element = document.elementFromPoint(init.clientX!, init.clientY!)!; + element.dispatchEvent(new DragEvent("dragenter", init)); + element.dispatchEvent(new DragEvent("dragover", init)); + return { + drop: () => element.dispatchEvent(new DragEvent("drop", init)), + }; +} + +function dropCursor() { + return document.querySelector('[class*="prosemirror-dropcursor"]'); +} + +/** The id of the block highlighted as the one a drop goes into. */ +function highlightedDropTarget() { + return ( + root + .querySelector('[data-drop-target="true"] > .bn-block') + ?.getAttribute("data-id") ?? null + ); +} + /** Puts the caret in the block and presses the keys. */ async function press( keys: string, @@ -271,7 +314,7 @@ describe.each(kinds)("$name", ({ toggle, newBlockTypeAfterClosedToggle }) => { }); // Notion keeps the toggle open, showing its empty-toggle placeholder. - it.fails("stays open, as an empty toggle, when its last child is removed", async () => { + it("stays open, as an empty toggle, when its last child is removed", async () => { mount([toggle("t", "Title", [{ id: "c1", type: "paragraph" }])]); await open("t"); @@ -293,6 +336,23 @@ describe.each(kinds)("$name", ({ toggle, newBlockTypeAfterClosedToggle }) => { }); }); + describe("layout", () => { + // The children span the frame below the title. When they fell into the + // chevron's grid column instead, they widened it and pushed the title far + // to the right. + it("keeps the title next to the chevron and the children below it", async () => { + mount(withChildren()); + await open("t"); + + const frame = own("t", ".bn-toggle-frame")!.getBoundingClientRect(); + const title = own("t", ".bn-inline-content")!.getBoundingClientRect(); + const group = own("t", ".bn-block-group")!.getBoundingClientRect(); + expect(title.left - frame.left).toBeLessThan(40); + expect(group.top).toBeGreaterThanOrEqual(title.bottom - 1); + expect(group.left).toBeLessThanOrEqual(title.left); + }); + }); + describe("empty toggle", () => { it('shows an "Add block" button when open, which adds a child and puts the caret in it', async () => { mount([toggle("t", "Title")]); @@ -521,7 +581,7 @@ describe.each(kinds)("$name", ({ toggle, newBlockTypeAfterClosedToggle }) => { expect(childrenAreVisible("t")).toBe(true); }); - it.fails("keeps a toggle open when its last child is moved out", async () => { + it("keeps a toggle open when its last child is moved out", async () => { mount([ toggle("t", "Title", [{ id: "c1", type: "paragraph", content: "One" }]), ]); @@ -578,6 +638,80 @@ describe.each(kinds)("$name", ({ toggle, newBlockTypeAfterClosedToggle }) => { }); }); + // A block dragged onto a toggle becomes its first child, as in Notion. For + // an empty toggle, this is the only way to drop a block into it (BLO-956). + describe("drop onto the toggle", () => { + it("onto the title: shows the place above the first child, and drops it there", async () => { + mount(withChildren()); + await open("t"); + + const drag = dragBlockOver("after", own("t", ".bn-inline-content")!); + + const cursor = dropCursor()!.getBoundingClientRect(); + expect( + Math.abs(cursor.top - blockElement("c1").getBoundingClientRect().top), + ).toBeLessThan(6); + expect(highlightedDropTarget()).toBe("t"); + + drag.drop(); + + expect(editor.getBlock("t")!.children.map((child) => child.id)).toEqual([ + "after", + "c1", + "c2", + ]); + await expect.poll(dropCursor).toBeNull(); + expect(highlightedDropTarget()).toBeNull(); + }); + + it("onto the chevron of a closed toggle: drops it as the first child, and opens the toggle", async () => { + mount(withChildren()); + + dragBlockOver("after", toggleButton("t")!).drop(); + + expect(editor.getBlock("t")!.children.map((child) => child.id)).toEqual([ + "after", + "c1", + "c2", + ]); + expect(isOpen("t")).toBe(true); + }); + + it("onto 'Add block' of an empty toggle: shows the place below the title, and drops it there (BLO-956)", async () => { + mount([ + toggle("t", "Title"), + { id: "after", type: "paragraph", content: "After" }, + ]); + await open("t"); + + const drag = dragBlockOver("after", addBlockButton("t")!); + + const cursor = dropCursor()!.getBoundingClientRect(); + const title = own("t", ".bn-block-content")!.getBoundingClientRect(); + expect(Math.abs(cursor.top - title.bottom)).toBeLessThan(6); + expect(highlightedDropTarget()).toBe("t"); + + drag.drop(); + + expect(editor.getBlock("t")!.children.map((child) => child.id)).toEqual([ + "after", + ]); + }); + + it("onto a child: drops it between the children, as usual", async () => { + mount(withChildren()); + await open("t"); + + const drag = dragBlockOver("after", own("c2", ".bn-inline-content")!); + expect(highlightedDropTarget()).toBeNull(); + drag.drop(); + + const children = editor.getBlock("t")!.children.map((child) => child.id); + expect(children).toHaveLength(3); + expect(children[0]).toBe("c1"); + }); + }); + describe("indentation", () => { it("Shift-Tab moves a child out of the toggle", async () => { mount(withChildren()); diff --git a/packages/core/src/editor/Block.css b/packages/core/src/editor/Block.css index 7809ea8d5b..d12187f19a 100644 --- a/packages/core/src/editor/Block.css +++ b/packages/core/src/editor/Block.css @@ -25,7 +25,9 @@ BASIC STYLES .ProseMirror-selectednode > .bn-block-content > *, /* Case for blocks/inline content where class is set manually */ .bn-block-content .ProseMirror-selectednode, -.bn-inline-content .ProseMirror-selectednode { +.bn-inline-content .ProseMirror-selectednode, +/* The block a dragged block drops into (set by the drop cursor) */ +.bn-block-outer[data-drop-target="true"] { /* Anchor for the `::after` highlight overlay below. */ position: relative; } @@ -35,7 +37,8 @@ BASIC STYLES .bn-block-content.ProseMirror-selectednode > *::after, .ProseMirror-selectednode > .bn-block-content > *::after, .bn-block-content .ProseMirror-selectednode::after, -.bn-inline-content .ProseMirror-selectednode::after { +.bn-inline-content .ProseMirror-selectednode::after, +.bn-block-outer[data-drop-target="true"]::after { content: ""; position: absolute; inset: 0; diff --git a/packages/core/src/extensions/DropCursor/DropCursor.ts b/packages/core/src/extensions/DropCursor/DropCursor.ts index 77d24cc114..ae451c5c6e 100644 --- a/packages/core/src/extensions/DropCursor/DropCursor.ts +++ b/packages/core/src/extensions/DropCursor/DropCursor.ts @@ -1,3 +1,4 @@ +import { Plugin } from "prosemirror-state"; import { dropPoint } from "prosemirror-transform"; import type { EditorView } from "prosemirror-view"; import { @@ -10,6 +11,7 @@ import { } from "./utils.js"; import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import { createExtension } from "../../editor/BlockNoteExtension.js"; +import { getDropIntoChildren } from "./dropIntoChildren.js"; export const DRAG_EXCLUSION_CLASSNAME = "bn-drag-exclude"; @@ -64,6 +66,11 @@ export const DropCursorExtension = createExtension< let element: HTMLElement | null = null; let timeout = -1; let dragSourceElement: Element | null = null; + // The block the drop goes into, if any, as the cursor shows it. Its + // element is highlighted like a selected block. + let dropInto: + | { blockId: string; draggedBlocks: any[]; element: HTMLElement } + | undefined; const config = { width: options.dropCursor?.width ?? 5, @@ -73,6 +80,14 @@ export const DropCursorExtension = createExtension< } as const; // Helper functions + const setDropInto = (into: typeof dropInto) => { + delete dropInto?.element.dataset.dropTarget; + dropInto = into; + if (dropInto) { + dropInto.element.dataset.dropTarget = "true"; + } + }; + const setCursor = (pos: DropCursorPosition | null) => { if ( pos?.pos === cursorPos?.pos && @@ -83,6 +98,7 @@ export const DropCursorExtension = createExtension< cursorPos = pos; if (pos == null) { + setDropInto(undefined); if (element && element.parentNode) { element.parentNode.removeChild(element); } @@ -189,13 +205,17 @@ export const DropCursorExtension = createExtension< } } - // Compute default position + // Compute default position. A block dragged onto a block with + // `meta.dropsIntoChildren` goes to the start of its children. const $pos = view.state.doc.resolve(target); const isBlock = !$pos.parent.inlineContent; - const defaultPosition: DropCursorPosition = { - pos: target, - orientation: isBlock ? "block-horizontal" : "inline", - }; + const intoChildren = getDropIntoChildren(editor, view, { + left: e.clientX, + top: e.clientY, + }); + const defaultPosition: DropCursorPosition = intoChildren + ? { pos: intoChildren.pos, orientation: "block-horizontal" } + : { pos: target, orientation: isBlock ? "block-horizontal" : "inline" }; // Allow hook to override position let finalPosition = defaultPosition; @@ -215,6 +235,26 @@ export const DropCursorExtension = createExtension< } setCursor(finalPosition); + // The drop goes into the block while the cursor shows the place of its + // children. A hook may show another place instead (e.g. a new column). + if ( + intoChildren && + finalPosition.pos === intoChildren.pos && + finalPosition.orientation === "block-horizontal" + ) { + const { block } = intoChildren.blockInfo; + const element = view.nodeDOM(block.beforePos); + if (!(element instanceof HTMLElement)) { + throw new Error("A block in the document must have an element"); + } + setDropInto({ + blockId: block.node.attrs.id, + draggedBlocks: intoChildren.draggedBlocks, + element, + }); + } else { + setDropInto(undefined); + } scheduleRemoval(5000); } }; @@ -238,8 +278,37 @@ export const DropCursorExtension = createExtension< dragSourceElement = null; }; + // Drops the blocks where the cursor shows them: as the first children of + // the block it goes into. Other drops are ProseMirror's. + const dropIntoChildrenPlugin = new Plugin({ + props: { + handleDrop(_view, _event, _slice, moved) { + if (!dropInto) { + return false; + } + const { blockId, draggedBlocks } = dropInto; + editor.transact(() => { + if (moved) { + // A drag from another editor leaves its blocks there. + editor.removeBlocks( + draggedBlocks.filter((block) => editor.getBlock(block.id)), + ); + } + const target = editor.getBlock(blockId)!; + if (target.children.length > 0) { + editor.insertBlocks(draggedBlocks, target.children[0], "before"); + } else { + editor.updateBlock(target, { children: draggedBlocks }); + } + }); + return true; + }, + }, + }); + return { key: "dropCursor", + prosemirrorPlugins: [dropIntoChildrenPlugin], mount({ signal, dom, root }) { // Track drag source at document level root.addEventListener("dragstart", onDragStart, { diff --git a/packages/core/src/extensions/DropCursor/dropIntoChildren.ts b/packages/core/src/extensions/DropCursor/dropIntoChildren.ts new file mode 100644 index 0000000000..453c704c81 --- /dev/null +++ b/packages/core/src/extensions/DropCursor/dropIntoChildren.ts @@ -0,0 +1,80 @@ +import type { EditorView } from "prosemirror-view"; + +import { fragmentToBlocks } from "../../api/nodeConversions/fragmentToBlocks.js"; +import { nodeToBlock } from "../../api/nodeConversions/nodeToBlock.js"; +import { getBlockInfoFromNode } from "../../api/getBlockInfoFromPos.js"; +import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; + +/** + * Where a block drag at `coords` drops, when it is over the content or frame + * chrome of a block with `meta.dropsIntoChildren`: the start of that block's + * children, or its end when it has no children yet. + * + * @returns `undefined` when the drop goes before or after a block, as usual: + * for a text drag, over a block's children, over a block without the + * setting, or over one of the dragged blocks. + */ +export function getDropIntoChildren( + editor: BlockNoteEditor, + view: EditorView, + coords: { left: number; top: number }, +) { + const slice = view.dragging?.slice; + // A block drag holds complete blocks (see `SideMenu.onDragStart`). + if (!slice || slice.openStart > 0) { + return undefined; + } + const draggedBlocks = fragmentToBlocks(slice.content); + const pos = view.posAtCoords(coords); + if (draggedBlocks.length === 0 || !pos || pos.inside < 0) { + return undefined; + } + + // The innermost block around the pointer. Over its children group (e.g. a + // gap between two children), the drop goes between them. + const $inside = view.state.doc.resolve(pos.inside + 1); + let depth = $inside.depth; + while (depth > 0 && !$inside.node(depth).type.isInGroup("bnBlock")) { + if ($inside.node(depth).type.name === "blockGroup") { + return undefined; + } + depth--; + } + if (depth === 0) { + return undefined; + } + const blockInfo = getBlockInfoFromNode( + $inside.node(depth), + $inside.before(depth), + ); + if (!blockInfo.hasContent) { + return undefined; + } + + const dropsIntoChildren = editor.schema.blockSpecs[ + blockInfo.blockNoteType + ]?.implementation.meta?.dropsIntoChildren?.( + nodeToBlock(blockInfo.block.node, view.state.doc), + ); + if (!dropsIntoChildren) { + return undefined; + } + + // A block can't be dropped into itself or into one of its descendants. + const targetId = blockInfo.block.node.attrs.id; + const containsTarget = (block: { + id: string; + children: { id: string; children: any[] }[]; + }): boolean => block.id === targetId || block.children.some(containsTarget); + if (draggedBlocks.some(containsTarget)) { + return undefined; + } + + return { + blockInfo, + draggedBlocks, + pos: blockInfo.children + ? blockInfo.children.childrenStart + : blockInfo.block.afterPos - 1, + }; +} diff --git a/packages/core/src/pm-nodes/BlockContainer.ts b/packages/core/src/pm-nodes/BlockContainer.ts index cafc675fd7..0fb1258e18 100644 --- a/packages/core/src/pm-nodes/BlockContainer.ts +++ b/packages/core/src/pm-nodes/BlockContainer.ts @@ -13,6 +13,8 @@ import { ignoreFrameChromeMutations } from "../schema/nodeViewMutations.js"; import { mergeCSSClasses } from "../util/browser.js"; import { suggestionMarks } from "./suggestionMarks.js"; +const dropEvents = new Set(["dragenter", "dragover", "dragleave", "drop"]); + /** Adapts vanilla frames to the same lifecycle as framework node views. */ function createFrameView( props: NodeViewRendererProps, @@ -206,11 +208,14 @@ export const BlockContainer = Node.create<{ }, stopEvent(event) { // Author chrome handles its own events; the slot remains editable. + // Drag-and-drop events still go to ProseMirror, so a block dropped + // on the chrome drops where the drop cursor shows it. const target = event.target; return ( (target instanceof globalThis.Node && frameView.dom.contains(target) && - !nodeView.contentDOM?.contains(target)) || + !nodeView.contentDOM?.contains(target) && + !dropEvents.has(event.type)) || (frameView.stopEvent?.(event) ?? false) ); }, diff --git a/packages/core/src/schema/blocks/types.ts b/packages/core/src/schema/blocks/types.ts index ba0c304956..d69bd6ab17 100644 --- a/packages/core/src/schema/blocks/types.ts +++ b/packages/core/src/schema/blocks/types.ts @@ -81,6 +81,14 @@ export interface BlockConfigMeta< */ draggable?: boolean; + /** + * Whether a block dragged onto this block's content or frame chrome becomes + * its first child (as in Notion's toggles), instead of going before or after + * it. A block dragged onto its children still goes between them. + * @default false + */ + dropsIntoChildren?(block: { type: TName; props: Props }): boolean; + /** * Enables syntax highlighting of the contents of the block with the result of this callback */ diff --git a/tests/src/end-to-end/toggleblocks/toggleblocks.test.tsx b/tests/src/end-to-end/toggleblocks/toggleblocks.test.tsx index 79531ae543..bc2ac6e0ad 100644 --- a/tests/src/end-to-end/toggleblocks/toggleblocks.test.tsx +++ b/tests/src/end-to-end/toggleblocks/toggleblocks.test.tsx @@ -15,12 +15,12 @@ import { beforeEach, describe, expect, test } from "vite-plus/test"; import { render } from "vitest-browser-react"; import { DRAG_HANDLE_ADD_SELECTOR, + DRAG_HANDLE_SELECTOR, EDITOR_SELECTOR, } from "../../utils/const.js"; import { browserName, page, userEvent } from "../../utils/context.js"; -import { waitForSelector } from "../../utils/editor.js"; +import { sleep, waitForSelector } from "../../utils/editor.js"; import { - dragAndDropBlock, getRect, mouseSequence, moveMouseOverElement, @@ -94,38 +94,94 @@ async function openToggle() { await userEvent.click(button); } +const DROP_CURSOR_SELECTOR = '[class*="prosemirror-dropcursor"]'; + +/** An element of the toggle itself, not of a toggle nested in it. */ +async function ownElement(selector: string) { + const block = await waitForSelector(`.bn-block[data-id="t"]`); + const element = [...block.querySelectorAll(selector)].find( + (candidate) => candidate.closest(".bn-block") === block, + ); + if (!element) { + throw new Error(`The toggle has no ${selector}`); + } + return element; +} + +/** + * Drags block `drag` by its handle to the center of `target`, and holds it + * there. `mouseSequence([{ type: "up" }])` drops it. + */ +async function dragOnto(target: Element) { + // Not `getByText`: a previous drag leaves its drag image, a copy of the + // dragged block, in the document. + await moveMouseOverElement( + await waitForSelector(`.bn-block[data-id="drag"] .bn-inline-content`), + ); + const handle = getRect(await waitForSelector(DRAG_HANDLE_SELECTOR)); + const rect = getRect(target); + // The pauses let the browser start the drag, as in `dragAndDropBlock`. + await mouseSequence([ + { + type: "move", + x: handle.x + handle.width / 2, + y: handle.y + handle.height / 2, + steps: 5, + }, + ]); + await sleep(100); + await mouseSequence([{ type: "down" }]); + await sleep(100); + await mouseSequence([ + { + type: "move", + x: rect.x + rect.width / 2, + y: rect.y + rect.height / 2, + steps: 5, + }, + ]); +} + +async function dropCursorTop() { + return getRect(await waitForSelector(DROP_CURSOR_SELECTOR)).top; +} + describe.each(kinds)("$name", ({ toggle }) => { - // BLO-956: an empty toggle has no place to drop a block into. The only way - // to fill it is to click "Add block" first. - test - .skipIf(browserName === "firefox") - .fails( - "accepts a block dropped into it while it is open and empty (BLO-956)", - async () => { - await render( - , - ); - await waitForSelector(EDITOR_SELECTOR); - await openToggle(); - - await dragAndDropBlock( - page.getByText("Drag me").element(), - await waitForSelector( - `.bn-block[data-id="t"] .bn-toggle-add-block-button`, - ), - true, - ); - - expect(editor.getBlock("t")!.children.map((child) => child.id)).toEqual( - ["drag"], - ); - }, - ); + // BLO-956: the only way to drop a block into an empty toggle is onto the + // toggle itself, e.g. its "Add block" button. This test uses a real mouse + // drag, which Playwright only emulates reliably in Chromium. All drop + // targets are tested in every browser in `toggleBlocks.browser.test.ts`, + // with synthetic drag events. + describe.skipIf(browserName !== "chromium")("drop onto the toggle", () => { + test("onto 'Add block', into the empty toggle (BLO-956)", async () => { + await render( + , + ); + await waitForSelector(EDITOR_SELECTOR); + await openToggle(); + + await dragOnto(await ownElement(".bn-toggle-add-block-button")); + // The drop cursor shows the place of the children: below the title. + const title = getRect(await ownElement(".bn-block-content")); + await expect.poll(dropCursorTop).toBeGreaterThan(title.bottom - 6); + expect(await dropCursorTop()).toBeLessThan(title.bottom + 6); + + await mouseSequence([{ type: "up" }]); + + // The drop cursor does not stay after the drop. + await expect + .poll(() => document.querySelector(DROP_CURSOR_SELECTOR)) + .toBeNull(); + expect(editor.getBlock("t")!.children.map((child) => child.id)).toEqual([ + "drag", + ]); + }); + }); // BLO-1030: the side menu of a block in the left column of a column list // inside a toggle was reported to disappear when the mouse moves onto it. From 0b93f1256241d26e955161f3c9a243f299cefbea Mon Sep 17 00:00:00 2001 From: yousefed Date: Wed, 30 Sep 2026 19:10:54 +0200 Subject: [PATCH 2/4] fix(core): drop as usual when the drop target is gone `handleDrop` used the target from the last `dragover`. If the target was removed in the meantime (e.g. by a collaborator), it threw halfway through the move. It now looks up the target first and lets ProseMirror drop the blocks when it is gone. The drop also clears the highlight. --- .../toggleBlocks.browser.test.ts | 21 ++++++++++++++++++- .../src/extensions/DropCursor/DropCursor.ts | 18 +++++++++++++--- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/packages/core/src/blocks/ToggleWrapper/toggleBlocks.browser.test.ts b/packages/core/src/blocks/ToggleWrapper/toggleBlocks.browser.test.ts index a6361f76b9..b8d932d189 100644 --- a/packages/core/src/blocks/ToggleWrapper/toggleBlocks.browser.test.ts +++ b/packages/core/src/blocks/ToggleWrapper/toggleBlocks.browser.test.ts @@ -170,7 +170,11 @@ function dragBlockOver(id: string, target: Element) { element.dispatchEvent(new DragEvent("dragenter", init)); element.dispatchEvent(new DragEvent("dragover", init)); return { - drop: () => element.dispatchEvent(new DragEvent("drop", init)), + // At the element under the point at drop time, as the browser does. + drop: () => + document + .elementFromPoint(init.clientX!, init.clientY!)! + .dispatchEvent(new DragEvent("drop", init)), }; } @@ -698,6 +702,21 @@ describe.each(kinds)("$name", ({ toggle, newBlockTypeAfterClosedToggle }) => { ]); }); + it("drops it as usual when the toggle is removed during the drag", async () => { + mount(withChildren()); + await open("t"); + + const drag = dragBlockOver("after", own("t", ".bn-inline-content")!); + expect(highlightedDropTarget()).toBe("t"); + // E.g. a collaborator removes the toggle. + editor.removeBlocks(["t"]); + drag.drop(); + + expect(editor.getBlock("t")).toBeUndefined(); + expect(editor.getBlock("after")).toBeDefined(); + expect(highlightedDropTarget()).toBeNull(); + }); + it("onto a child: drops it between the children, as usual", async () => { mount(withChildren()); await open("t"); diff --git a/packages/core/src/extensions/DropCursor/DropCursor.ts b/packages/core/src/extensions/DropCursor/DropCursor.ts index ae451c5c6e..01017ca24a 100644 --- a/packages/core/src/extensions/DropCursor/DropCursor.ts +++ b/packages/core/src/extensions/DropCursor/DropCursor.ts @@ -283,10 +283,15 @@ export const DropCursorExtension = createExtension< const dropIntoChildrenPlugin = new Plugin({ props: { handleDrop(_view, _event, _slice, moved) { - if (!dropInto) { + const into = dropInto; + // The drop ends the drag, and with it the highlight. + setDropInto(undefined); + // The block may be gone since the last `dragover`, e.g. removed by a + // collaborator. ProseMirror then drops the blocks as usual. + if (!into || !editor.getBlock(into.blockId)) { return false; } - const { blockId, draggedBlocks } = dropInto; + const { blockId, draggedBlocks } = into; editor.transact(() => { if (moved) { // A drag from another editor leaves its blocks there. @@ -294,7 +299,14 @@ export const DropCursorExtension = createExtension< draggedBlocks.filter((block) => editor.getBlock(block.id)), ); } - const target = editor.getBlock(blockId)!; + // The dragged blocks never contain the target (see + // `getDropIntoChildren`), so removing them keeps it. + const target = editor.getBlock(blockId); + if (!target) { + throw new Error( + "The drop target was removed with the dragged blocks", + ); + } if (target.children.length > 0) { editor.insertBlocks(draggedBlocks, target.children[0], "before"); } else { From bfd0c28e05a64fd8060f939df18162023c4e286a Mon Sep 17 00:00:00 2001 From: yousefed Date: Wed, 30 Sep 2026 19:26:37 +0200 Subject: [PATCH 3/4] test: update block spec snapshot for meta.dropsIntoChildren --- tests/src/unit/core/schema/__snapshots__/blocks.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/src/unit/core/schema/__snapshots__/blocks.json b/tests/src/unit/core/schema/__snapshots__/blocks.json index 2f68ea16fa..4804f7b7a8 100644 --- a/tests/src/unit/core/schema/__snapshots__/blocks.json +++ b/tests/src/unit/core/schema/__snapshots__/blocks.json @@ -316,6 +316,7 @@ "implementation": { "keyboard": [Function], "meta": { + "dropsIntoChildren": [Function], "isolating": false, }, "node": null, @@ -626,6 +627,7 @@ "implementation": { "keyboard": [Function], "meta": { + "dropsIntoChildren": [Function], "isolating": false, }, "node": null, From cb0315f997a6ff3d3a61eaef78549b9d06f88518 Mon Sep 17 00:00:00 2001 From: yousefed Date: Wed, 30 Sep 2026 20:28:45 +0200 Subject: [PATCH 4/4] fix: one source for the block types that the menus offer (BLO-990, BLO-1236) The block type select filtered a fixed item list by prop names and types. With `allowToggleHeadings: false` it showed no headings at all, and with `levels` it still showed every heading level. `getDefaultBlockTypeItems(editor)` (core) now returns the block types that the schema supports, with their props: headings for the configured levels, toggle headings only when the heading has `isToggleable`. The slash menu and the block type select both use it, each in its own order. Breaking: `blockTypeSelectItems(dict)` is now `blockTypeSelectItems(editor)`. --- .../src/App.tsx | 2 +- .../13-custom-ui/src/MUIFormattingToolbar.tsx | 4 +- .../05-alert-block-full-ux/src/App.tsx | 2 +- .../09-math-block/src/App.tsx | 2 +- .../10-diagram-block/src/App.tsx | 2 +- .../src/blocks/defaultBlockTypeItems.test.ts | 70 +++++++ .../core/src/blocks/defaultBlockTypeItems.ts | 99 ++++++++++ packages/core/src/blocks/index.ts | 1 + .../getDefaultSlashMenuItems.ts | 187 ++++-------------- .../DefaultSelects/BlockTypeSelect.test.ts | 46 +++++ .../DefaultSelects/BlockTypeSelect.tsx | 123 ++++-------- 11 files changed, 292 insertions(+), 246 deletions(-) create mode 100644 packages/core/src/blocks/defaultBlockTypeItems.test.ts create mode 100644 packages/core/src/blocks/defaultBlockTypeItems.ts create mode 100644 packages/react/src/components/FormattingToolbar/DefaultSelects/BlockTypeSelect.test.ts diff --git a/examples/03-ui-components/03-formatting-toolbar-block-type-items/src/App.tsx b/examples/03-ui-components/03-formatting-toolbar-block-type-items/src/App.tsx index eeb8e38887..155318652d 100644 --- a/examples/03-ui-components/03-formatting-toolbar-block-type-items/src/App.tsx +++ b/examples/03-ui-components/03-formatting-toolbar-block-type-items/src/App.tsx @@ -38,7 +38,7 @@ const CustomFormattingToolbar = () => { // Sets the items in the Block Type Select. blockTypeSelectItems={[ // Gets the default Block Type Select items. - ...blockTypeSelectItems(editor.dictionary), + ...blockTypeSelectItems(editor), // Adds an item for the Alert block. { name: "Alert", diff --git a/examples/03-ui-components/13-custom-ui/src/MUIFormattingToolbar.tsx b/examples/03-ui-components/13-custom-ui/src/MUIFormattingToolbar.tsx index 7a1b7d6b6d..b4833393a2 100644 --- a/examples/03-ui-components/13-custom-ui/src/MUIFormattingToolbar.tsx +++ b/examples/03-ui-components/13-custom-ui/src/MUIFormattingToolbar.tsx @@ -115,8 +115,8 @@ function MUIBlockTypeSelect() { // Gets the default items for the select. const defaultBlockTypeSelectItems = useMemo( - () => blockTypeSelectItems(editor.dictionary), - [editor.dictionary], + () => blockTypeSelectItems(editor), + [editor], ); // Gets the selected item. diff --git a/examples/06-custom-schema/05-alert-block-full-ux/src/App.tsx b/examples/06-custom-schema/05-alert-block-full-ux/src/App.tsx index 931c0e1169..409d7a1e97 100644 --- a/examples/06-custom-schema/05-alert-block-full-ux/src/App.tsx +++ b/examples/06-custom-schema/05-alert-block-full-ux/src/App.tsx @@ -42,7 +42,7 @@ const CustomFormattingToolbar = () => { // Sets the items in the Block Type Select. blockTypeSelectItems={[ // Gets the default Block Type Select items. - ...blockTypeSelectItems(editor.dictionary), + ...blockTypeSelectItems(editor), // Adds an item for the Alert block. { name: "Alert", diff --git a/examples/06-custom-schema/09-math-block/src/App.tsx b/examples/06-custom-schema/09-math-block/src/App.tsx index 554b3402bb..def4ecf3b1 100644 --- a/examples/06-custom-schema/09-math-block/src/App.tsx +++ b/examples/06-custom-schema/09-math-block/src/App.tsx @@ -84,7 +84,7 @@ export default function App() { formattingToolbar={() => ( diff --git a/examples/06-custom-schema/10-diagram-block/src/App.tsx b/examples/06-custom-schema/10-diagram-block/src/App.tsx index c96af84261..57998b3f8f 100644 --- a/examples/06-custom-schema/10-diagram-block/src/App.tsx +++ b/examples/06-custom-schema/10-diagram-block/src/App.tsx @@ -70,7 +70,7 @@ export default function App() { formattingToolbar={() => ( diff --git a/packages/core/src/blocks/defaultBlockTypeItems.test.ts b/packages/core/src/blocks/defaultBlockTypeItems.test.ts new file mode 100644 index 0000000000..97a4f23c93 --- /dev/null +++ b/packages/core/src/blocks/defaultBlockTypeItems.test.ts @@ -0,0 +1,70 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from "vite-plus/test"; + +import type { BlockNoteEditor } from "../editor/BlockNoteEditor.js"; +import { BlockNoteSchema } from "./BlockNoteSchema.js"; +import { createHeadingBlockSpec } from "./Heading/block.js"; +import { defaultBlockSpecs } from "./defaultBlocks.js"; +import { getDefaultBlockTypeItems } from "./defaultBlockTypeItems.js"; + +/** The items for a schema whose heading is `heading`. */ +function items(heading = createHeadingBlockSpec()) { + const schema = BlockNoteSchema.create({ + blockSpecs: { ...defaultBlockSpecs, heading }, + }); + // Only the schema is read. + return getDefaultBlockTypeItems({ schema } as unknown as BlockNoteEditor); +} + +describe("getDefaultBlockTypeItems", () => { + it("offers every default block type, in the block type select's order", () => { + expect(items().map((item) => item.key)).toEqual([ + "paragraph", + "heading", + "heading_2", + "heading_3", + "heading_4", + "heading_5", + "heading_6", + "toggle_heading", + "toggle_heading_2", + "toggle_heading_3", + "quote", + "toggle_list", + "bullet_list", + "numbered_list", + "check_list", + ]); + }); + + it("makes a regular heading turn a toggle heading into a regular one (BLO-959)", () => { + expect(items().find((item) => item.key === "heading_2")?.props).toEqual({ + level: 2, + isToggleable: false, + }); + }); + + it("offers regular headings when toggle headings are disabled (BLO-1236)", () => { + const offered = items( + createHeadingBlockSpec({ allowToggleHeadings: false }), + ); + expect(offered.filter((item) => item.type === "heading")).toEqual([ + { key: "heading", type: "heading", props: { level: 1 } }, + { key: "heading_2", type: "heading", props: { level: 2 } }, + { key: "heading_3", type: "heading", props: { level: 3 } }, + { key: "heading_4", type: "heading", props: { level: 4 } }, + { key: "heading_5", type: "heading", props: { level: 5 } }, + { key: "heading_6", type: "heading", props: { level: 6 } }, + ]); + }); + + it("offers only the configured heading levels (BLO-990)", () => { + expect( + items(createHeadingBlockSpec({ levels: [2, 4] })) + .filter((item) => item.type === "heading") + .map((item) => item.key), + ).toEqual(["heading_2", "heading_4", "toggle_heading_2"]); + }); +}); diff --git a/packages/core/src/blocks/defaultBlockTypeItems.ts b/packages/core/src/blocks/defaultBlockTypeItems.ts new file mode 100644 index 0000000000..6d5e1b3996 --- /dev/null +++ b/packages/core/src/blocks/defaultBlockTypeItems.ts @@ -0,0 +1,99 @@ +import type { BlockNoteEditor } from "../editor/BlockNoteEditor.js"; +import { editorHasBlockWithType } from "./defaultBlockTypeGuards.js"; + +const HEADING_KEYS = { + 1: "heading", + 2: "heading_2", + 3: "heading_3", + 4: "heading_4", + 5: "heading_5", + 6: "heading_6", +} as const; + +const TOGGLE_HEADING_KEYS = { + 1: "toggle_heading", + 2: "toggle_heading_2", + 3: "toggle_heading_3", +} as const; + +/** + * A block type that the default menus (the slash menu and the block type + * select) offer, with the props that choosing it sets. + */ +export type DefaultBlockTypeItem = { + /** The item's key in `dictionary.slash_menu`, which both menus share. */ + key: + | (typeof HEADING_KEYS)[keyof typeof HEADING_KEYS] + | (typeof TOGGLE_HEADING_KEYS)[keyof typeof TOGGLE_HEADING_KEYS] + | "paragraph" + | "quote" + | "toggle_list" + | "bullet_list" + | "numbered_list" + | "check_list"; + type: string; + props?: Record; +}; + +/** + * The block types that the editor's schema supports, out of the ones that the + * default menus offer: paragraph, headings, toggle headings, quote and lists. + * Each menu places them in its own order, by key; this list has the block type + * select's order. + * + * Headings are offered for the heading's configured `levels` (BLO-990). + * Toggle headings are offered only when the heading has `isToggleable`, and a + * regular heading then sets `isToggleable: false`, so that choosing it turns a + * toggle heading into a regular one (BLO-959, BLO-1236). + */ +export function getDefaultBlockTypeItems( + editor: BlockNoteEditor, +): DefaultBlockTypeItem[] { + const items: DefaultBlockTypeItem[] = []; + + if (editorHasBlockWithType(editor, "paragraph")) { + items.push({ key: "paragraph", type: "paragraph" }); + } + + if (editorHasBlockWithType(editor, "heading", { level: "number" })) { + const levels = editor.schema.blockSchema.heading.propSchema.level.values; + const hasToggles = editorHasBlockWithType(editor, "heading", { + level: "number", + isToggleable: "boolean", + }); + for (const level of [1, 2, 3, 4, 5, 6] as const) { + if (levels?.includes(level)) { + items.push({ + key: HEADING_KEYS[level], + type: "heading", + props: hasToggles ? { level, isToggleable: false } : { level }, + }); + } + } + if (hasToggles) { + for (const level of [1, 2, 3] as const) { + if (levels?.includes(level)) { + items.push({ + key: TOGGLE_HEADING_KEYS[level], + type: "heading", + props: { level, isToggleable: true }, + }); + } + } + } + } + + for (const [key, type] of [ + ["quote", "quote"], + ["toggle_list", "toggleListItem"], + ["bullet_list", "bulletListItem"], + ["numbered_list", "numberedListItem"], + ["check_list", "checkListItem"], + ] as const) { + if (editorHasBlockWithType(editor, type)) { + items.push({ key, type }); + } + } + + return items; +} diff --git a/packages/core/src/blocks/index.ts b/packages/core/src/blocks/index.ts index 2ba1252acc..72cfa6e7a5 100644 --- a/packages/core/src/blocks/index.ts +++ b/packages/core/src/blocks/index.ts @@ -26,4 +26,5 @@ export * from "./BlockNoteSchema.js"; export * from "./defaultBlockHelpers.js"; export * from "./defaultBlocks.js"; export * from "./defaultBlockTypeGuards.js"; +export * from "./defaultBlockTypeItems.js"; export * from "./defaultProps.js"; diff --git a/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts b/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts index 6c0682f012..30497bb8de 100644 --- a/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts +++ b/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts @@ -1,5 +1,9 @@ import { Block, PartialBlock } from "../../blocks/defaultBlocks.js"; import { editorHasBlockWithType } from "../../blocks/defaultBlockTypeGuards.js"; +import { + type DefaultBlockTypeItem, + getDefaultBlockTypeItems, +} from "../../blocks/defaultBlockTypeItems.js"; import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import { BlockSchema, @@ -92,21 +96,6 @@ export function insertOrUpdateBlockForSlashMenu< return newBlock; } -// A regular heading's props. With toggle headings in the schema, this sets -// `isToggleable: false`, so that updating a toggle heading turns it into a -// regular heading (BLO-959). -function regularHeadingProps( - editor: BlockNoteEditor, - level: number, -) { - return editorHasBlockWithType(editor, "heading", { - level: "number", - isToggleable: "boolean", - }) - ? { level, isToggleable: false } - : { level }; -} - export function getDefaultSlashMenuItems< BSchema extends BlockSchema, I extends InlineContentSchema, @@ -114,103 +103,39 @@ export function getDefaultSlashMenuItems< >(editor: BlockNoteEditor) { const items: DefaultSuggestionItem[] = []; - if (editorHasBlockWithType(editor, "heading", { level: "number" })) { - (editor.schema.blockSchema.heading.propSchema.level.values || []) - .filter((level): level is 1 | 2 | 3 => level <= 3) - .forEach((level) => { - items.push({ - onItemClick: () => { - insertOrUpdateBlockForSlashMenu(editor, { - type: "heading", - props: regularHeadingProps(editor, level), - }); - }, - badge: formatKeyboardShortcut(`Mod-Alt-${level}`), - key: - level === 1 ? ("heading" as const) : (`heading_${level}` as const), - ...editor.dictionary.slash_menu[ - level === 1 ? ("heading" as const) : (`heading_${level}` as const) - ], - }); - }); - } - - if (editorHasBlockWithType(editor, "quote")) { - items.push({ - onItemClick: () => { - insertOrUpdateBlockForSlashMenu(editor, { - type: "quote", - }); - }, - key: "quote", - ...editor.dictionary.slash_menu.quote, - }); - } - - if (editorHasBlockWithType(editor, "toggleListItem")) { - items.push({ - onItemClick: () => { - insertOrUpdateBlockForSlashMenu(editor, { - type: "toggleListItem", - }); - }, - badge: formatKeyboardShortcut("Mod-Shift-6"), - key: "toggle_list", - ...editor.dictionary.slash_menu.toggle_list, - }); - } - - if (editorHasBlockWithType(editor, "numberedListItem")) { - items.push({ - onItemClick: () => { - insertOrUpdateBlockForSlashMenu(editor, { - type: "numberedListItem", - }); - }, - badge: formatKeyboardShortcut("Mod-Shift-7"), - key: "numbered_list", - ...editor.dictionary.slash_menu.numbered_list, - }); - } - - if (editorHasBlockWithType(editor, "bulletListItem")) { + // The block types come from `getDefaultBlockTypeItems`, which the block + // type select also uses. The slash menu places them in its own order. + const blockTypes = new Map( + getDefaultBlockTypeItems(editor).map((item) => [item.key, item]), + ); + function pushBlockType(key: DefaultBlockTypeItem["key"], badge?: string) { + const blockType = blockTypes.get(key); + if (!blockType) { + return; + } items.push({ onItemClick: () => { + // The schema supports the block type (see `getDefaultBlockTypeItems`). insertOrUpdateBlockForSlashMenu(editor, { - type: "bulletListItem", - }); + type: blockType.type, + props: blockType.props, + } as PartialBlock); }, - badge: formatKeyboardShortcut("Mod-Shift-8"), - key: "bullet_list", - ...editor.dictionary.slash_menu.bullet_list, + badge, + key, + ...editor.dictionary.slash_menu[key], }); } - if (editorHasBlockWithType(editor, "checkListItem")) { - items.push({ - onItemClick: () => { - insertOrUpdateBlockForSlashMenu(editor, { - type: "checkListItem", - }); - }, - badge: formatKeyboardShortcut("Mod-Shift-9"), - key: "check_list", - ...editor.dictionary.slash_menu.check_list, - }); - } - - if (editorHasBlockWithType(editor, "paragraph")) { - items.push({ - onItemClick: () => { - insertOrUpdateBlockForSlashMenu(editor, { - type: "paragraph", - }); - }, - badge: formatKeyboardShortcut("Mod-Alt-0"), - key: "paragraph", - ...editor.dictionary.slash_menu.paragraph, - }); - } + pushBlockType("heading", formatKeyboardShortcut("Mod-Alt-1")); + pushBlockType("heading_2", formatKeyboardShortcut("Mod-Alt-2")); + pushBlockType("heading_3", formatKeyboardShortcut("Mod-Alt-3")); + pushBlockType("quote"); + pushBlockType("toggle_list", formatKeyboardShortcut("Mod-Shift-6")); + pushBlockType("numbered_list", formatKeyboardShortcut("Mod-Shift-7")); + pushBlockType("bullet_list", formatKeyboardShortcut("Mod-Shift-8")); + pushBlockType("check_list", formatKeyboardShortcut("Mod-Shift-9")); + pushBlockType("paragraph", formatKeyboardShortcut("Mod-Alt-0")); if (editorHasBlockWithType(editor, "codeBlock")) { items.push({ @@ -339,52 +264,12 @@ export function getDefaultSlashMenuItems< }); } - if ( - editorHasBlockWithType(editor, "heading", { - level: "number", - isToggleable: "boolean", - }) - ) { - (editor.schema.blockSchema.heading.propSchema.level.values || []) - .filter((level): level is 1 | 2 | 3 => level <= 3) - .forEach((level) => { - items.push({ - onItemClick: () => { - insertOrUpdateBlockForSlashMenu(editor, { - type: "heading", - props: { level: level, isToggleable: true }, - }); - }, - key: - level === 1 - ? ("toggle_heading" as const) - : (`toggle_heading_${level}` as const), - ...editor.dictionary.slash_menu[ - level === 1 - ? ("toggle_heading" as const) - : (`toggle_heading_${level}` as const) - ], - }); - }); - } - - if (editorHasBlockWithType(editor, "heading", { level: "number" })) { - (editor.schema.blockSchema.heading.propSchema.level.values || []) - .filter((level): level is 4 | 5 | 6 => level > 3) - .forEach((level) => { - items.push({ - onItemClick: () => { - insertOrUpdateBlockForSlashMenu(editor, { - type: "heading", - props: regularHeadingProps(editor, level), - }); - }, - badge: formatKeyboardShortcut(`Mod-Alt-${level}`), - key: `heading_${level}`, - ...editor.dictionary.slash_menu[`heading_${level}`], - }); - }); - } + pushBlockType("toggle_heading"); + pushBlockType("toggle_heading_2"); + pushBlockType("toggle_heading_3"); + pushBlockType("heading_4", formatKeyboardShortcut("Mod-Alt-4")); + pushBlockType("heading_5", formatKeyboardShortcut("Mod-Alt-5")); + pushBlockType("heading_6", formatKeyboardShortcut("Mod-Alt-6")); items.push({ onItemClick: () => { diff --git a/packages/react/src/components/FormattingToolbar/DefaultSelects/BlockTypeSelect.test.ts b/packages/react/src/components/FormattingToolbar/DefaultSelects/BlockTypeSelect.test.ts new file mode 100644 index 0000000000..f7b2071eae --- /dev/null +++ b/packages/react/src/components/FormattingToolbar/DefaultSelects/BlockTypeSelect.test.ts @@ -0,0 +1,46 @@ +/** + * @vitest-environment node + */ +import { + type BlockNoteEditor, + BlockNoteSchema, + createHeadingBlockSpec, + defaultBlockSpecs, +} from "@blocknote/core"; +import { en } from "@blocknote/core/locales"; +import { describe, expect, it } from "vite-plus/test"; + +import { blockTypeSelectItems } from "./BlockTypeSelect.js"; + +function schemaWith(heading = createHeadingBlockSpec()) { + return BlockNoteSchema.create({ + blockSpecs: { ...defaultBlockSpecs, heading }, + }); +} + +describe("blockTypeSelectItems", () => { + it("lists the default items in the same order as before", () => { + // Only the schema and the dictionary are read. + const editor = { + schema: schemaWith(), + dictionary: en, + } as unknown as BlockNoteEditor; + expect(blockTypeSelectItems(editor).map((item) => item.name)).toEqual([ + "Paragraph", + "Heading 1", + "Heading 2", + "Heading 3", + "Heading 4", + "Heading 5", + "Heading 6", + "Toggle Heading 1", + "Toggle Heading 2", + "Toggle Heading 3", + "Quote", + "Toggle List", + "Bullet List", + "Numbered List", + "Check List", + ]); + }); +}); diff --git a/packages/react/src/components/FormattingToolbar/DefaultSelects/BlockTypeSelect.tsx b/packages/react/src/components/FormattingToolbar/DefaultSelects/BlockTypeSelect.tsx index ba66847b02..0faa196988 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultSelects/BlockTypeSelect.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultSelects/BlockTypeSelect.tsx @@ -1,7 +1,9 @@ import { + BlockNoteEditor, BlockSchema, - Dictionary, + DefaultBlockTypeItem, editorHasBlockWithType, + getDefaultBlockTypeItems, InlineContentSchema, StyleSchema, } from "@blocknote/core"; @@ -38,94 +40,37 @@ export type BlockTypeSelectItem = { icon: IconType; }; +const icons: Record = { + paragraph: RiText, + heading: RiH1, + heading_2: RiH2, + heading_3: RiH3, + heading_4: RiH4, + heading_5: RiH5, + heading_6: RiH6, + toggle_heading: RiH1, + toggle_heading_2: RiH2, + toggle_heading_3: RiH3, + quote: RiQuoteText, + toggle_list: RiPlayList2Fill, + bullet_list: RiListUnordered, + numbered_list: RiListOrdered, + check_list: RiListCheck3, +}; + +/** + * The default block type select items: the block types of the editor's schema + * that the default menus offer (see `getDefaultBlockTypeItems`). + */ export const blockTypeSelectItems = ( - dict: Dictionary, -): BlockTypeSelectItem[] => [ - { - name: dict.slash_menu.paragraph.title, - type: "paragraph", - icon: RiText, - }, - { - name: dict.slash_menu.heading.title, - type: "heading", - props: { level: 1, isToggleable: false }, - icon: RiH1, - }, - { - name: dict.slash_menu.heading_2.title, - type: "heading", - props: { level: 2, isToggleable: false }, - icon: RiH2, - }, - { - name: dict.slash_menu.heading_3.title, - type: "heading", - props: { level: 3, isToggleable: false }, - icon: RiH3, - }, - { - name: dict.slash_menu.heading_4.title, - type: "heading", - props: { level: 4, isToggleable: false }, - icon: RiH4, - }, - { - name: dict.slash_menu.heading_5.title, - type: "heading", - props: { level: 5, isToggleable: false }, - icon: RiH5, - }, - { - name: dict.slash_menu.heading_6.title, - type: "heading", - props: { level: 6, isToggleable: false }, - icon: RiH6, - }, - { - name: dict.slash_menu.toggle_heading.title, - type: "heading", - props: { level: 1, isToggleable: true }, - icon: RiH1, - }, - { - name: dict.slash_menu.toggle_heading_2.title, - type: "heading", - props: { level: 2, isToggleable: true }, - icon: RiH2, - }, - { - name: dict.slash_menu.toggle_heading_3.title, - type: "heading", - props: { level: 3, isToggleable: true }, - icon: RiH3, - }, - { - name: dict.slash_menu.quote.title, - type: "quote", - icon: RiQuoteText, - }, - { - name: dict.slash_menu.toggle_list.title, - type: "toggleListItem", - icon: RiPlayList2Fill, - }, - { - name: dict.slash_menu.bullet_list.title, - type: "bulletListItem", - icon: RiListUnordered, - }, - { - name: dict.slash_menu.numbered_list.title, - type: "numberedListItem", - icon: RiListOrdered, - }, - { - name: dict.slash_menu.check_list.title, - type: "checkListItem", - icon: RiListCheck3, - }, -]; + editor: BlockNoteEditor, +): BlockTypeSelectItem[] => + getDefaultBlockTypeItems(editor).map(({ key, type, props }) => ({ + name: editor.dictionary.slash_menu[key].title, + type, + props, + icon: icons[key], + })); export const BlockTypeSelect = (props: { items?: BlockTypeSelectItem[] }) => { const Components = useComponentsContext()!; @@ -149,7 +94,7 @@ export const BlockTypeSelect = (props: { items?: BlockTypeSelectItem[] }) => { // the schema. const filteredItems = useMemo( () => - (props.items || blockTypeSelectItems(editor.dictionary)).filter((item) => + (props.items || blockTypeSelectItems(editor)).filter((item) => editorHasBlockWithType( editor, item.type,