From c3ac08a2a361f14addc50051a8f93731f2be682b Mon Sep 17 00:00:00 2001 From: yousefed Date: Tue, 29 Sep 2026 21:19:55 +0200 Subject: [PATCH 1/8] test(core): add behaviour tests for the built-in toggle blocks Tests for the toggle heading and the toggle list item, covering the toggle bugs under BLO-1018 and the rest of their editing behaviour. Every test runs for both blocks. The expected behaviour is Notion's (compared on 2026-09-29), with one exception: a toggle heading turned into a regular heading keeps its children nested. Tests for behaviour that BlockNote does not have yet use `it.fails`, so they fail once it is implemented and must then be changed to `it`: - Enter at the end of an open toggle's title adds a first child (BLO-929) - Enter mid-title moves the rest of the title into a first child (BLO-949) - Enter on an empty last child adds another child - Backspace at the start of the first child merges into the title (fixed by #3124) - the toggle stays open when its last child is removed or moved out - Enter in an empty toggle heading makes a regular heading - Mod-Alt-2 and the slash menu turn a toggle heading into a regular heading (BLO-959) - ArrowDown moves out of an open, empty toggle (BLO-956) - a block dropped into an open, empty toggle becomes its child (BLO-956) Keyboard and open-state tests are browser unit tests next to the toggle code. Drag and drop, the side menu and the placeholder need the full editor view, so they are end-to-end tests. --- .../toggleBlocks.browser.test.ts | 668 ++++++++++++++++++ .../toggleblocks/toggleblocks.test.tsx | 210 ++++++ 2 files changed, 878 insertions(+) create mode 100644 packages/core/src/blocks/ToggleWrapper/toggleBlocks.browser.test.ts create mode 100644 tests/src/end-to-end/toggleblocks/toggleblocks.test.tsx diff --git a/packages/core/src/blocks/ToggleWrapper/toggleBlocks.browser.test.ts b/packages/core/src/blocks/ToggleWrapper/toggleBlocks.browser.test.ts new file mode 100644 index 0000000000..cfa4e57fea --- /dev/null +++ b/packages/core/src/blocks/ToggleWrapper/toggleBlocks.browser.test.ts @@ -0,0 +1,668 @@ +import { TextSelection } from "prosemirror-state"; +import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; +import { userEvent } from "vite-plus/test/browser"; + +import "../../style.css"; +import { getNodeById } from "../../api/nodeUtil.js"; +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { getDefaultSlashMenuItems } from "../../extensions/SuggestionMenu/getDefaultSlashMenuItems.js"; +import type { PartialBlock } from "../defaultBlocks.js"; + +// Behaviour of the built-in toggle blocks (toggle heading and toggle list +// item), including regressions reported under BLO-1018. Every test runs for +// both blocks. The expected behaviour is Notion's (compared on 2026-09-29), +// with one deliberate exception: a toggle heading turned into a regular +// heading keeps its children nested, where Notion moves them out. Tests for +// behaviour BlockNote does not match yet use `it.fails`: they state the +// expected behaviour, so they start failing - and must be switched to `it` - +// once it is implemented. + +const MOD = navigator.platform.includes("Mac") ? "Meta" : "Control"; + +const kinds = [ + { + name: "toggle heading", + toggle: ( + id: string, + content: string, + children: PartialBlock[] = [], + ): PartialBlock => ({ + id, + type: "heading", + props: { level: 2, isToggleable: true }, + content, + children, + }), + newBlockTypeAfterClosedToggle: "paragraph", + }, + { + name: "toggle list item", + toggle: ( + id: string, + content: string, + children: PartialBlock[] = [], + ): PartialBlock => ({ + id, + type: "toggleListItem", + content, + children, + }), + newBlockTypeAfterClosedToggle: "toggleListItem", + }, +]; + +let editor: BlockNoteEditor; +let root: HTMLElement; + +function mount(content: PartialBlock[], options = { editable: true }) { + root = document.createElement("div"); + document.body.appendChild(root); + editor = BlockNoteEditor.create({ initialContent: content }); + editor.isEditable = options.editable; + editor.mount(root); +} + +/** Replaces the editor with a new one, as a page reload would. */ +function remount(content: PartialBlock[], options = { editable: true }) { + editor._tiptapEditor.destroy(); + root.remove(); + mount(content, options); +} + +beforeEach(() => { + // The open state of a toggle is kept in `localStorage`, keyed by block id. + localStorage.clear(); +}); + +afterEach(() => { + editor._tiptapEditor.destroy(); + root.remove(); +}); + +/** The block's type, text and children, e.g. `heading"Title"[paragraph"One"]`. */ +function shape(blocks = editor.document): string { + return blocks + .map((block) => { + const text = Array.isArray(block.content) + ? block.content.map((c) => ("text" in c ? c.text : "")).join("") + : ""; + const children = block.children.length + ? `[${shape(block.children)}]` + : ""; + return `${block.type}"${text}"${children}`; + }) + .join(", "); +} + +function blockElement(id: string) { + const element = root.querySelector(`.bn-block[data-id="${id}"]`); + if (!element) { + throw new Error(`Block "${id}" is not rendered`); + } + return element as HTMLElement; +} + +/** + * The first element matching `selector` that belongs to the block itself, + * not to one of its children. Where it sits inside the block is left open. + */ +function own(id: string, selector: string) { + const block = blockElement(id); + return ( + [...block.querySelectorAll(selector)].find( + (element) => element.closest(".bn-block") === block, + ) ?? null + ); +} + +function toggleButton(id: string) { + return own(id, ".bn-toggle-button"); +} + +function addBlockButton(id: string) { + return own(id, ".bn-toggle-add-block-button"); +} + +/** Whether the toggle is open, as the toggle wrapper records it. */ +function isOpen(id: string) { + const wrapper = own(id, ".bn-toggle-wrapper"); + if (!wrapper) { + throw new Error(`Block "${id}" is not a toggle`); + } + return wrapper.dataset.showChildren === "true"; +} + +function childrenAreVisible(id: string) { + const group = own(id, ".bn-block-group"); + if (!group) { + throw new Error(`Block "${id}" has no children`); + } + return getComputedStyle(group).display !== "none"; +} + +async function open(id: string) { + await userEvent.click(toggleButton(id)!); +} + +/** Puts the caret in the block and presses the keys. */ +async function press( + keys: string, + at: { block: string; placement: "start" | "end" }, +) { + editor.setTextCursorPosition(at.block, at.placement); + editor.focus(); + await userEvent.keyboard(keys); +} + +/** + * Puts the caret after the first `offset` characters of the block's text. + * BlockNote's API can only put the caret at the start or end of a block. + */ +function setCaretAt(id: string, offset: number) { + editor.transact((tr) => { + const block = getNodeById(id, tr.doc)!; + // Inside the block, then inside its content. + const textStart = block.posBeforeNode + 2; + tr.setSelection(TextSelection.create(tr.doc, textStart + offset)); + }); + editor.focus(); +} + +describe.each(kinds)("$name", ({ toggle, newBlockTypeAfterClosedToggle }) => { + const withChildren = (): PartialBlock[] => [ + toggle("t", "Title", [ + { id: "c1", type: "paragraph", content: "One" }, + { id: "c2", type: "paragraph", content: "Two" }, + ]), + { id: "after", type: "paragraph", content: "After" }, + ]; + + describe("open state", () => { + it("starts closed, with its children hidden", () => { + mount(withChildren()); + + expect(isOpen("t")).toBe(false); + expect(childrenAreVisible("t")).toBe(false); + }); + + it("shows and hides its children with the chevron, without changing the document", async () => { + mount(withChildren()); + const document = JSON.stringify(editor.document); + + await open("t"); + expect(childrenAreVisible("t")).toBe(true); + + await open("t"); + expect(childrenAreVisible("t")).toBe(false); + expect(JSON.stringify(editor.document)).toBe(document); + }); + + it("keeps the open state for the block when the editor is recreated", async () => { + mount(withChildren()); + await open("t"); + + remount(withChildren()); + expect(isOpen("t")).toBe(true); + expect(childrenAreVisible("t")).toBe(true); + + await open("t"); + remount(withChildren()); + expect(isOpen("t")).toBe(false); + }); + + it("keeps the caret where it is when the chevron is clicked", async () => { + mount(withChildren()); + editor.setTextCursorPosition("after", "end"); + editor.focus(); + + await open("t"); + + expect(editor.getTextCursorPosition().block.id).toBe("after"); + expect(editor.isFocused()).toBe(true); + }); + + it("does not re-render the block when it is opened or closed", async () => { + mount(withChildren()); + const content = own("t", ".bn-block-content")!; + + await open("t"); + await open("t"); + + expect(content.isConnected).toBe(true); + }); + + it("opens when a block is indented into it", async () => { + mount(withChildren()); + + await press("{Tab}", { block: "after", placement: "start" }); + + expect(editor.getBlock("t")!.children.map((child) => child.id)).toEqual([ + "c1", + "c2", + "after", + ]); + expect(isOpen("t")).toBe(true); + expect(childrenAreVisible("t")).toBe(true); + + // The toggle opened itself, and that is kept like a click. + remount(editor.document); + expect(isOpen("t")).toBe(true); + }); + + // 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 () => { + mount([toggle("t", "Title", [{ id: "c1", type: "paragraph" }])]); + await open("t"); + + editor.removeBlocks(["c1"]); + + expect(editor.getBlock("t")!.children).toHaveLength(0); + expect(isOpen("t")).toBe(true); + expect(addBlockButton("t")).not.toBeNull(); + }); + + it("stays open when one of several children is removed", async () => { + mount(withChildren()); + await open("t"); + + editor.removeBlocks(["c1"]); + + expect(isOpen("t")).toBe(true); + expect(childrenAreVisible("t")).toBe(true); + }); + }); + + 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")]); + expect(addBlockButton("t")).toBeNull(); + + await open("t"); + await userEvent.click(addBlockButton("t")!); + + const child = editor.getBlock("t")!.children; + expect(child).toHaveLength(1); + expect(editor.getTextCursorPosition().block.id).toBe(child[0].id); + expect(addBlockButton("t")).toBeNull(); + }); + + it('removes the "Add block" button when closed', async () => { + mount([toggle("t", "Title")]); + await open("t"); + + await open("t"); + + expect(addBlockButton("t")).toBeNull(); + }); + + it('replaces the "Add block" button when a block is indented into it', async () => { + mount([ + toggle("t", "Title"), + { id: "after", type: "paragraph", content: "After" }, + ]); + await open("t"); + + await press("{Tab}", { block: "after", placement: "start" }); + + expect(editor.getBlock("t")!.children.map((child) => child.id)).toEqual([ + "after", + ]); + expect(addBlockButton("t")).toBeNull(); + expect(childrenAreVisible("t")).toBe(true); + }); + + it('shows the "Add block" button when it was left open and is recreated', async () => { + mount([toggle("t", "Title")]); + await open("t"); + + remount([toggle("t", "Title")]); + + expect(isOpen("t")).toBe(true); + expect(addBlockButton("t")).not.toBeNull(); + }); + + it('shows no "Add block" button in a read-only editor', async () => { + mount([toggle("t", "Title")]); + editor.isEditable = false; + + await open("t"); + + expect(addBlockButton("t")).toBeNull(); + }); + + it('shows no "Add block" button when a read-only editor opens it from its saved state', async () => { + mount([toggle("t", "Title")]); + await open("t"); + + remount([toggle("t", "Title")], { editable: false }); + + expect(isOpen("t")).toBe(true); + expect(addBlockButton("t")).toBeNull(); + }); + + // BLO-956 (comment): in an open, empty toggle, the caret could not move + // down out of the title. + it.fails("ArrowDown moves the caret to the next block (BLO-956)", async () => { + mount([ + toggle("t", "Title"), + { id: "after", type: "paragraph", content: "After" }, + ]); + await open("t"); + + await press("{ArrowDown}", { block: "t", placement: "end" }); + + expect(editor.getTextCursorPosition().block.id).toBe("after"); + }); + }); + + describe("Enter", () => { + // BLO-929: Enter at the end of an open toggle's title should start the + // toggle's body, as in Notion, not a new block after the toggle. + it.fails("at the end of an open toggle's title adds a first child (BLO-929)", async () => { + mount(withChildren()); + await open("t"); + + await press("{Enter}", { block: "t", placement: "end" }); + + const toggleBlock = editor.getBlock("t")!; + expect(toggleBlock.children).toHaveLength(3); + expect(editor.getTextCursorPosition().block.id).toBe( + toggleBlock.children[0].id, + ); + }); + + // BLO-998 / #2020: on a closed toggle, the new block after it must not + // take the toggle's children. + it("at the end of a closed toggle's title keeps the children in the toggle (BLO-998)", async () => { + mount(withChildren()); + + await press("{Enter}", { block: "t", placement: "end" }); + + const [first, second] = editor.document; + expect(first.id).toBe("t"); + expect(first.children.map((child) => child.id)).toEqual(["c1", "c2"]); + expect(second.children).toHaveLength(0); + // A toggle list continues as a list; a heading continues with text. + expect(second.type).toBe(newBlockTypeAfterClosedToggle); + expect(editor.getTextCursorPosition().block.id).toBe(second.id); + }); + + // BLO-949: Enter in the title must not break the children apart. As in + // Notion, the text after the caret becomes the toggle's first child. + it.fails("in the middle of an open toggle's title moves the rest of the title into a first child (BLO-949)", async () => { + mount(withChildren()); + await open("t"); + + setCaretAt("t", 2); + await userEvent.keyboard("{Enter}"); + + expect(shape([editor.getBlock("t")!])).toMatch( + /^\w+"Ti"\[paragraph"tle", paragraph"One", paragraph"Two"\]$/, + ); + expect(editor.getTextCursorPosition().block.id).toBe( + editor.getBlock("t")!.children[0].id, + ); + }); + + // As in Notion, Enter on an empty last child stays inside the toggle. + it.fails("on an empty last child adds another child", async () => { + mount([ + toggle("t", "Title", [ + { id: "c1", type: "paragraph", content: "One" }, + { id: "c2", type: "paragraph" }, + ]), + ]); + await open("t"); + + await press("{Enter}", { block: "c2", placement: "start" }); + + const children = editor.getBlock("t")!.children; + expect(children.map((child) => child.id).slice(0, 2)).toEqual([ + "c1", + "c2", + ]); + expect(children).toHaveLength(3); + expect(editor.getTextCursorPosition().block.id).toBe(children[2].id); + }); + + // BLO-949: Enter in a child continues inside the toggle. + it("at the end of a child adds a sibling child (BLO-949)", async () => { + mount(withChildren()); + await open("t"); + + await press("{Enter}", { block: "c1", placement: "end" }); + + const children = editor.getBlock("t")!.children; + expect(children).toHaveLength(3); + expect(children[0].id).toBe("c1"); + expect(editor.getTextCursorPosition().block.id).toBe(children[1].id); + }); + }); + + it("turned into a paragraph shows its children and no chevron", () => { + mount(withChildren()); + + editor.updateBlock("t", { type: "paragraph", props: {} }); + + expect(toggleButton("t")).toBeNull(); + expect(childrenAreVisible("t")).toBe(true); + }); + + describe("Backspace", () => { + // As in Notion, Backspace at the start of the first child merges it into + // the title. + it.fails("at the start of the first child merges it into the title", async () => { + mount(withChildren()); + await open("t"); + + await press("{Backspace}", { block: "c1", placement: "start" }); + + expect(shape([editor.getBlock("t")!])).toMatch( + /^\w+"TitleOne"\[paragraph"Two"\]$/, + ); + expect(editor.document.map((block) => block.id)).toEqual(["t", "after"]); + }); + }); + + // Shift-Mod-ArrowUp/Down, BlockNote's shortcuts for moving blocks. + describe("moving blocks", () => { + const moveUp = `{${MOD}>}{Shift>}{ArrowUp}{/Shift}{/${MOD}}`; + const moveDown = `{${MOD}>}{Shift>}{ArrowDown}{/Shift}{/${MOD}}`; + + it("opens a closed toggle when a block is moved into it", async () => { + mount(withChildren()); + + await press(moveUp, { block: "after", placement: "start" }); + + expect(editor.getBlock("t")!.children.map((child) => child.id)).toEqual([ + "c1", + "c2", + "after", + ]); + expect(isOpen("t")).toBe(true); + expect(childrenAreVisible("t")).toBe(true); + }); + + it.fails("keeps a toggle open when its last child is moved out", async () => { + mount([ + toggle("t", "Title", [{ id: "c1", type: "paragraph", content: "One" }]), + ]); + await open("t"); + + await press(moveDown, { block: "c1", placement: "start" }); + + expect(editor.document.map((block) => block.id)).toEqual(["t", "c1"]); + expect(isOpen("t")).toBe(true); + expect(addBlockButton("t")).not.toBeNull(); + }); + + it("keeps an open toggle open, with its children, when it is moved", async () => { + mount([ + { id: "before", type: "paragraph", content: "Before" }, + ...withChildren(), + ]); + await open("t"); + + await press(moveUp, { block: "t", placement: "end" }); + + expect(editor.document.map((block) => block.id)).toEqual([ + "t", + "before", + "after", + ]); + expect(editor.getBlock("t")!.children.map((child) => child.id)).toEqual([ + "c1", + "c2", + ]); + expect(isOpen("t")).toBe(true); + expect(childrenAreVisible("t")).toBe(true); + }); + + it("keeps a closed toggle closed, with its children, when it is moved", async () => { + mount([ + { id: "before", type: "paragraph", content: "Before" }, + ...withChildren(), + ]); + + await press(moveUp, { block: "t", placement: "end" }); + + expect(editor.document.map((block) => block.id)).toEqual([ + "t", + "before", + "after", + ]); + expect(editor.getBlock("t")!.children.map((child) => child.id)).toEqual([ + "c1", + "c2", + ]); + expect(isOpen("t")).toBe(false); + expect(childrenAreVisible("t")).toBe(false); + }); + }); + + describe("indentation", () => { + it("Shift-Tab moves a child out of the toggle", async () => { + mount(withChildren()); + await open("t"); + + await press("{Shift>}{Tab}{/Shift}", { block: "c2", placement: "start" }); + + expect(editor.getBlock("t")!.children.map((child) => child.id)).toEqual([ + "c1", + ]); + expect(editor.document.map((block) => block.id)).toEqual([ + "t", + "c2", + "after", + ]); + }); + }); +}); + +// As in Notion, Enter in an empty toggle title removes the toggle: a toggle +// list item becomes a paragraph, a toggle heading a regular heading. +describe("Enter in an empty toggle title", () => { + it("turns a toggle list item into a paragraph", async () => { + mount([ + { id: "t", type: "toggleListItem", content: "Title" }, + { id: "empty", type: "toggleListItem" }, + ]); + + await press("{Enter}", { block: "empty", placement: "start" }); + + expect(editor.getBlock("empty")!.type).toBe("paragraph"); + expect(editor.document).toHaveLength(2); + }); + + it.fails("turns a toggle heading into a regular heading", async () => { + mount([ + { id: "t", type: "paragraph", content: "Before" }, + { id: "empty", type: "heading", props: { level: 2, isToggleable: true } }, + ]); + + await press("{Enter}", { block: "empty", placement: "start" }); + + expect(editor.getBlock("empty")!.props).toMatchObject({ + level: 2, + isToggleable: false, + }); + expect(editor.document).toHaveLength(2); + expect(toggleButton("empty")).toBeNull(); + }); +}); + +// BLO-959: turning a toggle heading into a regular heading must remove the +// toggle behaviour. Each way of turning a block into a heading is covered. +// Unlike Notion, which moves the children out (its headings can't have +// children), the children stay nested under the heading. +describe("toggle heading turned into a regular heading (BLO-959)", () => { + const toggleHeading = () => [ + { + id: "t", + type: "heading" as const, + props: { level: 1 as const, isToggleable: true }, + content: "Title", + children: [{ id: "c1", type: "paragraph" as const, content: "One" }], + }, + ]; + + function expectRegularHeading() { + expect(editor.getBlock("t")!.props).toMatchObject({ + level: 2, + isToggleable: false, + }); + expect(toggleButton("t")).toBeNull(); + expect(editor.getBlock("t")!.children.map((child) => child.id)).toEqual([ + "c1", + ]); + expect(childrenAreVisible("t")).toBe(true); + } + + it("with the block type menu's props", () => { + mount(toggleHeading()); + + // What the formatting toolbar's block type select applies for "Heading 2". + editor.updateBlock("t", { + type: "heading", + props: { level: 2, isToggleable: false }, + }); + + expectRegularHeading(); + }); + + // Not compared with Notion: its Cmd-Option-2 could not be automated there. + it.fails("with the heading keyboard shortcut", async () => { + mount(toggleHeading()); + + await press(`{${MOD}>}{Alt>}2{/Alt}{/${MOD}}`, { + block: "t", + placement: "end", + }); + + expectRegularHeading(); + }); + + // In Notion, the slash menu's "Heading 2" in an empty toggle heading keeps + // the toggle heading and inserts a regular heading after it. + it.fails("with the slash menu's heading item, which inserts a new heading instead", () => { + mount([ + { + id: "t", + type: "heading", + props: { level: 1, isToggleable: true }, + }, + ]); + editor.setTextCursorPosition("t", "end"); + + getDefaultSlashMenuItems(editor) + .find((item) => item.key === "heading_2")! + .onItemClick(); + + const [toggleHeading, inserted] = editor.document; + expect(toggleHeading.id).toBe("t"); + expect(toggleHeading.props).toMatchObject({ level: 1, isToggleable: true }); + expect(inserted.type).toBe("heading"); + expect(inserted.props).toMatchObject({ level: 2, isToggleable: false }); + expect(editor.getTextCursorPosition().block.id).toBe(inserted.id); + }); +}); diff --git a/tests/src/end-to-end/toggleblocks/toggleblocks.test.tsx b/tests/src/end-to-end/toggleblocks/toggleblocks.test.tsx new file mode 100644 index 0000000000..79531ae543 --- /dev/null +++ b/tests/src/end-to-end/toggleblocks/toggleblocks.test.tsx @@ -0,0 +1,210 @@ +import { + type BlockNoteEditor, + BlockNoteSchema, + type PartialBlock, +} from "@blocknote/core"; +import "@blocknote/core/fonts/inter.css"; +import { BlockNoteView } from "@blocknote/mantine"; +import "@blocknote/mantine/style.css"; +import { useCreateBlockNote } from "@blocknote/react"; +import { + multiColumnDropCursor, + withMultiColumn, +} from "@blocknote/xl-multi-column"; +import { beforeEach, describe, expect, test } from "vite-plus/test"; +import { render } from "vitest-browser-react"; +import { + DRAG_HANDLE_ADD_SELECTOR, + EDITOR_SELECTOR, +} from "../../utils/const.js"; +import { browserName, page, userEvent } from "../../utils/context.js"; +import { waitForSelector } from "../../utils/editor.js"; +import { + dragAndDropBlock, + getRect, + mouseSequence, + moveMouseOverElement, +} from "../../utils/mouse.js"; + +// UI behaviour of the built-in toggle blocks (toggle heading and toggle list +// item) that needs the full editor view: drag and drop, the side menu and the +// placeholder. Keyboard and open-state behaviour is covered by +// `packages/core/src/blocks/ToggleWrapper/toggleBlocks.browser.test.ts`. +// Tests for bugs that are still open use `test.fails`: they state the +// expected behaviour, so they start failing - and must be switched to `test` +// - once the bug is fixed. + +const schema = withMultiColumn(BlockNoteSchema.create()); + +let editor: BlockNoteEditor< + typeof schema.blockSchema, + typeof schema.inlineContentSchema, + typeof schema.styleSchema +>; + +function ToggleApp(props: { content: PartialBlock[]; width?: number }) { + editor = useCreateBlockNote({ + schema, + dropCursor: multiColumnDropCursor, + initialContent: props.content, + }); + + return ( +
+ +
+ ); +} + +const kinds = [ + { + name: "toggle heading", + toggle: (children: PartialBlock[] = []): PartialBlock => ({ + id: "t", + type: "heading", + props: { level: 2, isToggleable: true }, + content: "Toggle", + children, + }), + }, + { + name: "toggle list item", + toggle: (children: PartialBlock[] = []): PartialBlock => ({ + id: "t", + type: "toggleListItem", + content: "Toggle", + children, + }), + }, +]; + +beforeEach(() => { + // The open state of a toggle is kept in `localStorage`, keyed by block id. + localStorage.clear(); +}); + +async function openToggle() { + const block = document.querySelector(`.bn-block[data-id="t"]`)!; + const button = [...block.querySelectorAll(".bn-toggle-button")].find( + (element) => element.closest(".bn-block") === block, + ); + if (!button) { + throw new Error("The toggle has no chevron"); + } + await userEvent.click(button); +} + +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-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. + // This did not reproduce here (on `main` either), in any browser. The test + // keeps the behaviour the issue asks for. + test("keeps the side menu of a block in a column usable (BLO-1030)", async () => { + await render( + , + ); + await waitForSelector(EDITOR_SELECTOR); + await openToggle(); + + await moveMouseOverElement(page.getByText("Left").element()); + const add = getRect(await waitForSelector(DRAG_HANDLE_ADD_SELECTOR)); + await mouseSequence([ + { + type: "move", + x: add.x + add.width / 2, + y: add.y + add.height / 2, + steps: 10, + }, + ]); + await userEvent.click(await waitForSelector(DRAG_HANDLE_ADD_SELECTOR)); + + const leftColumn = editor.getParentBlock("left")!; + expect(leftColumn.children).toHaveLength(2); + expect(leftColumn.children[0].id).toBe("left"); + }); + + // BLO-967: on a narrow screen, a placeholder that wraps must take up space, + // so it doesn't overlap the block below it. + test("gives a wrapping placeholder in its body its own space (BLO-967)", async () => { + await render( + , + ); + await waitForSelector(EDITOR_SELECTOR); + await openToggle(); + + // The default placeholder shows in the empty block that has the caret. + editor.setTextCursorPosition("empty"); + editor.focus(); + + const content = (id: string) => + getRect(`.bn-block[data-id="${id}"] > .bn-block-content`); + const oneLine = content("filled").height; + expect(content("empty").height).toBeGreaterThan(oneLine * 1.5); + expect(content("below").top).toBeGreaterThanOrEqual( + content("empty").bottom, + ); + }); +}); From cffcf6d02ca7f9e26cc2127a35020fa05e80bd89 Mon Sep 17 00:00:00 2001 From: yousefed Date: Tue, 29 Sep 2026 21:21:15 +0200 Subject: [PATCH 2/8] refactor(core): draw the built-in toggles with renderFrame The toggle heading and the toggle list item now draw their chevron and "Add block" button with `renderFrame` (`createToggleFrame`), instead of wrapping their content with `createToggleWrapper`. `render` draws only the heading or paragraph. - The toggle logic goes from about 200 to about 100 lines: the frame's `update` hook replaces an editor-wide `onChange` listener per toggle, and `ignoreMutation`, `destroy` and the listener clean-up go away. - A heading that is not toggleable gets no frame. - ArrowDown now moves the caret out of an open, empty toggle (BLO-956): the "Add block" button no longer sits inside the content element. - The frame keeps the `bn-toggle-wrapper` class and `data-show-children`, so existing CSS and the internal HTML export still find it. A frame moves `.bn-block-content` one level down, and `Block.css` selects it as a direct child of `.bn-block` in many places. Heading sizes and the block colours that also apply to a block's children therefore get a second selector for the toggle frame. The chevron rotation now applies only to a toggle's own chevron, so a closed toggle nested in an open one keeps its chevron. `createToggleWrapper` and the React `ToggleWrapper` stay, because custom blocks use them. --- packages/core/src/blocks/Heading/block.ts | 22 ++- .../blocks/ListItem/ToggleListItem/block.ts | 12 +- .../blocks/ToggleWrapper/createToggleFrame.ts | 96 ++++++++++ .../toggleBlocks.browser.test.ts | 2 +- packages/core/src/blocks/index.ts | 1 + packages/core/src/editor/Block.css | 180 ++++++++++++++++-- .../blocknoteHTML/heading/toggleable.html | 52 ++--- .../blocknoteHTML/lists/basic.html | 28 +-- .../blocknoteHTML/lists/nested.html | 28 +-- .../lists/toggleWithChildren.html | 106 +++++------ .../core/schema/__snapshots__/blocks.json | 2 + 11 files changed, 385 insertions(+), 144 deletions(-) create mode 100644 packages/core/src/blocks/ToggleWrapper/createToggleFrame.ts diff --git a/packages/core/src/blocks/Heading/block.ts b/packages/core/src/blocks/Heading/block.ts index 6b14204cc8..ebcedaa585 100644 --- a/packages/core/src/blocks/Heading/block.ts +++ b/packages/core/src/blocks/Heading/block.ts @@ -7,7 +7,7 @@ import { parseDefaultProps, } from "../defaultProps.js"; import { getDetailsContent } from "../getDetailsContent.js"; -import { createToggleWrapper } from "../ToggleWrapper/createToggleWrapper.js"; +import { createToggleFrame } from "../ToggleWrapper/createToggleFrame.js"; const HEADING_LEVELS = [1, 2, 3, 4, 5, 6] as const; @@ -126,19 +126,25 @@ export const createHeadingBlockSpec = createBlockSpec( } : {}), runsBefore: ["toggleListItem"], - render(block, editor) { + render(block) { const dom = document.createElement(`h${block.props.level}`); - - if (allowToggleHeadings) { - const toggleWrapper = createToggleWrapper(block, editor, dom); - return { ...toggleWrapper, contentDOM: dom }; - } - return { dom, contentDOM: dom, }; }, + renderFrame(block, editor) { + if (!allowToggleHeadings || !block.props.isToggleable) { + return undefined; + } + const frame = createToggleFrame(block, editor); + return { + ...frame, + // A heading that is no longer toggleable gets no frame. + update: (updated) => + !!updated.props.isToggleable && frame.update(updated), + }; + }, toExternalHTML(block) { const dom = document.createElement(`h${block.props.level}`); addDefaultPropsExternalHTML(block.props, dom); diff --git a/packages/core/src/blocks/ListItem/ToggleListItem/block.ts b/packages/core/src/blocks/ListItem/ToggleListItem/block.ts index 54a7a39dfe..9d2b0b04ed 100644 --- a/packages/core/src/blocks/ListItem/ToggleListItem/block.ts +++ b/packages/core/src/blocks/ListItem/ToggleListItem/block.ts @@ -6,7 +6,7 @@ import { parseDefaultProps, } from "../../defaultProps.js"; import { getDetailsContent } from "../../getDetailsContent.js"; -import { createToggleWrapper } from "../../ToggleWrapper/createToggleWrapper.js"; +import { createToggleFrame } from "../../ToggleWrapper/createToggleFrame.js"; import { handleEnter } from "../../utils/listItemEnterHandler.js"; export type ToggleListItemBlockConfig = ReturnType< @@ -71,15 +71,11 @@ export const createToggleListItemBlockSpec = createBlockSpec( ); }, runsBefore: ["bulletListItem"], - render(block, editor) { + render() { const paragraphEl = document.createElement("p"); - const toggleWrapper = createToggleWrapper( - block as any, - editor, - paragraphEl, - ); - return { ...toggleWrapper, contentDOM: paragraphEl }; + return { dom: paragraphEl, contentDOM: paragraphEl }; }, + renderFrame: createToggleFrame, toExternalHTML(block) { const li = document.createElement("li"); const details = document.createElement("details"); diff --git a/packages/core/src/blocks/ToggleWrapper/createToggleFrame.ts b/packages/core/src/blocks/ToggleWrapper/createToggleFrame.ts new file mode 100644 index 0000000000..1a653d5405 --- /dev/null +++ b/packages/core/src/blocks/ToggleWrapper/createToggleFrame.ts @@ -0,0 +1,96 @@ +import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import type { Block } from "../defaultBlocks.js"; +import { defaultToggledState } from "./createToggleWrapper.js"; + +// https://fonts.google.com/icons?selected=Material+Symbols+Rounded:chevron_right:FILL@0;wght@700;GRAD@0;opsz@24&icon.query=chevron&icon.style=Rounded&icon.size=24&icon.color=%23e8eaed +const chevronIcon = + ''; + +/** + * The frame of a toggle block, for `renderFrame`: a chevron that shows or + * hides the block's children, and an "Add block" button while the toggle is + * open and has no children. BlockNote puts the block's content and its + * children in the slot; hiding the children is a CSS rule on the frame. + * + * Whether a toggle is open is the reader's view state, kept in + * `toggledState` (per browser by default), not in the document. + */ +export function createToggleFrame( + block: Block, + editor: BlockNoteEditor, + toggledState = defaultToggledState, +) { + const dom = document.createElement("div"); + dom.className = "bn-toggle-wrapper bn-toggle-frame"; + + // Chrome outside the slot handles its own events. Cancelling `mousedown` + // keeps a click from moving the caret. + const toggleButton = document.createElement("button"); + toggleButton.className = "bn-toggle-button"; + toggleButton.type = "button"; + toggleButton.innerHTML = chevronIcon; + toggleButton.addEventListener("mousedown", (event) => event.preventDefault()); + + const slot = document.createElement("div"); + slot.className = "bn-toggle-slot"; + + const addBlockButton = document.createElement("button"); + addBlockButton.className = "bn-toggle-add-block-button"; + addBlockButton.type = "button"; + addBlockButton.textContent = editor.dictionary.toggle_blocks.add_block_button; + addBlockButton.addEventListener("mousedown", (event) => + event.preventDefault(), + ); + addBlockButton.addEventListener("click", () => { + editor.transact(() => { + // A single empty block of the default type. + const updated = editor.updateBlock(block.id, { children: [{}] }); + editor.setTextCursorPosition(updated.children[0], "end"); + editor.focus(); + }); + }); + + dom.append(toggleButton, slot); + + let childCount = block.children.length; + + function show(open: boolean) { + dom.dataset.showChildren = String(open); + const showAddBlock = open && childCount === 0 && editor.isEditable; + if (showAddBlock && !addBlockButton.isConnected) { + dom.append(addBlockButton); + } else if (!showAddBlock) { + addBlockButton.remove(); + } + } + + toggleButton.addEventListener("click", () => { + const open = dom.dataset.showChildren !== "true"; + toggledState.set(block, open); + show(open); + }); + + show(toggledState.get(block)); + + return { + 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. + update(updated: Block) { + const newChildCount = updated.children.length; + let open = dom.dataset.showChildren === "true"; + if (newChildCount > childCount) { + open = true; + } else if (newChildCount === 0 && childCount > 0) { + open = false; + } + if (open !== (dom.dataset.showChildren === "true")) { + toggledState.set(updated, open); + } + childCount = newChildCount; + show(open); + return true; + }, + }; +} diff --git a/packages/core/src/blocks/ToggleWrapper/toggleBlocks.browser.test.ts b/packages/core/src/blocks/ToggleWrapper/toggleBlocks.browser.test.ts index cfa4e57fea..bd59514749 100644 --- a/packages/core/src/blocks/ToggleWrapper/toggleBlocks.browser.test.ts +++ b/packages/core/src/blocks/ToggleWrapper/toggleBlocks.browser.test.ts @@ -342,7 +342,7 @@ describe.each(kinds)("$name", ({ toggle, newBlockTypeAfterClosedToggle }) => { // BLO-956 (comment): in an open, empty toggle, the caret could not move // down out of the title. - it.fails("ArrowDown moves the caret to the next block (BLO-956)", async () => { + it("ArrowDown moves the caret to the next block (BLO-956)", async () => { mount([ toggle("t", "Title"), { id: "after", type: "paragraph", content: "After" }, diff --git a/packages/core/src/blocks/index.ts b/packages/core/src/blocks/index.ts index 76fc76d8a7..c8684b316f 100644 --- a/packages/core/src/blocks/index.ts +++ b/packages/core/src/blocks/index.ts @@ -20,6 +20,7 @@ export * from "./Code/helpers/parse/parsePreCode.js"; export * from "./Code/helpers/render/createCodeBlock.js"; export * from "./Code/helpers/toExternalHTML/createPreCode.js"; export * from "./ToggleWrapper/createToggleWrapper.js"; +export * from "./ToggleWrapper/createToggleFrame.js"; export * from "./PageBreak/getPageBreakSlashMenuItems.js"; export * from "./BlockNoteSchema.js"; diff --git a/packages/core/src/editor/Block.css b/packages/core/src/editor/Block.css index ef2867121d..bd5fdfcec1 100644 --- a/packages/core/src/editor/Block.css +++ b/packages/core/src/editor/Block.css @@ -189,7 +189,12 @@ NESTED BLOCKS --prev-level: 0.8em; } -.bn-block-outer[data-prev-type="heading"] > .bn-block > .bn-block-content { +.bn-block-outer[data-prev-type="heading"] > .bn-block > .bn-block-content, +.bn-block-outer[data-prev-type="heading"] + > .bn-block + > .bn-toggle-frame + > .bn-toggle-slot + > .bn-block-content { font-size: var(--prev-level); font-weight: bold; } @@ -197,6 +202,12 @@ NESTED BLOCKS .bn-block-outer:not([data-prev-type]) > .bn-block > .bn-block-content[data-content-type="heading"], +/* A toggle heading, inside its toggle frame. */ +.bn-block-outer:not([data-prev-type]) + > .bn-block + > .bn-toggle-frame + > .bn-toggle-slot + > .bn-block-content[data-content-type="heading"], .bn-block-outer:not([data-prev-type]) > .bn-block > div[data-type="modification"] @@ -336,10 +347,47 @@ NESTED BLOCKS height: 18px; } -.bn-toggle-wrapper[data-show-children="true"] .bn-toggle-button { +.bn-toggle-wrapper[data-show-children="true"] > .bn-toggle-button { transform: rotate(90deg); } +/* The frame of the built-in toggle blocks (see `createToggleFrame`): the + chevron, then the block's content, and the children below, across both + columns. The slot is `display: contents`, so the content and the child + group BlockNote mounts in it are items of this grid. */ +.bn-toggle-frame { + display: grid; + grid-template-columns: auto 1fr; + align-items: center; +} + +.bn-toggle-slot { + display: contents; +} + +.bn-toggle-slot > .bn-block-group, +.bn-toggle-frame > .bn-toggle-add-block-button { + grid-column: 1 / -1; +} + +.bn-toggle-frame[data-show-children="false"] + > .bn-toggle-slot + > .bn-block-group { + display: none; +} + +/* A heading's top spacing goes on the frame, so the chevron stays centred on + the heading text. */ +.bn-toggle-frame:has( + > .bn-toggle-slot > .bn-block-content[data-content-type="heading"] +) { + padding-top: 18px; +} + +.bn-toggle-slot > .bn-block-content[data-content-type="heading"] { + padding-top: 0; +} + .bn-toggle-add-block-button { font-size: 16px; color: var(--bn-colors-side-menu); @@ -869,112 +917,204 @@ NESTED BLOCKS } /* TEXT COLORS */ +/* A block's color also applies to its children. A toggle block's content sits + inside its frame (see `createToggleFrame`), hence the second selector. */ [data-style-type="textColor"][data-value="gray"], [data-text-color="gray"], -.bn-block:has(> .bn-block-content[data-text-color="gray"]) { +.bn-block:has(> .bn-block-content[data-text-color="gray"]), +.bn-block:has( + > .bn-toggle-frame + > .bn-toggle-slot + > .bn-block-content[data-text-color="gray"] +) { color: #9b9a97; } [data-style-type="textColor"][data-value="brown"], [data-text-color="brown"], -.bn-block:has(> .bn-block-content[data-text-color="brown"]) { +.bn-block:has(> .bn-block-content[data-text-color="brown"]), +.bn-block:has( + > .bn-toggle-frame + > .bn-toggle-slot + > .bn-block-content[data-text-color="brown"] +) { color: #64473a; } [data-style-type="textColor"][data-value="red"], [data-text-color="red"], -.bn-block:has(> .bn-block-content[data-text-color="red"]) { +.bn-block:has(> .bn-block-content[data-text-color="red"]), +.bn-block:has( + > .bn-toggle-frame + > .bn-toggle-slot + > .bn-block-content[data-text-color="red"] +) { color: #e03e3e; } [data-style-type="textColor"][data-value="orange"], [data-text-color="orange"], -.bn-block:has(> .bn-block-content[data-text-color="orange"]) { +.bn-block:has(> .bn-block-content[data-text-color="orange"]), +.bn-block:has( + > .bn-toggle-frame + > .bn-toggle-slot + > .bn-block-content[data-text-color="orange"] +) { color: #d9730d; } [data-style-type="textColor"][data-value="yellow"], [data-text-color="yellow"], -.bn-block:has(> .bn-block-content[data-text-color="yellow"]) { +.bn-block:has(> .bn-block-content[data-text-color="yellow"]), +.bn-block:has( + > .bn-toggle-frame + > .bn-toggle-slot + > .bn-block-content[data-text-color="yellow"] +) { color: #dfab01; } [data-style-type="textColor"][data-value="green"], [data-text-color="green"], -.bn-block:has(> .bn-block-content[data-text-color="green"]) { +.bn-block:has(> .bn-block-content[data-text-color="green"]), +.bn-block:has( + > .bn-toggle-frame + > .bn-toggle-slot + > .bn-block-content[data-text-color="green"] +) { color: #4d6461; } [data-style-type="textColor"][data-value="blue"], [data-text-color="blue"], -.bn-block:has(> .bn-block-content[data-text-color="blue"]) { +.bn-block:has(> .bn-block-content[data-text-color="blue"]), +.bn-block:has( + > .bn-toggle-frame + > .bn-toggle-slot + > .bn-block-content[data-text-color="blue"] +) { color: #0b6e99; } [data-style-type="textColor"][data-value="purple"], [data-text-color="purple"], -.bn-block:has(> .bn-block-content[data-text-color="purple"]) { +.bn-block:has(> .bn-block-content[data-text-color="purple"]), +.bn-block:has( + > .bn-toggle-frame + > .bn-toggle-slot + > .bn-block-content[data-text-color="purple"] +) { color: #6940a5; } [data-style-type="textColor"][data-value="pink"], [data-text-color="pink"], -.bn-block:has(> .bn-block-content[data-text-color="pink"]) { +.bn-block:has(> .bn-block-content[data-text-color="pink"]), +.bn-block:has( + > .bn-toggle-frame + > .bn-toggle-slot + > .bn-block-content[data-text-color="pink"] +) { color: #ad1a72; } /* BACKGROUND COLORS */ [data-style-type="backgroundColor"][data-value="gray"], [data-background-color="gray"], -.bn-block:has(> .bn-block-content[data-background-color="gray"]) { +.bn-block:has(> .bn-block-content[data-background-color="gray"]), +.bn-block:has( + > .bn-toggle-frame + > .bn-toggle-slot + > .bn-block-content[data-background-color="gray"] +) { background-color: #ebeced; } [data-style-type="backgroundColor"][data-value="brown"], [data-background-color="brown"], -.bn-block:has(> .bn-block-content[data-background-color="brown"]) { +.bn-block:has(> .bn-block-content[data-background-color="brown"]), +.bn-block:has( + > .bn-toggle-frame + > .bn-toggle-slot + > .bn-block-content[data-background-color="brown"] +) { background-color: #e9e5e3; } [data-style-type="backgroundColor"][data-value="red"], [data-background-color="red"], -.bn-block:has(> .bn-block-content[data-background-color="red"]) { +.bn-block:has(> .bn-block-content[data-background-color="red"]), +.bn-block:has( + > .bn-toggle-frame + > .bn-toggle-slot + > .bn-block-content[data-background-color="red"] +) { background-color: #fbe4e4; } [data-style-type="backgroundColor"][data-value="orange"], [data-background-color="orange"], -.bn-block:has(> .bn-block-content[data-background-color="orange"]) { +.bn-block:has(> .bn-block-content[data-background-color="orange"]), +.bn-block:has( + > .bn-toggle-frame + > .bn-toggle-slot + > .bn-block-content[data-background-color="orange"] +) { background-color: #f6e9d9; } [data-style-type="backgroundColor"][data-value="yellow"], [data-background-color="yellow"], -.bn-block:has(> .bn-block-content[data-background-color="yellow"]) { +.bn-block:has(> .bn-block-content[data-background-color="yellow"]), +.bn-block:has( + > .bn-toggle-frame + > .bn-toggle-slot + > .bn-block-content[data-background-color="yellow"] +) { background-color: #fbf3db; } [data-style-type="backgroundColor"][data-value="green"], [data-background-color="green"], -.bn-block:has(> .bn-block-content[data-background-color="green"]) { +.bn-block:has(> .bn-block-content[data-background-color="green"]), +.bn-block:has( + > .bn-toggle-frame + > .bn-toggle-slot + > .bn-block-content[data-background-color="green"] +) { background-color: #ddedea; } [data-style-type="backgroundColor"][data-value="blue"], [data-background-color="blue"], -.bn-block:has(> .bn-block-content[data-background-color="blue"]) { +.bn-block:has(> .bn-block-content[data-background-color="blue"]), +.bn-block:has( + > .bn-toggle-frame + > .bn-toggle-slot + > .bn-block-content[data-background-color="blue"] +) { background-color: #ddebf1; } [data-style-type="backgroundColor"][data-value="purple"], [data-background-color="purple"], -.bn-block:has(> .bn-block-content[data-background-color="purple"]) { +.bn-block:has(> .bn-block-content[data-background-color="purple"]), +.bn-block:has( + > .bn-toggle-frame + > .bn-toggle-slot + > .bn-block-content[data-background-color="purple"] +) { background-color: #eae4f2; } [data-style-type="backgroundColor"][data-value="pink"], [data-background-color="pink"], -.bn-block:has(> .bn-block-content[data-background-color="pink"]) { +.bn-block:has(> .bn-block-content[data-background-color="pink"]), +.bn-block:has( + > .bn-toggle-frame + > .bn-toggle-slot + > .bn-block-content[data-background-color="pink"] +) { background-color: #f4dfeb; } diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/heading/toggleable.html b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/heading/toggleable.html index 2982ce3673..bceb80b782 100644 --- a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/heading/toggleable.html +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/heading/toggleable.html @@ -1,34 +1,34 @@
-
-
-
- +
+ +
+

Toggle Heading

-
-
-
-
-
-
-

Child content

+
+
+
+
+

Child content

+
+
diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/lists/basic.html b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/lists/basic.html index c58d0153ab..d7e76a87e2 100644 --- a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/lists/basic.html +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/lists/basic.html @@ -61,20 +61,20 @@
-
-
-
- +
+ +
+

Toggle List Item 1

diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/lists/nested.html b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/lists/nested.html index 389ff4c34b..eebb73b974 100644 --- a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/lists/nested.html +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/lists/nested.html @@ -58,20 +58,20 @@
-
-
-
- +
+ +
+

Toggle List Item 1

diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/lists/toggleWithChildren.html b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/lists/toggleWithChildren.html index 018c41520e..e7aaf3d378 100644 --- a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/lists/toggleWithChildren.html +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/lists/toggleWithChildren.html @@ -1,36 +1,36 @@
-
-
-
- +
+ +
+

Toggle List Item

-
-
-
-
-
-
-

Toggle Child 1

+
+
+
+
+

Toggle Child 1

+
+
-
-
-
-
-
-

Toggle Child 2

+
+
+
+

Toggle Child 2

+
+
@@ -39,34 +39,34 @@
-
-
-
- +
+ +
+

Toggle Heading

-
-
-
-
-
-
-

Heading Child 1

+
+
+
+
+

Heading Child 1

+
+
diff --git a/tests/src/unit/core/schema/__snapshots__/blocks.json b/tests/src/unit/core/schema/__snapshots__/blocks.json index 99261ea107..9659c392cb 100644 --- a/tests/src/unit/core/schema/__snapshots__/blocks.json +++ b/tests/src/unit/core/schema/__snapshots__/blocks.json @@ -314,6 +314,7 @@ "parse": [Function], "parseContent": [Function], "render": [Function], + "renderFrame": [Function], "runsBefore": [ "toggleListItem", ], @@ -614,6 +615,7 @@ "parse": [Function], "parseContent": [Function], "render": [Function], + "renderFrame": [Function], "runsBefore": [ "bulletListItem", ], From 80f635a315ca171f8d2fd31f52e0da988300aa6b Mon Sep 17 00:00:00 2001 From: yousefed Date: Wed, 30 Sep 2026 10:02:22 +0200 Subject: [PATCH 3/8] feat(core)!: split keyboard behaviour from container structure Blocks declare how the keyboard treats them with a `keyboard` option on their implementation (Enter, Shift-Enter, reset, empty-child Enter, outdenting), instead of deriving it from a `children` config. A container is marked with `container: true`; `children` only restricts its child types or count, and defaults to `{ allow: "blocks" }` for every block. - List items and toggles declare their Enter behaviour as settings; the per-block list Enter handlers and the unused ListItemKeyboardShortcuts file are removed. - Enter at the start of a non-empty block inserts an empty block above, so the block keeps its id, props and children (#550). - Toggles: Enter on an open toggle goes into its children, a closed toggle keeps them; the chevron has an accessible name and `aria-expanded`, which also drives the CSS (replaces `data-show-children`). - A toggle heading turned into a regular heading by the shortcut, the markdown rule or the slash menu stops being a toggle. - Backspace after a block whose own content is not rich text (a code block) merges into its last child block. - Removes `createToggleWrapper`, the React `ToggleWrapper` and the toggleable-blocks example; `meta.hardBreakShortcut` is deprecated. --- .../custom-schemas/container-blocks.mdx | 33 +- .../features/custom-schemas/custom-blocks.mdx | 42 ++- .../custom-schemas/source-with-preview.mdx | 12 +- .../06-toggleable-blocks/.bnexample.json | 6 - .../06-toggleable-blocks/README.md | 9 - .../06-toggleable-blocks/index.html | 14 - .../06-toggleable-blocks/main.tsx | 11 - .../06-toggleable-blocks/package.json | 30 -- .../06-toggleable-blocks/src/App.tsx | 55 ---- .../06-toggleable-blocks/src/Toggle.tsx | 25 -- .../06-toggleable-blocks/src/vite-env.d.ts | 1 - .../06-toggleable-blocks/tsconfig.json | 32 -- .../06-toggleable-blocks/vite-env.d.ts | 1 - .../06-toggleable-blocks/vite.config.ts | 35 -- .../09-container-block/README.md | 2 +- .../09-container-block/src/Panel.tsx | 2 +- .../11-source-with-preview/src/App.tsx | 9 +- .../13-callout-block/README.md | 11 +- .../13-callout-block/src/App.tsx | 4 +- .../13-callout-block/src/Callout.tsx | 18 +- .../insertBlocks/insertPlacement.test.ts | 4 +- .../commands/mergeBlocks/mergeBlocks.ts | 47 ++- .../commands/nestBlock/nestBlock.ts | 21 +- .../commands/updateBlock/updateBlock.ts | 7 +- .../containers/containers.fixture.ts | 23 +- .../containers/containers.test.ts | 28 ++ .../containers/fixContainer.ts | 6 +- .../containers/plainBlocks.test.ts | 21 +- .../containers/titledBlocks.test.ts | 33 +- .../exporters/html/internalHTMLSerializer.ts | 8 +- .../core/src/api/getBlockInfoFromPos.test.ts | 3 +- packages/core/src/api/getBlockInfoFromPos.ts | 6 - packages/core/src/blocks/Heading/block.ts | 44 ++- .../blocks/ListItem/BulletListItem/block.ts | 10 +- .../blocks/ListItem/CheckListItem/block.ts | 10 +- .../ListItem/ListItemKeyboardShortcuts.ts | 63 ---- .../blocks/ListItem/NumberedListItem/block.ts | 10 +- .../blocks/ListItem/ToggleListItem/block.ts | 21 +- .../blocks/ToggleWrapper/createToggleFrame.ts | 51 ++- .../ToggleWrapper/createToggleWrapper.ts | 197 ------------ .../toggleBlocks.browser.test.ts | 251 +++++++++++++-- packages/core/src/blocks/index.ts | 1 - .../src/blocks/utils/listItemEnterHandler.ts | 43 --- .../SourceBlockWithPreview.ts | 7 +- .../getDefaultSlashMenuItems.ts | 19 +- .../KeyboardShortcutsExtension.test.ts | 303 +++++++++++++++--- .../KeyboardShortcutsExtension.ts | 229 ++++++++++--- .../blockIdentity.browser.test.ts | 108 +++++++ packages/core/src/i18n/locales/ar.ts | 1 + packages/core/src/i18n/locales/de.ts | 1 + packages/core/src/i18n/locales/en.ts | 1 + packages/core/src/i18n/locales/es.ts | 1 + packages/core/src/i18n/locales/fa.ts | 1 + packages/core/src/i18n/locales/fr.ts | 1 + packages/core/src/i18n/locales/he.ts | 1 + packages/core/src/i18n/locales/hr.ts | 1 + packages/core/src/i18n/locales/is.ts | 1 + packages/core/src/i18n/locales/it.ts | 1 + packages/core/src/i18n/locales/ja.ts | 1 + packages/core/src/i18n/locales/ko.ts | 1 + packages/core/src/i18n/locales/nl.ts | 1 + packages/core/src/i18n/locales/no.ts | 1 + packages/core/src/i18n/locales/pl.ts | 1 + packages/core/src/i18n/locales/pt.ts | 1 + packages/core/src/i18n/locales/ru.ts | 1 + packages/core/src/i18n/locales/sk.ts | 1 + packages/core/src/i18n/locales/uk.ts | 1 + packages/core/src/i18n/locales/uz.ts | 1 + packages/core/src/i18n/locales/vi.ts | 1 + packages/core/src/i18n/locales/zh-tw.ts | 1 + packages/core/src/i18n/locales/zh.ts | 1 + .../core/src/schema/blocks/children.test.ts | 112 +++---- packages/core/src/schema/blocks/children.ts | 30 +- .../schema/blocks/createSpec.browser.test.ts | 6 +- .../core/src/schema/blocks/createSpec.test.ts | 12 +- packages/core/src/schema/blocks/createSpec.ts | 7 +- packages/core/src/schema/blocks/internal.ts | 16 +- packages/core/src/schema/blocks/keyboard.ts | 116 +++++++ .../src/schema/blocks/renderFrame.test.ts | 3 - packages/core/src/schema/blocks/types.ts | 62 ++-- .../src/schema/blocks/validateChildren.ts | 41 ++- packages/core/src/schema/index.ts | 1 + .../src/block/createReactDiagramBlockSpec.tsx | 5 +- .../block/createReactMathBlockSpec.test.tsx | 2 +- .../src/block/createReactMathBlockSpec.tsx | 1 - .../block/SourceBlockWithPreview.tsx | 4 +- .../blocks/ToggleWrapper/ToggleWrapper.tsx | 162 ---------- packages/react/src/index.ts | 1 - .../ReactBlockSpec.container.browser.test.tsx | 2 +- .../ReactBlockSpec.frame.browser.test.tsx | 1 - packages/react/src/schema/ReactBlockSpec.tsx | 5 +- .../src/blocks/Columns/index.ts | 3 +- playground/src/examples.gen.tsx | 22 +- pnpm-lock.yaml | 43 --- .../blocknoteHTML/heading/toggleable.html | 10 +- .../blocknoteHTML/lists/basic.html | 10 +- .../blocknoteHTML/lists/nested.html | 10 +- .../lists/toggleWithChildren.html | 20 +- .../core/schema/__snapshots__/blocks.json | 24 +- tests/src/unit/core/testSchema.ts | 4 +- tests/src/unit/react/reactFrame.test.tsx | 3 +- .../src/unit/react/useNodeViewBlock.test.tsx | 2 +- 102 files changed, 1517 insertions(+), 1172 deletions(-) delete mode 100644 examples/06-custom-schema/06-toggleable-blocks/.bnexample.json delete mode 100644 examples/06-custom-schema/06-toggleable-blocks/README.md delete mode 100644 examples/06-custom-schema/06-toggleable-blocks/index.html delete mode 100644 examples/06-custom-schema/06-toggleable-blocks/main.tsx delete mode 100644 examples/06-custom-schema/06-toggleable-blocks/package.json delete mode 100644 examples/06-custom-schema/06-toggleable-blocks/src/App.tsx delete mode 100644 examples/06-custom-schema/06-toggleable-blocks/src/Toggle.tsx delete mode 100644 examples/06-custom-schema/06-toggleable-blocks/src/vite-env.d.ts delete mode 100644 examples/06-custom-schema/06-toggleable-blocks/tsconfig.json delete mode 100644 examples/06-custom-schema/06-toggleable-blocks/vite-env.d.ts delete mode 100644 examples/06-custom-schema/06-toggleable-blocks/vite.config.ts delete mode 100644 packages/core/src/blocks/ListItem/ListItemKeyboardShortcuts.ts delete mode 100644 packages/core/src/blocks/ToggleWrapper/createToggleWrapper.ts delete mode 100644 packages/core/src/blocks/utils/listItemEnterHandler.ts create mode 100644 packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/blockIdentity.browser.test.ts create mode 100644 packages/core/src/schema/blocks/keyboard.ts delete mode 100644 packages/react/src/blocks/ToggleWrapper/ToggleWrapper.tsx diff --git a/docs/content/docs/features/custom-schemas/container-blocks.mdx b/docs/content/docs/features/custom-schemas/container-blocks.mdx index d9b56d1216..aa4bbc554c 100644 --- a/docs/content/docs/features/custom-schemas/container-blocks.mdx +++ b/docs/content/docs/features/custom-schemas/container-blocks.mdx @@ -11,7 +11,7 @@ You can create custom blocks that contain other blocks, such as panels, callouts ## Creating a Container Block -Use the `createReactBlockSpec` function to create a container block, just like a [Custom Block](/docs/features/custom-schemas/custom-blocks). For the panel below, we set `content` to `"none"` and add the `children` option to let it contain other blocks: +Use the `createReactBlockSpec` function to create a container block, just like a [Custom Block](/docs/features/custom-schemas/custom-blocks). For the panel below, we set `content` to `"none"` and `container` to `true`, so the panel's child blocks go inside it: ```tsx import { createReactBlockSpec } from "@blocknote/react"; @@ -21,7 +21,7 @@ export const createPanel = createReactBlockSpec( type: "panel", propSchema: {}, content: "none", - children: { allow: "blocks" }, + container: true, }, { render: (props) => ( @@ -35,15 +35,15 @@ export const createPanel = createReactBlockSpec( The block config defines the content and child blocks your container can hold: -`content:` Works the same as for [Custom Blocks](/docs/features/custom-schemas/custom-blocks#block-config-customblockconfig). When using the `children` option, choose `"none"`, `"inline"`, or `"plain"`. +`content:` Must be `"none"` for a container: its node holds nothing but its child blocks. For a block with its own text and child blocks, see [Combining Content and Child Blocks](#combining-content-and-child-blocks). -`children.allow:` Set to `"blocks"` to accept the editor's block types. You can also restrict a container to specific container types, as explained in [Restricting Children](#restricting-children). +`container:` Set to `true` to put the block's child blocks inside it. Without it, a block's child blocks are indented below it. A container accepts any block by default. You can also restrict it to specific container types, as explained in [Restricting Children](#restricting-children). `propSchema:` Defines the container's props, just like for other custom blocks. Use these to customize its appearance or behavior. ### Block Implementation -`render:` Your React component defines how the block should look. With `content: "none"`, attach `contentRef` where the child blocks should appear. With `content: "inline"` or `"plain"`, attach it to the block's own editable text. You can add icons, buttons, or other elements around it: +`render:` Your React component defines how the block should look. For a container, attach `contentRef` where the child blocks should appear. With `content: "inline"` or `"plain"`, attach it to the block's own editable text. You can add icons, buttons, or other elements around it: ```tsx render: (props) => ( @@ -109,7 +109,7 @@ A block can have both its own text and child blocks. Use this for a question fol -Set `content` to `"inline"` for rich text or `"plain"` for unstyled text, and add `children: { allow: "blocks" }`. Use `render` for the block's own text and `renderFrame` to style that content and its child blocks together: +Set `content` to `"inline"` for rich text or `"plain"` for unstyled text. Every block with content can have child blocks, so there's nothing to declare for them. Use `render` for the block's own text, `renderFrame` to style that content and its child blocks together, and `keyboard` to keep the child blocks inside the block: ```tsx import { createReactBlockSpec } from "@blocknote/react"; @@ -119,9 +119,13 @@ export const createCallout = createReactBlockSpec( type: "callout", propSchema: {}, content: "inline", - children: { allow: "blocks" }, }, { + keyboard: { + enter: "into-children", + childrenCanOutdent: false, + emptyChildEnter: "exit-at-end", + }, render: (props) => (
), @@ -146,9 +150,11 @@ The child blocks can still contain rich text, images, and other block types. `renderFrame:` An optional React component for styling the block and its children together, such as giving the callout a shared border or background. It receives `block`, `editor`, and `contentRef`, just like `render`. Attach `contentRef` where the block's content and children should appear. You can use the block's props to customize the frame, add interactive controls, or return `null` to show the block without a frame. -You can also use `renderFrame` without the `children` option to style a block and its indented children together. For a container with `content: "none"`, like the panel above, add the surrounding styling directly in `render`. +`keyboard:` Without it, child blocks behave like any indented blocks. Here, `enter: "into-children"` makes Enter in the title add a first child block, `childrenCanOutdent: false` keeps Shift-Tab from moving blocks out of the callout, and `emptyChildEnter: "exit-at-end"` makes Enter in an empty last block leave the callout. See [Custom Blocks](/docs/features/custom-schemas/custom-blocks) for all keyboard settings. + +For a container with `content: "none"`, like the panel above, add the surrounding styling directly in `render`. -Add `callout: createCallout()` to your schema, then use `content` for the title and `children` for the body: +Add `callout: createCallout()` to your schema, then use `content` for the title and `children` for the blocks inside it: ```typescript { @@ -160,7 +166,7 @@ Add `callout: createCallout()` to your schema, then use `content` for the title } ``` -Pressing Enter at the end of the title starts a paragraph in the body. Moving the callout moves its title and body together. +Pressing Enter at the end of the title adds a paragraph inside the callout, and Enter in an empty last paragraph leaves the callout. Moving the callout moves its title and child blocks together. To add blocks to an existing container, see [Inserting Blocks](/docs/reference/editor/manipulating-content#inserting-blocks). @@ -172,6 +178,7 @@ Use an array of container type names for `children.allow`, and `min` to set the ```typescript // Column layout config: +container: true, children: { allow: ["column"], min: 2 }, ``` @@ -179,16 +186,16 @@ On the column itself, set `placeable` to `"namedOnly"` so it can only be used in ```typescript // Column config: -children: { allow: "blocks" }, +container: true, placeable: "namedOnly", ``` -`children.allow:` Accepts `"blocks"` or an array of container type names. You cannot list regular block types such as `"paragraph"` individually. +`children.allow:` Accepts `"blocks"` (the default) or an array of container type names. You cannot list regular block types such as `"paragraph"` individually. `children.min:` The minimum number of children. Defaults to `1`. `placeable:` Set to `"namedOnly"` to restrict a container to parents that name it in `children.allow`. Defaults to `"anywhere"`. -These options apply to containers with `content: "none"`. Blocks with `content: "inline"` or `"plain"` use `children: { allow: "blocks" }` and can have no child blocks. +These options need `container: true`. Other blocks can always have child blocks of any type, so for them `children` can only be the default, `{ allow: "blocks" }`. For built-in column blocks, see [Multi-Column Layouts](/docs/foundations/document-structure#column-blocks). diff --git a/docs/content/docs/features/custom-schemas/custom-blocks.mdx b/docs/content/docs/features/custom-schemas/custom-blocks.mdx index 4146907f28..d446aa8afe 100644 --- a/docs/content/docs/features/custom-schemas/custom-blocks.mdx +++ b/docs/content/docs/features/custom-schemas/custom-blocks.mdx @@ -56,8 +56,9 @@ type BlockConfig = { type: string; content: "inline" | "plain" | "none"; readonly propSchema: PropSchema; + container?: true; // only with content: "none" children?: { - allow: "blocks" | string[]; + allow?: "blocks" | string[]; min?: number; }; placeable?: "anywhere" | "namedOnly"; @@ -78,12 +79,12 @@ type BlockConfig = { - _Custom blocks can also contain child blocks by declaring the `children` - option, with or without editable content of their own. See [Container - Blocks](/docs/features/custom-schemas/container-blocks)._ + _Every block can have child blocks, indented below it. A block without + content can also hold them inside itself with `container: true`. See + [Container Blocks](/docs/features/custom-schemas/container-blocks)._ -`children?:` Defines which child blocks the block can contain. `placeable?:` Controls where a container block can be used. See [Container Blocks](/docs/features/custom-schemas/container-blocks) for the supported configurations. +`container?:` Puts the block's child blocks inside it. `children?:` Restricts which child blocks a container can hold. `placeable?:` Controls where a container block can be used. See [Container Blocks](/docs/features/custom-schemas/container-blocks) for the supported configurations. `propSchema:` The `PropSchema` specifies the props that the block supports. Block props (properties) are data stored with your Block in the document, and can be used to customize its appearance or behavior. @@ -146,8 +147,17 @@ type ReactCustomBlockImplementation = { schema: Schema; }) => Fragment | undefined; runsBefore?: string[]; + keyboard?: KeyboardSettings | ((block: Block) => KeyboardSettings); + // KeyboardSettings: { + // enter?: "split" | "into-children" | "line-break"; + // shiftEnter?: "line-break" | "same-as-enter"; + // splitKeepsType?: boolean; + // resetsTo?: { type: string; props?: Record }; + // emptyEnterResets?: boolean; + // emptyChildEnter?: "outdent" | "exit-at-end" | "stay"; + // childrenCanOutdent?: boolean; + // } meta?: { - hardBreakShortcut?: "shift+enter" | "enter" | "none"; selectable?: boolean; fileBlockAccept?: string[]; code?: boolean; @@ -184,9 +194,25 @@ type ReactCustomBlockImplementation = { `runsBefore?:` If this block has parsing or extensions that need to be given priority over any other blocks, you can pass their `type`s in an array here. -`meta?:` An object for setting various generic properties of the block. +`keyboard?:` How the keyboard treats the block and its children. Give only the settings that differ from the defaults. To make settings depend on the block's props, give a function that gets the block and returns them instead. + +- `enter?:` What Enter does in the block's content. `"split"` (default) splits the block, moving the text after the caret into a new block below. `"into-children"` moves it into a new first child instead. `"line-break"` inserts a line break, and makes Shift-Enter do the same. For `content: "plain"` blocks (which can't hold hard break nodes), a line break is a literal newline (`"\n"`). + +- `shiftEnter?:` What Shift-Enter does in the block's content: `"line-break"` (default) or `"same-as-enter"`. + +- `splitKeepsType?:` Whether the block created by splitting this one with Enter has the same type, as in lists. Defaults to `false`. + +- `resetsTo?:` What the block turns into when it's reset, keeping its content and children. Backspace at the start of the block always resets it. A `type`, and `props` to merge into the block's props. Defaults to `{ type: "paragraph" }`. -- `hardBreakShortcut?:` Defines which keyboard shortcut should be used to insert a hard break into the block's inline content. Defaults to `"shift+enter"`. For `content: "plain"` blocks (which can't hold hard break nodes), the shortcut inserts a literal newline (`"\n"`) instead. +- `emptyEnterResets?:` Whether Enter in the empty block resets it too, as when an empty list item turns into a paragraph. Defaults to `false`. + +- `emptyChildEnter?:` What Enter does in an empty child of this block. `"outdent"` (default) outdents any empty child. `"exit-at-end"` (default for container blocks) moves an empty last child out to after the block, and adds a new child after any other empty child. `"stay"` always adds a new child after it. + +- `childrenCanOutdent?:` Whether the block's children can be outdented out of it with Shift-Tab, or by Enter or Backspace in an empty or nested child. Defaults to `true`, or `false` for container blocks, whose children can never be outdented. + +When settings meet, Enter at the start of non-empty content always inserts an empty block above it, and resetting an empty block comes before `enter: "into-children"`. + +`meta?:` An object for setting various generic properties of the block. - `selectable?:` Can be set to false in order to make the block non-selectable, both using the mouse and keyboard. This also helps with being able to select non-editable content within the block. Should only be set to false when `content` is `none` and defaults to true. diff --git a/docs/content/docs/features/custom-schemas/source-with-preview.mdx b/docs/content/docs/features/custom-schemas/source-with-preview.mdx index 03dd18c51e..343168c87d 100644 --- a/docs/content/docs/features/custom-schemas/source-with-preview.mdx +++ b/docs/content/docs/features/custom-schemas/source-with-preview.mdx @@ -62,7 +62,7 @@ return ( A few more props customize the states: `errorPreview` for the compact error state shown in place of the preview, `emptySourcePlaceholder` for when the source is empty (a string customizes the default placeholder's text, an element — e.g. the exported `PreviewPlaceholder` with your own icon — replaces it entirely), and `sourcePlaceholder` for the popup input's placeholder. See the `SourceWithPreviewProps` type for the full list. -**3. The spec's `meta`**, opting into the popup: +**3. The spec's `meta`**, opting into the popup, and its `keyboard`: ```tsx const createMyBlockSpec = createReactBlockSpec(createMyBlockConfig, { @@ -70,10 +70,12 @@ const createMyBlockSpec = createReactBlockSpec(createMyBlockConfig, { code: true, // Marks the block as rendering a preview with an editable source popup. hasPreview: true, - // What Enter does while the popup is open: "enter" inserts a newline - // (multiline sources, like diagrams), "shift+enter" closes the popup - // (single-line sources, like math). - hardBreakShortcut: "enter", + }, + // What Enter does while the popup is open: "line-break" inserts a newline + // (multiline sources, like diagrams). Without it, Enter closes the popup + // (single-line sources, like math). + keyboard: { + enter: "line-break", }, render: MyBlockPreview, }); diff --git a/examples/06-custom-schema/06-toggleable-blocks/.bnexample.json b/examples/06-custom-schema/06-toggleable-blocks/.bnexample.json deleted file mode 100644 index 6d4a02dd52..0000000000 --- a/examples/06-custom-schema/06-toggleable-blocks/.bnexample.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "playground": true, - "docs": true, - "author": "matthewlipski", - "tags": ["Basic"] -} diff --git a/examples/06-custom-schema/06-toggleable-blocks/README.md b/examples/06-custom-schema/06-toggleable-blocks/README.md deleted file mode 100644 index 4bbaaa70e1..0000000000 --- a/examples/06-custom-schema/06-toggleable-blocks/README.md +++ /dev/null @@ -1,9 +0,0 @@ -# Toggleable Custom Blocks - -This example shows how to create custom blocks with a toggle button to show/hide their children, like with the default toggle heading and list item blocks. This is done using the use the `ToggleWrapper` component from `@blocknote/react`. - -**Relevant Docs:** - -- [Custom Blocks](/docs/features/custom-schemas/custom-blocks) -- [Editor Setup](/docs/getting-started/editor-setup) -- [Default Schema](/docs/features/blocks) diff --git a/examples/06-custom-schema/06-toggleable-blocks/index.html b/examples/06-custom-schema/06-toggleable-blocks/index.html deleted file mode 100644 index 4ae468dbd8..0000000000 --- a/examples/06-custom-schema/06-toggleable-blocks/index.html +++ /dev/null @@ -1,14 +0,0 @@ - - - - - Toggleable Custom Blocks - - - -
- - - diff --git a/examples/06-custom-schema/06-toggleable-blocks/main.tsx b/examples/06-custom-schema/06-toggleable-blocks/main.tsx deleted file mode 100644 index 1260513388..0000000000 --- a/examples/06-custom-schema/06-toggleable-blocks/main.tsx +++ /dev/null @@ -1,11 +0,0 @@ -// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY -import React from "react"; -import { createRoot } from "react-dom/client"; -import App from "./src/App.jsx"; - -const root = createRoot(document.getElementById("root")!); -root.render( - - - , -); diff --git a/examples/06-custom-schema/06-toggleable-blocks/package.json b/examples/06-custom-schema/06-toggleable-blocks/package.json deleted file mode 100644 index caed51c711..0000000000 --- a/examples/06-custom-schema/06-toggleable-blocks/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "@blocknote/example-custom-schema-toggleable-blocks", - "description": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", - "type": "module", - "private": true, - "version": "0.12.4", - "scripts": { - "start": "vite", - "dev": "vite", - "build:prod": "tsc && vite build", - "preview": "vite preview" - }, - "dependencies": { - "@blocknote/ariakit": "latest", - "@blocknote/core": "latest", - "@blocknote/mantine": "latest", - "@blocknote/react": "latest", - "@blocknote/shadcn": "latest", - "@mantine/core": "^9.0.2", - "@mantine/hooks": "^9.0.2", - "react": "^19.2.3", - "react-dom": "^19.2.3" - }, - "devDependencies": { - "@types/react": "^19.2.3", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.1", - "vite": "^8.0.0" - } -} diff --git a/examples/06-custom-schema/06-toggleable-blocks/src/App.tsx b/examples/06-custom-schema/06-toggleable-blocks/src/App.tsx deleted file mode 100644 index 81877de7e4..0000000000 --- a/examples/06-custom-schema/06-toggleable-blocks/src/App.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { BlockNoteSchema, defaultBlockSpecs } from "@blocknote/core"; -import "@blocknote/core/fonts/inter.css"; -import { BlockNoteView } from "@blocknote/mantine"; -import "@blocknote/mantine/style.css"; -import { useCreateBlockNote } from "@blocknote/react"; - -import { ToggleBlock } from "./Toggle"; - -// Our schema with block specs, which contain the configs and implementations for -// blocks that we want our editor to use. -const schema = BlockNoteSchema.create({ - blockSpecs: { - // Adds all default blocks. - ...defaultBlockSpecs, - // Adds the Toggle block. - toggle: ToggleBlock(), - }, -}); - -export default function App() { - // Creates a new editor instance. - const editor = useCreateBlockNote({ - schema, - initialContent: [ - { - type: "paragraph", - content: "Welcome to this demo!", - }, - { - // We set a persistent ID so that the toggled state is preserved - // on reload. - id: "toggle", - type: "toggle", - content: "This is an example toggle", - children: [ - { - type: "paragraph", - content: "This is the first child of the toggle block.", - }, - { - type: "paragraph", - content: "This is the second child of the toggle block.", - }, - ], - }, - { - type: "paragraph", - content: "Click the '>' icon to show/hide its children", - }, - ], - }); - - // Renders the editor instance. - return ; -} diff --git a/examples/06-custom-schema/06-toggleable-blocks/src/Toggle.tsx b/examples/06-custom-schema/06-toggleable-blocks/src/Toggle.tsx deleted file mode 100644 index 244661f841..0000000000 --- a/examples/06-custom-schema/06-toggleable-blocks/src/Toggle.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { defaultProps } from "@blocknote/core"; -import { createReactBlockSpec, ToggleWrapper } from "@blocknote/react"; - -// The Toggle block that we want to add to our editor. -export const ToggleBlock = createReactBlockSpec( - { - type: "toggle", - propSchema: { - ...defaultProps, - }, - content: "inline", - }, - { - render: (props) => ( - // The `ToggleWrapper` component renders a button on the left which - // toggles the visibility of the block's children. It also adds a button - // to add child blocks if there are none. By default, it uses local - // storage to remember the toggled state based on the block ID, but you can pass a custom - // `toggledState` prop to use a different storage mechanism. - -

- - ), - }, -); diff --git a/examples/06-custom-schema/06-toggleable-blocks/src/vite-env.d.ts b/examples/06-custom-schema/06-toggleable-blocks/src/vite-env.d.ts deleted file mode 100644 index bc2d8a36f3..0000000000 --- a/examples/06-custom-schema/06-toggleable-blocks/src/vite-env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// diff --git a/examples/06-custom-schema/06-toggleable-blocks/tsconfig.json b/examples/06-custom-schema/06-toggleable-blocks/tsconfig.json deleted file mode 100644 index 2aa62c56e6..0000000000 --- a/examples/06-custom-schema/06-toggleable-blocks/tsconfig.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "__comment": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", - "compilerOptions": { - "target": "ESNext", - "useDefineForClassFields": true, - "lib": ["DOM", "DOM.Iterable", "ESNext"], - "allowJs": false, - "skipLibCheck": true, - "allowSyntheticDefaultImports": true, - "strict": true, - "forceConsistentCasingInFileNames": true, - "module": "ESNext", - "moduleResolution": "bundler", - "resolveJsonModule": true, - "isolatedModules": true, - "noEmit": true, - "jsx": "react-jsx", - "composite": true, - "paths": { - "@shared/*": ["../../../shared/*"] - } - }, - "include": ["."], - "__ADD_FOR_LOCAL_DEV_references": [ - { - "path": "../../../packages/core/" - }, - { - "path": "../../../packages/react/" - } - ] -} diff --git a/examples/06-custom-schema/06-toggleable-blocks/vite-env.d.ts b/examples/06-custom-schema/06-toggleable-blocks/vite-env.d.ts deleted file mode 100644 index 11f02fe2a0..0000000000 --- a/examples/06-custom-schema/06-toggleable-blocks/vite-env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// diff --git a/examples/06-custom-schema/06-toggleable-blocks/vite.config.ts b/examples/06-custom-schema/06-toggleable-blocks/vite.config.ts deleted file mode 100644 index cbf6ff2ffc..0000000000 --- a/examples/06-custom-schema/06-toggleable-blocks/vite.config.ts +++ /dev/null @@ -1,35 +0,0 @@ -// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY -import react from "@vitejs/plugin-react"; -import * as fs from "fs"; -import * as path from "path"; -import { defineConfig } from "vite"; -// https://vitejs.dev/config/ -export default defineConfig(((conf: { command: string }) => ({ - plugins: [react()], - optimizeDeps: {}, - build: { - sourcemap: true, - }, - resolve: { - alias: - conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) - ? {} - : ({ - // The repo-wide alias for the shared test-utils directory (private, - // so it only resolves inside the monorepo). Harmless for examples - // that don't use it. - "@shared": path.resolve(__dirname, "../../../shared/"), - // Comment out the lines below to load a built version of blocknote - // or, keep as is to load live from sources with live reload working - "@blocknote/core": path.resolve( - __dirname, - "../../../packages/core/src/", - ), - "@blocknote/react": path.resolve( - __dirname, - "../../../packages/react/src/", - ), - } as any), - }, -})) as Parameters[0]); diff --git a/examples/06-custom-schema/09-container-block/README.md b/examples/06-custom-schema/09-container-block/README.md index 999bf62786..5ecd226989 100644 --- a/examples/06-custom-schema/09-container-block/README.md +++ b/examples/06-custom-schema/09-container-block/README.md @@ -2,7 +2,7 @@ In this example, we create a custom `Panel` block that holds other blocks as its body, such as a panel containing headings and paragraphs. -The block declares the `children` config on `BlockConfig`. `children: { allow: "blocks" }` makes it a container: its child blocks mount into the rendered content region (attached with `ref={contentRef}`), and live on `block.children` at runtime. A pure container like this draws its box in `render`, which re-renders live when props change. +The block sets `container: true` on `BlockConfig`, which makes it a container: its child blocks mount into the rendered content region (attached with `ref={contentRef}`), and live on `block.children` at runtime. A pure container like this draws its box in `render`, which re-renders live when props change. We also wire up a Slash Menu item to insert the panel. diff --git a/examples/06-custom-schema/09-container-block/src/Panel.tsx b/examples/06-custom-schema/09-container-block/src/Panel.tsx index 7af68eff48..49a623fa31 100644 --- a/examples/06-custom-schema/09-container-block/src/Panel.tsx +++ b/examples/06-custom-schema/09-container-block/src/Panel.tsx @@ -7,7 +7,7 @@ export const createPanel = createReactBlockSpec( type: "panel", propSchema: {}, content: "none", - children: { allow: "blocks" }, + container: true, }, { // With no content of its own, contentRef receives the child blocks. diff --git a/examples/06-custom-schema/11-source-with-preview/src/App.tsx b/examples/06-custom-schema/11-source-with-preview/src/App.tsx index 3485907844..75fabe5fcd 100644 --- a/examples/06-custom-schema/11-source-with-preview/src/App.tsx +++ b/examples/06-custom-schema/11-source-with-preview/src/App.tsx @@ -114,10 +114,11 @@ const createCSVTableBlockSpec = createReactBlockSpec( // Marks the block as rendering a preview with an editable source popup // (driven by an editor-wide extension - nothing to register). hasPreview: true, - // Enter inserts a newline while the popup is open (multiline source); - // use "shift+enter" for single-line sources, where Enter closes the - // popup instead. - hardBreakShortcut: "enter", + }, + // Enter inserts a newline while the popup is open (multiline source). + // Without this, Enter closes the popup, as for single-line sources. + keyboard: { + enter: "line-break", }, render: CSVTablePreview, }, diff --git a/examples/06-custom-schema/13-callout-block/README.md b/examples/06-custom-schema/13-callout-block/README.md index 20071bb974..2e0a9c7fcf 100644 --- a/examples/06-custom-schema/13-callout-block/README.md +++ b/examples/06-custom-schema/13-callout-block/README.md @@ -1,16 +1,17 @@ # Callout Block -In this example, we create a custom `Callout` block with a real rich-text title and a body of child blocks (a titled block), like a Notion-style callout. +In this example, we create a custom `Callout` block with a real rich-text title and child blocks inside it (a titled block), like a Notion-style callout. -The block combines `content: "inline"` with the `children` config on `BlockConfig`. The title is ordinary inline content — formatting, links, and multiplayer cursors all work — while `children: { allow: "blocks" }` hosts the body blocks, which live on `block.children` at runtime. `render` draws the title row and `renderFrame` draws the box around the title and body together. +The block has `content: "inline"`: the title is ordinary inline content — formatting, links, and multiplayer cursors all work — and its child blocks live on `block.children` at runtime. Its `keyboard` settings keep the child blocks inside the callout: Enter in the title adds a first child block, Shift-Tab doesn't move child blocks out, and Enter in an empty last child block leaves the callout. `render` draws the title row and `renderFrame` draws the box around the title and child blocks together. We also wire up a Slash Menu item to insert the callout. **Try it out:** -- Press Enter at the end of the callout's title to jump into its body. -- Press Backspace at the start of the first body block to merge it back into the title. -- Press "/" inside the body and add a code block, heading, or list. +- Press Enter at the end of the callout's title to add a block inside the callout. +- Press Enter in an empty last block inside the callout to leave the callout. +- Press Backspace at the start of the first block inside the callout to merge it back into the title. +- Press "/" inside the callout and add a code block, heading, or list. **Relevant Docs:** diff --git a/examples/06-custom-schema/13-callout-block/src/App.tsx b/examples/06-custom-schema/13-callout-block/src/App.tsx index bb199625b9..842b59e344 100644 --- a/examples/06-custom-schema/13-callout-block/src/App.tsx +++ b/examples/06-custom-schema/13-callout-block/src/App.tsx @@ -45,7 +45,7 @@ export default function App() { { type: "paragraph", content: - "Welcome! This demo shows a titled block: a rich-text title with a body of child blocks.", + "Welcome! This demo shows a titled block: a rich-text title with child blocks inside it.", }, { type: "callout", @@ -59,7 +59,7 @@ export default function App() { { type: "paragraph", content: - "Press Enter at the end of the title to jump into the body, or Backspace at the start of the body to merge back.", + "Press Enter at the end of the title to add a block inside the callout, or Backspace at the start of the first block inside to merge it back.", }, ], }, diff --git a/examples/06-custom-schema/13-callout-block/src/Callout.tsx b/examples/06-custom-schema/13-callout-block/src/Callout.tsx index d26c7eb81a..dfccd6014c 100644 --- a/examples/06-custom-schema/13-callout-block/src/Callout.tsx +++ b/examples/06-custom-schema/13-callout-block/src/Callout.tsx @@ -2,19 +2,25 @@ import { createReactBlockSpec } from "@blocknote/react"; import "./styles.css"; -// The Callout block: a titled block. `content: "inline"` plus `children` -// gives the block its own rich-text title with child blocks as its body. -// `render` draws the title row (the title mounts into `contentRef`), while -// `renderFrame` draws the box around the title and the body together. +// The Callout block: a titled block. `content: "inline"` gives the block its +// own rich-text title, and its child blocks sit inside the callout. The +// `keyboard` settings keep them inside: Enter in the title adds a first child +// block, child blocks can't be outdented out, and Enter in an empty last child +// block leaves the callout. `render` draws the title row (the title mounts +// into `contentRef`), while `renderFrame` draws the box around the title and +// the child blocks together. export const createCallout = createReactBlockSpec( { type: "callout", propSchema: {}, content: "inline", - // The title is ordinary inline content; the children are the body. - children: { allow: "blocks" }, }, { + keyboard: { + enter: "into-children", + childrenCanOutdent: false, + emptyChildEnter: "exit-at-end", + }, render: (props) => (

diff --git a/packages/core/src/api/blockManipulation/commands/insertBlocks/insertPlacement.test.ts b/packages/core/src/api/blockManipulation/commands/insertBlocks/insertPlacement.test.ts index d2d1022389..b00fcfa71c 100644 --- a/packages/core/src/api/blockManipulation/commands/insertBlocks/insertPlacement.test.ts +++ b/packages/core/src/api/blockManipulation/commands/insertBlocks/insertPlacement.test.ts @@ -30,17 +30,19 @@ const schema = BlockNoteSchema.create().extend({ // cannot reach inside it. box: container("box", { content: "none", + container: true, children: { allow: "blocks", min: 0 }, }), // A container that only accepts other containers, so an insertion has to // descend a level to find a place for a regular block. grid: container("grid", { content: "none", + container: true, children: { allow: ["cell"], min: 2 }, }), cell: container("cell", { content: "none", - children: { allow: "blocks" }, + container: true, placeable: "namedOnly", }), } as const, diff --git a/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts b/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts index e3817aa3a6..6414788a2b 100644 --- a/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts +++ b/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts @@ -5,24 +5,34 @@ import { type BlockInfo, getBlockInfoAt, getLastDescendantBlockInfo, - getPrevBlockInfo, getParentBlockInfo, + getPrevBlockInfo, } from "../../../getBlockInfoFromPos.js"; -/** Returns compatible text to append, or undefined when the blocks cannot merge. */ +/** + * Returns compatible text to append, or undefined when the blocks cannot merge. + * TODO: remove with #3124. A block that is its children's title + * (`currentIsTitle`) also takes plain text, dropping formatting its schema + * disallows. + */ export function getMergeContent( current: Extract, next: Extract, + // TODO: remove with #3124, which makes merging into a parent general. + currentIsTitle = false, ): Fragment | undefined { const inline = current.contentKind === "inline" && next.contentKind === "inline"; - const ownedText = - current.hasOwnedChildren && + // TODO: remove with #3124. + const titleText = + currentIsTitle && current.content.node.isTextblock && next.content.node.isTextblock; - if (!inline && !ownedText) { + // TODO: remove `titleText` with #3124. + if (!inline && !titleText) { return undefined; } + // TODO: remove with #3124 (only a title reaches here with plain content). if (current.contentKind === "plain") { const type = current.content.node.type; const children: Node[] = []; @@ -50,11 +60,18 @@ export function getMergeContent( * back from there. * @returns A tiptap command that returns `false` (leaving the doc untouched) * when the two blocks can't merge: no compatible text block above, or the - * block above is empty (deleting it is handled elsewhere). An owning block - * can also merge plain text, dropping formatting that its schema disallows. + * block above is empty (deleting it is handled elsewhere). + * @param isTitle TODO: remove with #3124. Whether a block is its children's + * title (its Enter goes into its children), so that its first child can merge + * into it. */ export const mergeBlocksCommand = - (posBetweenBlocks: number) => + ( + posBetweenBlocks: number, + // TODO: remove with #3124, which lets every first child merge into its + // parent. + isTitle: (node: Node) => boolean = () => false, + ) => ({ state, dispatch, @@ -71,11 +88,12 @@ export const mergeBlocksCommand = const parent = prevSibling ? undefined : getParentBlockInfo(state.doc, nextBlockInfo.block.beforePos); - // An owned body's first block can merge into its title. Ordinary nested - // blocks still need a preceding sibling; lifting handles their boundary. + // TODO: remove the `isTitle` branch with #3124. A title's first child can + // merge into the title. Other first children have no block above to merge + // into; lifting handles their boundary. const prevBlockInfo = prevSibling ? getLastDescendantBlockInfo(prevSibling) - : parent?.hasOwnedChildren + : parent && isTitle(parent.block.node) ? parent : undefined; if (!prevBlockInfo) { @@ -89,7 +107,12 @@ export const mergeBlocksCommand = ) { return false; } - const content = getMergeContent(prevBlockInfo, nextBlockInfo); + const content = getMergeContent( + prevBlockInfo, + nextBlockInfo, + // TODO: remove with #3124. + isTitle(prevBlockInfo.block.node), + ); if (content === undefined) { return false; } diff --git a/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts b/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts index 7dc8fa9aa7..0f28679300 100644 --- a/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts +++ b/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts @@ -3,10 +3,8 @@ import { Transaction } from "prosemirror-state"; import { canJoin, liftTarget, ReplaceAroundStep } from "prosemirror-transform"; import { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js"; -import { - CHILD_CONTAINER_GROUP, - hasOwnedChildren, -} from "../../../../schema/blocks/children.js"; +import { CHILD_CONTAINER_GROUP } from "../../../../schema/blocks/children.js"; +import { nodeToBlock } from "../../../nodeConversions/nodeToBlock.js"; /** * Whether `node` is the sibling list that nesting and unnesting operate on: a @@ -180,6 +178,9 @@ export function liftItem( tr: Transaction, itemType: NodeType, groupType: NodeType, // change 2 + // Whether a block may be outdented out of `parent` (its + // `keyboard.childrenCanOutdent` setting). + canOutdentFrom: (parent: Node) => boolean, ) { const { $from, $to } = tr.selection; const range = $from.blockRange($to, (node) => holdsItems(node, itemType)); // change 1 @@ -188,9 +189,9 @@ export function liftItem( } const parent = $from.node(range.depth - 1); - // A titled block's body belongs to the block that owns it, so unnesting - // stops at its edge rather than lifting the block out of it. - if (parent.type === itemType && hasOwnedChildren(parent)) { + // A block whose children can't be outdented keeps them: unnesting stops at + // its edge rather than lifting the block out of it. + if (parent.type === itemType && !canOutdentFrom(parent)) { return false; } @@ -210,6 +211,12 @@ function unnestCommand(editor: BlockNoteEditor) { tr, editor.pmSchema.nodes["blockContainer"], editor.pmSchema.nodes["blockGroup"], + (parent) => { + const block = nodeToBlock(parent, tr.doc); + return editor.schema.blockSpecs[block.type].implementation.keyboard( + block, + ).childrenCanOutdent; + }, ); } diff --git a/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts b/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts index 15caf1dca1..5014b45184 100644 --- a/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts +++ b/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts @@ -28,7 +28,10 @@ import { import { nodeToBlock } from "../../../nodeConversions/nodeToBlock.js"; import { getNodeById } from "../../../nodeUtil.js"; import { getBlockSchema, getPmSchema } from "../../../pmUtil.js"; -import { createBlockGroup } from "../../../../schema/blocks/children.js"; +import { + createBlockGroup, + isContainerNode, +} from "../../../../schema/blocks/children.js"; // for compatibility with tiptap. TODO: remove as we want to remove dependency on tiptap command interface export const updateBlockCommand = < @@ -128,7 +131,7 @@ export function updateBlockTr< targetConfig.content === "plain" ) { content = existingBlock.content; - } else if (targetConfig.children !== undefined) { + } else if (isContainerNode(newNodeType)) { children.unshift({ type: "paragraph", content: existingBlock.content }); } } diff --git a/packages/core/src/api/blockManipulation/containers/containers.fixture.ts b/packages/core/src/api/blockManipulation/containers/containers.fixture.ts index a32488ad5b..273feaff27 100644 --- a/packages/core/src/api/blockManipulation/containers/containers.fixture.ts +++ b/packages/core/src/api/blockManipulation/containers/containers.fixture.ts @@ -17,7 +17,7 @@ const Callout = createBlockSpec( }, }, content: "none", - children: { allow: "blocks" }, + container: true, }, { render: renderDiv }, )(); @@ -27,6 +27,7 @@ const Grid = createBlockSpec( type: "grid" as const, propSchema: {}, content: "none", + container: true, children: { allow: ["gridCell"], min: 2, @@ -40,7 +41,7 @@ const GridCell = createBlockSpec( type: "gridCell" as const, propSchema: {}, content: "none", - children: { allow: "blocks" }, + container: true, placeable: "namedOnly", }, { render: renderDiv }, @@ -53,25 +54,29 @@ const Pair = createBlockSpec( type: "pair" as const, propSchema: {}, content: "none", - children: { - allow: "blocks", - min: 2, - }, + container: true, + children: { min: 2 }, }, { render: renderDiv }, )(); // A titled block: an ordinary block with inline content (the title) whose -// `children` are a body that belongs to it. The frame draws the box around -// title and body together. +// child blocks are a body that belongs to it. Its `keyboard` settings keep +// the body together: Enter in the title starts it, its blocks can't be +// outdented, and an empty last block leaves it. The frame draws the box +// around title and body together. const Alert = createBlockSpec( { type: "alert" as const, propSchema: {}, content: "inline", - children: { allow: "blocks" }, }, { + keyboard: { + enter: "into-children", + childrenCanOutdent: false, + emptyChildEnter: "exit-at-end", + }, render: renderDiv, renderFrame: () => { const dom = document.createElement("div"); diff --git a/packages/core/src/api/blockManipulation/containers/containers.test.ts b/packages/core/src/api/blockManipulation/containers/containers.test.ts index 0f6ea25348..faf537dc7b 100644 --- a/packages/core/src/api/blockManipulation/containers/containers.test.ts +++ b/packages/core/src/api/blockManipulation/containers/containers.test.ts @@ -174,6 +174,33 @@ describe("children insertion & filling", () => { }); }); +describe("container keyboard defaults", () => { + // A container's children can't be outdented, so its defaults say so: an + // empty last child leaves the container instead. + it("defaults to leaving the container instead of outdenting", () => { + editor.replaceBlocks(editor.document, [ + { id: "callout", type: "callout", children: [{ type: "paragraph" }] }, + ]); + const keyboard = editor.schema.blockSpecs.callout.implementation.keyboard( + editor.getBlock("callout")!, + ); + expect(keyboard).toMatchObject({ + emptyChildEnter: "exit-at-end", + childrenCanOutdent: false, + }); + }); + + it("keeps outdenting for other blocks", () => { + const keyboard = editor.schema.blockSpecs.paragraph.implementation.keyboard( + editor.getBlock("p-0")!, + ); + expect(keyboard).toMatchObject({ + emptyChildEnter: "outdent", + childrenCanOutdent: true, + }); + }); +}); + describe("container nodes", () => { // No container is `isolating`. PM only honours that flag while no selection // spans the edge, and nothing prevents one: given a spanning slice, `Fitter` @@ -436,6 +463,7 @@ describe("repair edge cases", () => { type: "tray" as const, propSchema: {}, content: "none" as const, + container: true, children: { allow: "blocks", min: 0 }, }, { diff --git a/packages/core/src/api/blockManipulation/containers/fixContainer.ts b/packages/core/src/api/blockManipulation/containers/fixContainer.ts index 6cff1615a2..8206bea42e 100644 --- a/packages/core/src/api/blockManipulation/containers/fixContainer.ts +++ b/packages/core/src/api/blockManipulation/containers/fixContainer.ts @@ -32,8 +32,7 @@ export function isEmptyContainerChild(node: Node): boolean { // and no fill happened on the way. A `min: 0` container in this state is // valid and stays; any other is broken structure. if (node.childCount === 0) { - const children = node.type.spec.blockConfig?.children; - return !!children && (children.min ?? 1) >= 1; + return (node.type.spec.blockConfig?.children?.min ?? 1) >= 1; } return false; } @@ -67,8 +66,7 @@ export function fixContainer(tr: Transaction, containerPos: number) { return; } - const childrenConfig = container.type.spec.blockConfig?.children; - const min = childrenConfig ? (childrenConfig.min ?? 1) : 1; + const min = container.type.spec.blockConfig?.children?.min ?? 1; const survivors: { node: Node; offset: number }[] = []; const emptied: { from: number; to: number }[] = []; container.forEach((child, offset) => { diff --git a/packages/core/src/api/blockManipulation/containers/plainBlocks.test.ts b/packages/core/src/api/blockManipulation/containers/plainBlocks.test.ts index 7fd3b4dc58..39b84c81b4 100644 --- a/packages/core/src/api/blockManipulation/containers/plainBlocks.test.ts +++ b/packages/core/src/api/blockManipulation/containers/plainBlocks.test.ts @@ -10,9 +10,15 @@ const plainNote = createBlockSpec( type: "plainNote", propSchema: {}, content: "plain", - children: { allow: "blocks" }, }, { + // A titled block: Enter in its text starts its body, and the body's + // blocks can't be outdented out of it. + keyboard: { + enter: "into-children", + childrenCanOutdent: false, + emptyChildEnter: "exit-at-end", + }, render() { const dom = document.createElement("pre"); return { dom, contentDOM: dom }; @@ -82,7 +88,7 @@ function text(editor: ReturnType, id: string) { .join(""); } -describe("plain blocks with owned children", () => { +describe("plain blocks whose Enter goes into their children", () => { it.each(["", "Source"])( "Enter starts the body and preserves children for %j", (content) => { @@ -161,6 +167,17 @@ describe("plain blocks with owned children", () => { }, ); + it("Backspace in the block after merges it into the body's last block", () => { + const editor = editorWith(); + editor.setTextCursorPosition("after", "start"); + press(editor, "Backspace"); + + // As after any block with children: the text is appended to the last + // block above it. + expect(text(editor, "body")).toBe("ExplanationAfter"); + expect(editor.document.map((block) => block.id)).toEqual(["note"]); + }); + it("keeps owned children when Shift-Tab is pressed", () => { const editor = editorWith(); editor.setTextCursorPosition("body", "start"); diff --git a/packages/core/src/api/blockManipulation/containers/titledBlocks.test.ts b/packages/core/src/api/blockManipulation/containers/titledBlocks.test.ts index af683b567d..a151e1e33a 100644 --- a/packages/core/src/api/blockManipulation/containers/titledBlocks.test.ts +++ b/packages/core/src/api/blockManipulation/containers/titledBlocks.test.ts @@ -1,11 +1,9 @@ import { NodeSelection, TextSelection } from "prosemirror-state"; import { afterEach, describe, expect, it } from "vite-plus/test"; +import type { BlockNoteSchema } from "../../../blocks/BlockNoteSchema.js"; import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js"; -import { - hasOwnedChildren, - isContainerNode, -} from "../../../schema/blocks/children.js"; +import { isContainerNode } from "../../../schema/blocks/children.js"; import { getBlockInfoAt } from "../../getBlockInfoFromPos.js"; import { getNodeById } from "../../nodeUtil.js"; import { containerSchema } from "./containers.fixture.js"; @@ -77,19 +75,33 @@ const withAlert = (children: any[] = body) => [ ]; describe("titled-block schema shape", () => { - it("recognizes declared ownership on ordinary blocks", () => { + it("is an ordinary block whose keyboard settings keep its body together", () => { const editor = editorWith(withAlert()); + function keyboardOf(id: string) { + const { blockSpecs } = editor.schema as BlockNoteSchema; + const block = editor.getBlock(id)!; + return blockSpecs[block.type].implementation.keyboard(block); + } + editor.transact((tr) => { const alert = getNodeById("w", tr.doc)!; // An ordinary blockContainer: its node holds content, not children. expect(alert.node.type.name).toBe("blockContainer"); expect(isContainerNode(alert.node.type)).toBe(false); - expect(hasOwnedChildren(alert.node)).toBe(true); + expect(keyboardOf("w")).toMatchObject({ + enter: "into-children", + childrenCanOutdent: false, + emptyChildEnter: "exit-at-end", + }); expect(alert.node.attrs.id).toBe("w"); expect(alert.node.firstChild!.attrs).not.toHaveProperty("id"); - expect(hasOwnedChildren(getNodeById("pre", tr.doc)!.node)).toBe(false); + expect(keyboardOf("pre")).toMatchObject({ + enter: "split", + childrenCanOutdent: true, + emptyChildEnter: "outdent", + }); // The body is the blockGroup the alert nests, resolved with positions. const info = getBlockInfoAt(tr.doc, alert.posBeforeNode); @@ -217,14 +229,15 @@ describe("a titled block's keyboard behaviour", () => { ); }); - it("Backspace in the block after moves it into the body, whole", () => { + it("Backspace in the block after merges it into the body's last block", () => { const editor = editorWith(withAlert()); editor.setTextCursorPosition("post", "start"); press(editor, "Backspace"); - // Moved in as its own block: text never merges across the edge. + // As after any block with children: the text is appended to the last + // block above it. expect(shape(editor.document)).toBe( - 'paragraph"Before", alert"Title"[paragraph"One", paragraph"Two", paragraph"After"]', + 'paragraph"Before", alert"Title"[paragraph"One", paragraph"TwoAfter"]', ); }); diff --git a/packages/core/src/api/exporters/html/internalHTMLSerializer.ts b/packages/core/src/api/exporters/html/internalHTMLSerializer.ts index 33376b2835..05338f5f97 100644 --- a/packages/core/src/api/exporters/html/internalHTMLSerializer.ts +++ b/packages/core/src/api/exporters/html/internalHTMLSerializer.ts @@ -60,11 +60,11 @@ const makeCheckListItemsReadOnly = (element: HTMLElement) => { // serializing HTML elements to a string, so the button no longer works if the // HTML string is rendered out. const forceToggleBlocksShow = (element: HTMLElement) => { - const hiddenToggleWrappers = element.querySelectorAll( - '.bn-toggle-wrapper[data-show-children="false"]', + const closedToggleButtons = element.querySelectorAll( + '.bn-toggle-button[aria-expanded="false"]', ); - hiddenToggleWrappers.forEach((toggleWrapper) => { - toggleWrapper.setAttribute("data-show-children", "true"); + closedToggleButtons.forEach((toggleButton) => { + toggleButton.setAttribute("aria-expanded", "true"); }); return element; diff --git a/packages/core/src/api/getBlockInfoFromPos.test.ts b/packages/core/src/api/getBlockInfoFromPos.test.ts index 5080902c0d..ddd0d00b7c 100644 --- a/packages/core/src/api/getBlockInfoFromPos.test.ts +++ b/packages/core/src/api/getBlockInfoFromPos.test.ts @@ -524,7 +524,7 @@ describe("block info for containers", () => { editor._tiptapEditor.destroy(); }); it.each(["paragraph", "alert", "callout"] as const)( - "distinguishes %s ownership from the presence of children", + "distinguishes %s content from the presence of children", (type) => { for (const children of [ [], @@ -532,7 +532,6 @@ describe("block info for containers", () => { ]) { const node = blockToNode({ type, children }, editor.pmSchema); const info = getBlockInfoFromNode(node, 10); - expect(info.hasOwnedChildren).toBe(type !== "paragraph"); expect(info.hasContent).toBe(type !== "callout"); if (children.length) { expect(info.children?.node.childCount).toBe(1); diff --git a/packages/core/src/api/getBlockInfoFromPos.ts b/packages/core/src/api/getBlockInfoFromPos.ts index e9712d1b4c..822fc14044 100644 --- a/packages/core/src/api/getBlockInfoFromPos.ts +++ b/packages/core/src/api/getBlockInfoFromPos.ts @@ -10,7 +10,6 @@ import { import { CHILD_CONTAINER_GROUP, isContainerNode, - hasOwnedChildren, } from "../schema/blocks/children.js"; import type { BlockConfig } from "../schema/blocks/types.js"; @@ -74,7 +73,6 @@ export type BlockInfo = { children: ChildrenInfo; content?: undefined; hasContent: false; - hasOwnedChildren: true; contentStart?: undefined; contentEnd?: undefined; contentKind?: undefined; @@ -111,8 +109,6 @@ export type BlockInfo = { * `hasContent: false`. */ hasContent: true; - /** Whether children belong to this block, even before a body exists. */ - hasOwnedChildren: boolean; } ); @@ -330,7 +326,6 @@ export function getBlockInfoFromNode(node: Node, beforePos: number): BlockInfo { if (isContainerNode(node.type)) { return { hasContent: false, - hasOwnedChildren: true, block, children: { ...block, @@ -384,7 +379,6 @@ export function getBlockInfoFromNode(node: Node, beforePos: number): BlockInfo { return { hasContent: true, - hasOwnedChildren: hasOwnedChildren(node), block, content, children, diff --git a/packages/core/src/blocks/Heading/block.ts b/packages/core/src/blocks/Heading/block.ts index ebcedaa585..4e84967d9e 100644 --- a/packages/core/src/blocks/Heading/block.ts +++ b/packages/core/src/blocks/Heading/block.ts @@ -7,7 +7,10 @@ import { parseDefaultProps, } from "../defaultProps.js"; import { getDetailsContent } from "../getDetailsContent.js"; -import { createToggleFrame } from "../ToggleWrapper/createToggleFrame.js"; +import { + createToggleFrame, + isToggleOpen, +} from "../ToggleWrapper/createToggleFrame.js"; const HEADING_LEVELS = [1, 2, 3, 4, 5, 6] as const; @@ -18,8 +21,15 @@ export interface HeadingOptions { allowToggleHeadings?: boolean; } +// A regular heading's props. With toggle headings enabled, this sets +// `isToggleable: false`, so that turning a toggle heading into a heading of +// some level also makes it a regular heading (BLO-959). +function regularHeadingProps(level: number, allowToggleHeadings: boolean) { + return allowToggleHeadings ? { level, isToggleable: false } : { level }; +} + const createHeadingKeyboardShortcut = - (level: number) => + (level: number, allowToggleHeadings: boolean) => ({ editor }: { editor: BlockNoteEditor }) => { const cursorPosition = editor.getTextCursorPosition(); @@ -31,7 +41,7 @@ const createHeadingKeyboardShortcut = editor.updateBlock(cursorPosition.block, { type: "heading", - props: { level }, + props: regularHeadingProps(level, allowToggleHeadings), }); return true; @@ -64,6 +74,23 @@ export const createHeadingBlockSpec = createBlockSpec( meta: { isolating: false, }, + // 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 + // children, and Enter in an empty child adds another child. + keyboard: allowToggleHeadings + ? (block) => { + if (!block.props.isToggleable) { + return {}; + } + const open = isToggleOpen(block); + return { + resetsTo: { type: "heading", props: { isToggleable: false } }, + emptyEnterResets: true, + enter: open ? "into-children" : "split", + emptyChildEnter: open ? "stay" : "outdent", + }; + } + : undefined, parse(e) { if (allowToggleHeadings && e.tagName === "DETAILS") { const summary = e.querySelector(":scope > summary"); @@ -169,13 +196,16 @@ export const createHeadingBlockSpec = createBlockSpec( }; }, }), - ({ levels = HEADING_LEVELS }: HeadingOptions = {}) => [ + ({ + levels = HEADING_LEVELS, + allowToggleHeadings = true, + }: HeadingOptions = {}) => [ createExtension({ key: "heading-shortcuts", keyboardShortcuts: Object.fromEntries( levels.map((level) => [ `Mod-Alt-${level}`, - createHeadingKeyboardShortcut(level), + createHeadingKeyboardShortcut(level, allowToggleHeadings), ]) ?? [], ), inputRules: levels.map((level) => ({ @@ -183,9 +213,7 @@ export const createHeadingBlockSpec = createBlockSpec( replace({ match }: { match: RegExpMatchArray }) { return { type: "heading", - props: { - level: match[1].length, - }, + props: regularHeadingProps(match[1].length, allowToggleHeadings), }; }, })), diff --git a/packages/core/src/blocks/ListItem/BulletListItem/block.ts b/packages/core/src/blocks/ListItem/BulletListItem/block.ts index 0a40bdc1ce..3a96bfe747 100644 --- a/packages/core/src/blocks/ListItem/BulletListItem/block.ts +++ b/packages/core/src/blocks/ListItem/BulletListItem/block.ts @@ -6,7 +6,6 @@ import { defaultProps, parseDefaultProps, } from "../../defaultProps.js"; -import { handleEnter } from "../../utils/listItemEnterHandler.js"; import { getListItemContent } from "../getListItemContent.js"; export type BulletListItemBlockConfig = ReturnType< @@ -27,6 +26,12 @@ export const createBulletListItemBlockConfig = createBlockConfig( export const createBulletListItemBlockSpec = createBlockSpec( createBulletListItemBlockConfig, { + // Enter continues the list, and Enter in an empty item ends it: the item + // turns into a paragraph. + keyboard: { + splitKeepsType: true, + emptyEnterResets: true, + }, meta: { isolating: false, }, @@ -84,9 +89,6 @@ export const createBulletListItemBlockSpec = createBlockSpec( createExtension({ key: "bullet-list-item-shortcuts", keyboardShortcuts: { - Enter: ({ editor }) => { - return handleEnter(editor, "bulletListItem"); - }, "Mod-Shift-8": ({ editor }) => { const cursorPosition = editor.getTextCursorPosition(); diff --git a/packages/core/src/blocks/ListItem/CheckListItem/block.ts b/packages/core/src/blocks/ListItem/CheckListItem/block.ts index 6d514270bf..b52c4f384a 100644 --- a/packages/core/src/blocks/ListItem/CheckListItem/block.ts +++ b/packages/core/src/blocks/ListItem/CheckListItem/block.ts @@ -5,7 +5,6 @@ import { defaultProps, parseDefaultProps, } from "../../defaultProps.js"; -import { handleEnter } from "../../utils/listItemEnterHandler.js"; import { getListItemContent } from "../getListItemContent.js"; export type CheckListItemBlockConfig = ReturnType< @@ -27,6 +26,12 @@ export const createCheckListItemConfig = createBlockConfig( export const createCheckListItemBlockSpec = createBlockSpec( createCheckListItemConfig, { + // Enter continues the list, and Enter in an empty item ends it: the item + // turns into a paragraph. + keyboard: { + splitKeepsType: true, + emptyEnterResets: true, + }, meta: { isolating: false, }, @@ -133,9 +138,6 @@ export const createCheckListItemBlockSpec = createBlockSpec( createExtension({ key: "check-list-item-shortcuts", keyboardShortcuts: { - Enter: ({ editor }) => { - return handleEnter(editor, "checkListItem"); - }, "Mod-Shift-9": ({ editor }) => { const cursorPosition = editor.getTextCursorPosition(); diff --git a/packages/core/src/blocks/ListItem/ListItemKeyboardShortcuts.ts b/packages/core/src/blocks/ListItem/ListItemKeyboardShortcuts.ts deleted file mode 100644 index 218618ca1f..0000000000 --- a/packages/core/src/blocks/ListItem/ListItemKeyboardShortcuts.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { splitBlockCommand } from "../../api/blockManipulation/commands/splitBlock/splitBlock.js"; -import { updateBlockCommand } from "../../api/blockManipulation/commands/updateBlock/updateBlock.js"; -import { getBlockInfoFromSelection } from "../../api/getBlockInfoFromPos.js"; -import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; - -export const handleEnter = (editor: BlockNoteEditor) => { - const { blockInfo, selectionEmpty } = editor.transact((tr) => { - return { - blockInfo: getBlockInfoFromSelection(tr), - selectionEmpty: tr.selection.anchor === tr.selection.head, - }; - }); - - if (!blockInfo.hasContent) { - return false; - } - const { block: blockContainer, content } = blockInfo; - - if ( - !( - content.node.type.name === "toggleListItem" || - content.node.type.name === "bulletListItem" || - content.node.type.name === "numberedListItem" || - content.node.type.name === "checkListItem" - ) || - !selectionEmpty - ) { - return false; - } - - return editor._tiptapEditor.commands.first(({ state, chain, commands }) => [ - () => - // Changes list item block to a paragraph block if the content is empty. - commands.command(() => { - if (blockInfo.isContentEmpty) { - return commands.command( - updateBlockCommand(blockContainer.beforePos, { - type: "paragraph", - props: {}, - }), - ); - } - - return false; - }), - - () => - // Splits the current block, moving content inside that's after the cursor - // to a new block of the same type below. - commands.command(() => { - if (content.node.childCount > 0) { - chain() - .deleteSelection() - .command(splitBlockCommand(state.selection.from, true)) - .run(); - - return true; - } - - return false; - }), - ]); -}; diff --git a/packages/core/src/blocks/ListItem/NumberedListItem/block.ts b/packages/core/src/blocks/ListItem/NumberedListItem/block.ts index fc2537829d..37b8990201 100644 --- a/packages/core/src/blocks/ListItem/NumberedListItem/block.ts +++ b/packages/core/src/blocks/ListItem/NumberedListItem/block.ts @@ -6,7 +6,6 @@ import { defaultProps, parseDefaultProps, } from "../../defaultProps.js"; -import { handleEnter } from "../../utils/listItemEnterHandler.js"; import { getListItemContent } from "../getListItemContent.js"; import { NumberedListIndexingDecorationPlugin } from "./IndexingPlugin.js"; @@ -29,6 +28,12 @@ export const createNumberedListItemBlockConfig = createBlockConfig( export const createNumberedListItemBlockSpec = createBlockSpec( createNumberedListItemBlockConfig, { + // Enter continues the list, and Enter in an empty item ends it: the item + // turns into a paragraph. + keyboard: { + splitKeepsType: true, + emptyEnterResets: true, + }, meta: { isolating: false, }, @@ -115,9 +120,6 @@ export const createNumberedListItemBlockSpec = createBlockSpec( }, ], keyboardShortcuts: { - Enter: ({ editor }) => { - return handleEnter(editor, "numberedListItem"); - }, "Mod-Shift-7": ({ editor }) => { const cursorPosition = editor.getTextCursorPosition(); diff --git a/packages/core/src/blocks/ListItem/ToggleListItem/block.ts b/packages/core/src/blocks/ListItem/ToggleListItem/block.ts index 9d2b0b04ed..bc4c0a8d95 100644 --- a/packages/core/src/blocks/ListItem/ToggleListItem/block.ts +++ b/packages/core/src/blocks/ListItem/ToggleListItem/block.ts @@ -6,8 +6,10 @@ import { parseDefaultProps, } from "../../defaultProps.js"; import { getDetailsContent } from "../../getDetailsContent.js"; -import { createToggleFrame } from "../../ToggleWrapper/createToggleFrame.js"; -import { handleEnter } from "../../utils/listItemEnterHandler.js"; +import { + createToggleFrame, + isToggleOpen, +} from "../../ToggleWrapper/createToggleFrame.js"; export type ToggleListItemBlockConfig = ReturnType< typeof createToggleListItemBlockConfig @@ -27,6 +29,18 @@ export const createToggleListItemBlockConfig = createBlockConfig( export const createToggleListItemBlockSpec = createBlockSpec( createToggleListItemBlockConfig, { + // Enter continues the list, and Enter in an empty item ends it: the item + // turns into a paragraph. While the toggle is open, Enter in its text + // starts its children, and Enter in an empty child adds another child. + keyboard: (block) => { + const open = isToggleOpen(block); + return { + splitKeepsType: true, + emptyEnterResets: true, + enter: open ? "into-children" : "split", + emptyChildEnter: open ? "stay" : "outdent", + }; + }, meta: { isolating: false, }, @@ -99,9 +113,6 @@ export const createToggleListItemBlockSpec = createBlockSpec( createExtension({ key: "toggle-list-item-shortcuts", keyboardShortcuts: { - Enter: ({ editor }) => { - return handleEnter(editor, "toggleListItem"); - }, "Mod-Shift-6": ({ editor }) => { const cursorPosition = editor.getTextCursorPosition(); diff --git a/packages/core/src/blocks/ToggleWrapper/createToggleFrame.ts b/packages/core/src/blocks/ToggleWrapper/createToggleFrame.ts index 1a653d5405..0907ea8a34 100644 --- a/packages/core/src/blocks/ToggleWrapper/createToggleFrame.ts +++ b/packages/core/src/blocks/ToggleWrapper/createToggleFrame.ts @@ -1,10 +1,33 @@ import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import type { Block } from "../defaultBlocks.js"; -import { defaultToggledState } from "./createToggleWrapper.js"; + +// Only the block's id is used, so anything with an id will do. +type ToggledState = { + set: (block: Pick, "id">, isToggled: boolean) => void; + get: (block: Pick, "id">) => boolean; +}; + +export const defaultToggledState: ToggledState = { + set: (block, isToggled: boolean) => + window.localStorage.setItem( + `toggle-${block.id}`, + isToggled ? "true" : "false", + ), + get: (block) => window.localStorage.getItem(`toggle-${block.id}`) === "true", +}; // https://fonts.google.com/icons?selected=Material+Symbols+Rounded:chevron_right:FILL@0;wght@700;GRAD@0;opsz@24&icon.query=chevron&icon.style=Rounded&icon.size=24&icon.color=%23e8eaed +// `aria-hidden`: the icon is decorative, and the button has its own name. const chevronIcon = - ''; + ''; + +/** + * Whether the toggle block is open, as its frame shows it. For keyboard + * settings that differ between an open and a closed toggle. + */ +export function isToggleOpen(block: { id: string }) { + return defaultToggledState.get(block); +} /** * The frame of a toggle block, for `renderFrame`: a chevron that shows or @@ -28,6 +51,13 @@ export function createToggleFrame( const toggleButton = document.createElement("button"); toggleButton.className = "bn-toggle-button"; toggleButton.type = "button"; + // A fixed name, with the state in `aria-expanded`, so a screen reader + // announces e.g. "Expand or collapse, button, collapsed" (#2811). The CSS + // reads the open state from `aria-expanded` too. + toggleButton.setAttribute( + "aria-label", + editor.dictionary.toggle_blocks.toggle_button, + ); toggleButton.innerHTML = chevronIcon; toggleButton.addEventListener("mousedown", (event) => event.preventDefault()); @@ -53,9 +83,10 @@ export function createToggleFrame( dom.append(toggleButton, slot); let childCount = block.children.length; + let open = toggledState.get(block); - function show(open: boolean) { - dom.dataset.showChildren = String(open); + function show() { + toggleButton.setAttribute("aria-expanded", String(open)); const showAddBlock = open && childCount === 0 && editor.isEditable; if (showAddBlock && !addBlockButton.isConnected) { dom.append(addBlockButton); @@ -65,12 +96,12 @@ export function createToggleFrame( } toggleButton.addEventListener("click", () => { - const open = dom.dataset.showChildren !== "true"; + open = !open; toggledState.set(block, open); - show(open); + show(); }); - show(toggledState.get(block)); + show(); return { dom, @@ -79,17 +110,17 @@ export function createToggleFrame( // a child opens the toggle, and removing the last one closes it. update(updated: Block) { const newChildCount = updated.children.length; - let open = dom.dataset.showChildren === "true"; + const wasOpen = open; if (newChildCount > childCount) { open = true; } else if (newChildCount === 0 && childCount > 0) { open = false; } - if (open !== (dom.dataset.showChildren === "true")) { + if (open !== wasOpen) { toggledState.set(updated, open); } childCount = newChildCount; - show(open); + show(); return true; }, }; diff --git a/packages/core/src/blocks/ToggleWrapper/createToggleWrapper.ts b/packages/core/src/blocks/ToggleWrapper/createToggleWrapper.ts deleted file mode 100644 index 257fd7ce6f..0000000000 --- a/packages/core/src/blocks/ToggleWrapper/createToggleWrapper.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { ViewMutationRecord } from "@tiptap/pm/view"; - -import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; -import { Block } from "../defaultBlocks.js"; - -type ToggledState = { - set: (block: Block, isToggled: boolean) => void; - get: (block: Block) => boolean; -}; - -export const defaultToggledState: ToggledState = { - set: (block, isToggled: boolean) => - window.localStorage.setItem( - `toggle-${block.id}`, - isToggled ? "true" : "false", - ), - get: (block) => window.localStorage.getItem(`toggle-${block.id}`) === "true", -}; - -export const createToggleWrapper = ( - block: Block, - editor: BlockNoteEditor, - renderedElement: HTMLElement, - toggledState: ToggledState = defaultToggledState, -): { - dom: HTMLElement; - contentDOM?: HTMLElement; - ignoreMutation?: (mutation: ViewMutationRecord) => boolean; - destroy?: () => void; -} => { - if ("isToggleable" in block.props && !block.props.isToggleable) { - return { - dom: renderedElement, - }; - } - - const dom = document.createElement("div"); - - const toggleWrapper = document.createElement("div"); - toggleWrapper.className = "bn-toggle-wrapper"; - - const toggleButton = document.createElement("button"); - toggleButton.className = "bn-toggle-button"; - toggleButton.type = "button"; - toggleButton.innerHTML = - // https://fonts.google.com/icons?selected=Material+Symbols+Rounded:chevron_right:FILL@0;wght@700;GRAD@0;opsz@24&icon.query=chevron&icon.style=Rounded&icon.size=24&icon.color=%23e8eaed - ''; - const toggleButtonMouseDown = (event: MouseEvent) => event.preventDefault(); - toggleButton.addEventListener("mousedown", toggleButtonMouseDown); - const toggleButtonOnClick = () => { - // Toggles visibility of child blocks. Also adds/removes the "add block" - // button if there are no child blocks. - const currentBlock = editor.getBlock(block); - if (!currentBlock) { - return; - } - - if (toggleWrapper.getAttribute("data-show-children") === "true") { - toggleWrapper.setAttribute("data-show-children", "false"); - toggledState.set(currentBlock, false); - - if (dom.contains(toggleAddBlockButton)) { - dom.removeChild(toggleAddBlockButton); - } - } else { - toggleWrapper.setAttribute("data-show-children", "true"); - toggledState.set(currentBlock, true); - - if ( - editor.isEditable && - currentBlock.children.length === 0 && - !dom.contains(toggleAddBlockButton) - ) { - dom.appendChild(toggleAddBlockButton); - } - } - }; - toggleButton.addEventListener("click", toggleButtonOnClick); - - toggleWrapper.appendChild(toggleButton); - toggleWrapper.appendChild(renderedElement); - - const toggleAddBlockButton = document.createElement("button"); - toggleAddBlockButton.className = "bn-toggle-add-block-button"; - toggleAddBlockButton.type = "button"; - toggleAddBlockButton.textContent = - editor.dictionary.toggle_blocks.add_block_button; - const toggleAddBlockButtonMouseDown = (event: MouseEvent) => - event.preventDefault(); - toggleAddBlockButton.addEventListener( - "mousedown", - toggleAddBlockButtonMouseDown, - ); - const toggleAddBlockButtonOnClick = () => { - // Adds a single empty child block. - editor.transact(() => { - // dom.removeChild(toggleAddBlockButton); - - const updatedBlock = editor.updateBlock(block, { - // Single empty block with default type. - children: [{}], - }); - editor.setTextCursorPosition(updatedBlock.children[0].id, "end"); - editor.focus(); - }); - }; - toggleAddBlockButton.addEventListener("click", toggleAddBlockButtonOnClick); - - dom.appendChild(toggleWrapper); - - let childCount = block.children.length; - const onEditorChange = editor.onChange(() => { - const newChildCount = editor.getBlock(block)?.children.length ?? 0; - - if (newChildCount > childCount) { - // If a child block is added while children are hidden, show children. - if (toggleWrapper.getAttribute("data-show-children") === "false") { - toggleWrapper.setAttribute("data-show-children", "true"); - const currentBlock = editor.getBlock(block); - if (currentBlock) { - toggledState.set(currentBlock, true); - } - } - - // Remove the "add block" button as we want to show child blocks and - // there is at least one child block. - if (dom.contains(toggleAddBlockButton)) { - dom.removeChild(toggleAddBlockButton); - } - } else if (newChildCount === 0 && newChildCount < childCount) { - // If the last child block is removed while children are shown, hide - // children. - if (toggleWrapper.getAttribute("data-show-children") === "true") { - toggleWrapper.setAttribute("data-show-children", "false"); - const currentBlock = editor.getBlock(block); - if (currentBlock) { - toggledState.set(currentBlock, false); - } - } - - // Remove the "add block" button as we want to hide child blocks, - // regardless of whether there are child blocks or not. - if (dom.contains(toggleAddBlockButton)) { - dom.removeChild(toggleAddBlockButton); - } - } - - childCount = newChildCount; - }); - - if (toggledState.get(block)) { - toggleWrapper.setAttribute("data-show-children", "true"); - - if (editor.isEditable && block.children.length === 0) { - // If the toggle is set to show children, but there are no children, - // we add the "add block" button. - dom.appendChild(toggleAddBlockButton); - } - } else { - toggleWrapper.setAttribute("data-show-children", "false"); - } - - return { - dom, - // Prevents re-renders when the toggle button is clicked. - ignoreMutation: (mutation) => { - if ( - mutation instanceof MutationRecord && - // We want to prevent re-renders when the view changes, so we ignore - // all mutations where the `data-show-children` attribute is changed - // or the "add block" button is added/removed. - ((mutation.type === "attributes" && - mutation.target === toggleWrapper && - mutation.attributeName === "data-show-children") || - (mutation.type === "childList" && - (mutation.addedNodes[0] === toggleAddBlockButton || - mutation.removedNodes[0] === toggleAddBlockButton))) - ) { - return true; - } - return false; - }, - destroy: () => { - toggleButton.removeEventListener("mousedown", toggleButtonMouseDown); - toggleButton.removeEventListener("click", toggleButtonOnClick); - toggleAddBlockButton.removeEventListener( - "mousedown", - toggleAddBlockButtonMouseDown, - ); - toggleAddBlockButton.removeEventListener( - "click", - toggleAddBlockButtonOnClick, - ); - onEditorChange?.(); - }, - }; -}; diff --git a/packages/core/src/blocks/ToggleWrapper/toggleBlocks.browser.test.ts b/packages/core/src/blocks/ToggleWrapper/toggleBlocks.browser.test.ts index bd59514749..ad736427b2 100644 --- a/packages/core/src/blocks/ToggleWrapper/toggleBlocks.browser.test.ts +++ b/packages/core/src/blocks/ToggleWrapper/toggleBlocks.browser.test.ts @@ -1,6 +1,6 @@ import { TextSelection } from "prosemirror-state"; import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; -import { userEvent } from "vite-plus/test/browser"; +import { page, userEvent } from "vite-plus/test/browser"; import "../../style.css"; import { getNodeById } from "../../api/nodeUtil.js"; @@ -123,13 +123,13 @@ function addBlockButton(id: string) { return own(id, ".bn-toggle-add-block-button"); } -/** Whether the toggle is open, as the toggle wrapper records it. */ +/** Whether the toggle is open, as its chevron announces it. */ function isOpen(id: string) { - const wrapper = own(id, ".bn-toggle-wrapper"); - if (!wrapper) { + const button = toggleButton(id); + if (!button) { throw new Error(`Block "${id}" is not a toggle`); } - return wrapper.dataset.showChildren === "true"; + return button.getAttribute("aria-expanded") === "true"; } function childrenAreVisible(id: string) { @@ -197,6 +197,27 @@ describe.each(kinds)("$name", ({ toggle, newBlockTypeAfterClosedToggle }) => { expect(JSON.stringify(editor.document)).toBe(document); }); + // #2811: a screen reader must find the chevron and hear its state. + it("names the chevron and exposes the open state to assistive technology", async () => { + mount(withChildren()); + const button = page.getByRole("button", { + name: "Expand or collapse", + expanded: false, + }); + await expect.element(button).toBeInTheDocument(); + expect(toggleButton("t")!.querySelector("svg")!.ariaHidden).toBe("true"); + + await open("t"); + await expect + .element( + page.getByRole("button", { + name: "Expand or collapse", + expanded: true, + }), + ) + .toBeInTheDocument(); + }); + it("keeps the open state for the block when the editor is recreated", async () => { mount(withChildren()); await open("t"); @@ -358,7 +379,7 @@ describe.each(kinds)("$name", ({ toggle, newBlockTypeAfterClosedToggle }) => { describe("Enter", () => { // BLO-929: Enter at the end of an open toggle's title should start the // toggle's body, as in Notion, not a new block after the toggle. - it.fails("at the end of an open toggle's title adds a first child (BLO-929)", async () => { + it("at the end of an open toggle's title adds a first child (BLO-929)", async () => { mount(withChildren()); await open("t"); @@ -389,7 +410,7 @@ describe.each(kinds)("$name", ({ toggle, newBlockTypeAfterClosedToggle }) => { // BLO-949: Enter in the title must not break the children apart. As in // Notion, the text after the caret becomes the toggle's first child. - it.fails("in the middle of an open toggle's title moves the rest of the title into a first child (BLO-949)", async () => { + it("in the middle of an open toggle's title moves the rest of the title into a first child (BLO-949)", async () => { mount(withChildren()); await open("t"); @@ -404,8 +425,25 @@ describe.each(kinds)("$name", ({ toggle, newBlockTypeAfterClosedToggle }) => { ); }); + // On a closed toggle, Enter splits the title as for any block: the rest + // goes into a new block after the toggle, which continues the list for a + // toggle list item, and the children stay with the toggle. + it("in the middle of a closed toggle's title moves the rest into a new block after it", async () => { + mount(withChildren()); + + setCaretAt("t", 2); + await userEvent.keyboard("{Enter}"); + + const [first, second] = editor.document; + expect(shape([first])).toMatch( + /^\w+"Ti"\[paragraph"One", paragraph"Two"\]$/, + ); + expect(second.type).toBe(newBlockTypeAfterClosedToggle); + expect(shape([second])).toMatch(/"tle"$/); + }); + // As in Notion, Enter on an empty last child stays inside the toggle. - it.fails("on an empty last child adds another child", async () => { + it("on an empty last child adds another child", async () => { mount([ toggle("t", "Title", [ { id: "c1", type: "paragraph", content: "One" }, @@ -451,7 +489,7 @@ describe.each(kinds)("$name", ({ toggle, newBlockTypeAfterClosedToggle }) => { describe("Backspace", () => { // As in Notion, Backspace at the start of the first child merges it into // the title. - it.fails("at the start of the first child merges it into the title", async () => { + it("at the start of the first child merges it into the title", async () => { mount(withChildren()); await open("t"); @@ -574,7 +612,100 @@ describe("Enter in an empty toggle title", () => { expect(editor.document).toHaveLength(2); }); - it.fails("turns a toggle heading into a regular heading", async () => { + // The same when the toggle is open: an empty title never starts the + // toggle's children. + it("turns an open toggle list item into a paragraph", async () => { + mount([ + { id: "t", type: "paragraph", content: "Before" }, + { id: "empty", type: "toggleListItem" }, + ]); + await userEvent.click(toggleButton("empty")!); + + await press("{Enter}", { block: "empty", placement: "start" }); + + expect(editor.getBlock("empty")!.type).toBe("paragraph"); + expect(editor.getBlock("empty")!.children).toHaveLength(0); + expect(editor.document).toHaveLength(2); + }); + + it("turns an open toggle heading into a regular heading", async () => { + mount([ + { id: "t", type: "paragraph", content: "Before" }, + { id: "empty", type: "heading", props: { level: 2, isToggleable: true } }, + ]); + await userEvent.click(toggleButton("empty")!); + + await press("{Enter}", { block: "empty", placement: "start" }); + + expect(editor.getBlock("empty")!.props).toMatchObject({ + level: 2, + isToggleable: false, + }); + expect(editor.getBlock("empty")!.children).toHaveLength(0); + expect(editor.document).toHaveLength(2); + }); + + // As in Notion, the children stay nested under the reset block. + for (const state of ["closed", "open"] as const) { + it(`turns a toggle list item with children into a paragraph, keeping the children (${state})`, async () => { + mount([ + { id: "t", type: "paragraph", content: "Before" }, + { + id: "empty", + type: "toggleListItem", + children: [{ id: "c1", type: "paragraph", content: "One" }], + }, + ]); + if (state === "open") { + await userEvent.click(toggleButton("empty")!); + } + + await press("{Enter}", { block: "empty", placement: "start" }); + + expect(shape()).toBe('paragraph"Before", paragraph""[paragraph"One"]'); + }); + + it(`turns a toggle heading with children into a regular heading, keeping the children (${state})`, async () => { + mount([ + { id: "t", type: "paragraph", content: "Before" }, + { + id: "empty", + type: "heading", + props: { level: 2, isToggleable: true }, + children: [{ id: "c1", type: "paragraph", content: "One" }], + }, + ]); + if (state === "open") { + await userEvent.click(toggleButton("empty")!); + } + + await press("{Enter}", { block: "empty", placement: "start" }); + + expect(editor.getBlock("empty")!.props).toMatchObject({ + level: 2, + isToggleable: false, + }); + expect(shape()).toBe('paragraph"Before", heading""[paragraph"One"]'); + }); + } + + it("turns a nested toggle list item into a paragraph, which stays nested", async () => { + mount([ + { + id: "parent", + type: "paragraph", + content: "Parent", + children: [{ id: "empty", type: "toggleListItem" }], + }, + ]); + + await press("{Enter}", { block: "empty", placement: "start" }); + + expect(editor.getBlock("empty")!.type).toBe("paragraph"); + expect(editor.getParentBlock("empty")!.id).toBe("parent"); + }); + + it("turns a toggle heading into a regular heading", async () => { mount([ { id: "t", type: "paragraph", content: "Before" }, { id: "empty", type: "heading", props: { level: 2, isToggleable: true } }, @@ -591,6 +722,77 @@ describe("Enter in an empty toggle title", () => { }); }); +// As in Notion, Backspace at the start of a non-empty toggle heading turns it +// into a regular heading. The text and (unlike Notion) the children stay. +describe("Backspace at the start of a non-empty toggle title", () => { + it("turns a toggle heading into a regular heading", async () => { + mount([ + { + id: "t", + type: "heading", + props: { level: 2, isToggleable: true }, + content: "Title", + children: [{ id: "c1", type: "paragraph", content: "One" }], + }, + ]); + + await press("{Backspace}", { block: "t", placement: "start" }); + + const block = editor.getBlock("t")!; + expect(block.type).toBe("heading"); + expect(block.props).toMatchObject({ level: 2, isToggleable: false }); + expect(shape([block])).toBe('heading"Title"[paragraph"One"]'); + }); + + // Not compared with Notion: current behaviour, as for the other lists. + it("turns a toggle list item into a paragraph", async () => { + mount([ + { + id: "t", + type: "toggleListItem", + content: "Title", + children: [{ id: "c1", type: "paragraph", content: "One" }], + }, + ]); + + await press("{Backspace}", { block: "t", placement: "start" }); + + expect(shape([editor.getBlock("t")!])).toBe( + 'paragraph"Title"[paragraph"One"]', + ); + }); +}); + +// As in Notion, Enter at the start of a non-empty toggle list item inserts an +// empty toggle list item above it. The item keeps its text and children, open +// or closed, and the caret stays at its start. +describe("Enter at the start of a non-empty toggle list item", () => { + for (const state of ["closed", "open"] as const) { + it(`inserts an empty toggle list item above it (${state})`, async () => { + mount([ + { + id: "t", + type: "toggleListItem", + content: "Title", + children: [{ id: "c1", type: "paragraph", content: "One" }], + }, + ]); + if (state === "open") { + await userEvent.click(toggleButton("t")!); + } + + await press("{Enter}", { block: "t", placement: "start" }); + + expect(shape()).toBe( + 'toggleListItem"", toggleListItem"Title"[paragraph"One"]', + ); + const item = editor.document[1]; + expect(editor.getTextCursorPosition().block.id).toBe(item.id); + expect(isOpen(item.id)).toBe(state === "open"); + }); + } +}); + // BLO-959: turning a toggle heading into a regular heading must remove the // toggle behaviour. Each way of turning a block into a heading is covered. // Unlike Notion, which moves the children out (its headings can't have @@ -631,7 +833,7 @@ describe("toggle heading turned into a regular heading (BLO-959)", () => { }); // Not compared with Notion: its Cmd-Option-2 could not be automated there. - it.fails("with the heading keyboard shortcut", async () => { + it("with the heading keyboard shortcut", async () => { mount(toggleHeading()); await press(`{${MOD}>}{Alt>}2{/Alt}{/${MOD}}`, { @@ -642,14 +844,27 @@ describe("toggle heading turned into a regular heading (BLO-959)", () => { expectRegularHeading(); }); - // In Notion, the slash menu's "Heading 2" in an empty toggle heading keeps - // the toggle heading and inserts a regular heading after it. - it.fails("with the slash menu's heading item, which inserts a new heading instead", () => { + it("with the markdown shortcut", async () => { + mount(toggleHeading()); + + await press("## ", { block: "t", placement: "start" }); + + expectRegularHeading(); + expect(editor.getBlock("t")!.content).toEqual([ + { type: "text", text: "Title", styles: {} }, + ]); + }); + + // The slash menu updates an empty block in place, as the block type menu + // does. Notion differs here: its "Heading 2" in an empty toggle heading + // keeps the toggle heading and inserts a regular heading after it. + it("with the slash menu's heading item in an empty toggle heading", () => { mount([ { id: "t", type: "heading", props: { level: 1, isToggleable: true }, + children: [{ id: "c1", type: "paragraph", content: "One" }], }, ]); editor.setTextCursorPosition("t", "end"); @@ -658,11 +873,7 @@ describe("toggle heading turned into a regular heading (BLO-959)", () => { .find((item) => item.key === "heading_2")! .onItemClick(); - const [toggleHeading, inserted] = editor.document; - expect(toggleHeading.id).toBe("t"); - expect(toggleHeading.props).toMatchObject({ level: 1, isToggleable: true }); - expect(inserted.type).toBe("heading"); - expect(inserted.props).toMatchObject({ level: 2, isToggleable: false }); - expect(editor.getTextCursorPosition().block.id).toBe(inserted.id); + expectRegularHeading(); + expect(editor.getTextCursorPosition().block.id).toBe("t"); }); }); diff --git a/packages/core/src/blocks/index.ts b/packages/core/src/blocks/index.ts index c8684b316f..2ba1252acc 100644 --- a/packages/core/src/blocks/index.ts +++ b/packages/core/src/blocks/index.ts @@ -19,7 +19,6 @@ export { EMPTY_CELL_HEIGHT, EMPTY_CELL_WIDTH } from "./Table/TableExtension.js"; export * from "./Code/helpers/parse/parsePreCode.js"; export * from "./Code/helpers/render/createCodeBlock.js"; export * from "./Code/helpers/toExternalHTML/createPreCode.js"; -export * from "./ToggleWrapper/createToggleWrapper.js"; export * from "./ToggleWrapper/createToggleFrame.js"; export * from "./PageBreak/getPageBreakSlashMenuItems.js"; diff --git a/packages/core/src/blocks/utils/listItemEnterHandler.ts b/packages/core/src/blocks/utils/listItemEnterHandler.ts deleted file mode 100644 index 6008c1a023..0000000000 --- a/packages/core/src/blocks/utils/listItemEnterHandler.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { splitBlockTr } from "../../api/blockManipulation/commands/splitBlock/splitBlock.js"; -import { updateBlockTr } from "../../api/blockManipulation/commands/updateBlock/updateBlock.js"; -import { getBlockInfoFromSelection } from "../../api/getBlockInfoFromPos.js"; -import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; - -export const handleEnter = ( - editor: BlockNoteEditor, - listItemType: string, -) => { - const { blockInfo, selectionEmpty } = editor.transact((tr) => { - return { - blockInfo: getBlockInfoFromSelection(tr), - selectionEmpty: tr.selection.anchor === tr.selection.head, - }; - }); - - if (!blockInfo.hasContent) { - return false; - } - const { block: blockContainer, content } = blockInfo; - - if (!(content.node.type.name === listItemType) || !selectionEmpty) { - return false; - } - - if (blockInfo.isContentEmpty) { - editor.transact((tr) => { - updateBlockTr(tr, blockContainer.beforePos, { - type: "paragraph", - props: {}, - }); - }); - return true; - } else if (content.node.childCount > 0) { - return editor.transact((tr) => { - tr.deleteSelection(); - tr.scrollIntoView(); - return splitBlockTr(tr, tr.selection.from, true); - }); - } - - return false; -}; diff --git a/packages/core/src/extensions/SourceBlockWithPreview/SourceBlockWithPreview.ts b/packages/core/src/extensions/SourceBlockWithPreview/SourceBlockWithPreview.ts index c9e8648861..845d41b99f 100644 --- a/packages/core/src/extensions/SourceBlockWithPreview/SourceBlockWithPreview.ts +++ b/packages/core/src/extensions/SourceBlockWithPreview/SourceBlockWithPreview.ts @@ -57,7 +57,8 @@ export const SourceBlockWithPreviewExtension = createExtension( key: "sourceBlockWithPreview", store, keyboardShortcuts: { - // Toggles the popup. This may be overridden by `hardBreakShortcut`. + // Toggles the popup, unless Enter inserts line breaks in the block + // (`keyboard.enter: "line-break"`). Enter: ({ editor }) => { const { block } = editor.getTextCursorPosition(); if (!blockHasPreview(block)) { @@ -66,8 +67,8 @@ export const SourceBlockWithPreviewExtension = createExtension( if ( store.state.popupOpen === block.id && - editor.schema.blockSpecs[block.type]?.implementation?.meta - ?.hardBreakShortcut === "enter" + editor.schema.blockSpecs[block.type].implementation.keyboard(block) + .enter === "line-break" ) { const view = editor.prosemirrorView!; view.dispatch(view.state.tr.insertText("\n")); diff --git a/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts b/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts index 756c2c1c36..6c0682f012 100644 --- a/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts +++ b/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts @@ -92,6 +92,21 @@ 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, @@ -107,7 +122,7 @@ export function getDefaultSlashMenuItems< onItemClick: () => { insertOrUpdateBlockForSlashMenu(editor, { type: "heading", - props: { level: level }, + props: regularHeadingProps(editor, level), }); }, badge: formatKeyboardShortcut(`Mod-Alt-${level}`), @@ -361,7 +376,7 @@ export function getDefaultSlashMenuItems< onItemClick: () => { insertOrUpdateBlockForSlashMenu(editor, { type: "heading", - props: { level: level }, + props: regularHeadingProps(editor, level), }); }, badge: formatKeyboardShortcut(`Mod-Alt-${level}`), diff --git a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.test.ts b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.test.ts index 5e4723c847..670766f94e 100644 --- a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.test.ts +++ b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.test.ts @@ -47,9 +47,51 @@ const createHardBreakTestBlockSpec = < }, )(); +// The same blocks, configured with the `keyboard` settings that replace the +// deprecated `meta.hardBreakShortcut`. +const createKeyboardTestBlockSpec = < + const T extends string, + const C extends "inline" | "plain", +>( + type: T, + keyboard: { + enter?: "split" | "into-children" | "line-break"; + shiftEnter?: "line-break" | "same-as-enter"; + }, + content: C = "inline" as C, +) => + createBlockSpec( + { + type, + propSchema: {}, + content, + }, + { + keyboard, + render: () => { + const dom = document.createElement("p"); + return { + dom, + contentDOM: dom, + }; + }, + }, + )(); + const schema = BlockNoteSchema.create({ blockSpecs: { ...defaultBlockSpecs, + keyboardEnter: createKeyboardTestBlockSpec("keyboardEnter", { + enter: "line-break", + }), + keyboardNone: createKeyboardTestBlockSpec("keyboardNone", { + shiftEnter: "same-as-enter", + }), + keyboardEnterPlain: createKeyboardTestBlockSpec( + "keyboardEnterPlain", + { enter: "line-break" }, + "plain", + ), hardBreakEnter: createHardBreakTestBlockSpec("hardBreakEnter", "enter"), hardBreakNone: createHardBreakTestBlockSpec("hardBreakNone", "none"), // "plain" content (`text*`) can't hold a `hardBreak` node, so these blocks @@ -67,7 +109,10 @@ function createEditor( | "paragraph" | "hardBreakEnter" | "hardBreakNone" - | "hardBreakEnterPlain", + | "hardBreakEnterPlain" + | "keyboardEnter" + | "keyboardNone" + | "keyboardEnterPlain", ) { const editor = BlockNoteEditor.create({ schema, @@ -93,6 +138,22 @@ function pressKeys(editor: BlockNoteEditor, keys: string) { editor._tiptapEditor.commands.keyboardShortcut(keys); } +/** + * Dispatches a keydown event straight to the view's handlers. Unlike + * `pressKeys`, whose command loses the selection and stored marks the handler + * sets, this leaves the state as a real key press does. Use it for tests that + * check the caret or the styles of what is typed next. + */ +function keyDown( + editor: BlockNoteEditor, + init: KeyboardEventInit, +) { + const view = editor._tiptapEditor.view; + view.someProp("handleKeyDown", (handler) => + handler(view, new KeyboardEvent("keydown", init)), + ); +} + function countHardBreaks(editor: BlockNoteEditor) { let count = 0; editor._tiptapEditor.state.doc.descendants((node) => { @@ -255,6 +316,109 @@ describe("KeyboardShortcutsExtension Backspace", () => { `); editor._tiptapEditor.destroy(); }); + // The block above isn't rich text, but its last child is: the text joins + // that child, as after any block with children. + it("merges into the last child of a block above whose own content isn't inline", () => { + const editor = createEditorWithBlocks( + [ + { + id: "code", + type: "codeBlock", + content: "x", + children: [{ id: "child", type: "paragraph", content: "Child" }], + }, + { id: "after", type: "paragraph", content: "After" }, + ], + { id: "after", placement: "start" }, + ); + + pressKeys(editor, "Backspace"); + + expect(outline(editor.document)).toMatchInlineSnapshot(` + [ + { + "children": [ + { + "text": "ChildAfter", + "type": "paragraph", + }, + ], + "text": "x", + "type": "codeBlock", + }, + ] + `); + editor._tiptapEditor.destroy(); + }); + + it("does not merge rich text into a code block above", () => { + const editor = createEditorWithBlocks( + [ + { id: "code", type: "codeBlock", content: "x" }, + { id: "after", type: "paragraph", content: "After" }, + ], + { id: "after", placement: "start" }, + ); + + pressKeys(editor, "Backspace"); + + expect(outline(editor.document)).toMatchInlineSnapshot(` + [ + { + "text": "x", + "type": "codeBlock", + }, + { + "text": "After", + "type": "paragraph", + }, + ] + `); + editor._tiptapEditor.destroy(); + }); + + // #2566: the caret used to jump to the end of the top-level block above, + // instead of the last block nested under it. + it("in an empty block moves the caret to the end of the deepest last block above", () => { + const editor = createEditorWithBlocks( + [ + { + id: "list", + type: "bulletListItem", + content: "One", + children: [ + { id: "nested", type: "bulletListItem", content: "Two" }, + { id: "last", type: "bulletListItem" }, + ], + }, + { id: "empty", type: "paragraph" }, + ], + { id: "empty", placement: "start" }, + ); + + keyDown(editor, { key: "Backspace", keyCode: 8 }); + + expect(editor.getBlock("empty")).toBeUndefined(); + expect(editor.getTextCursorPosition().block.id).toBe("last"); + editor._tiptapEditor.destroy(); + }); + + // #605: Backspace in an empty block below an image used to delete the image + // too. + it("in an empty block below a block without content deletes only the empty block", () => { + const editor = createEditorWithBlocks( + [ + { id: "image", type: "image" }, + { id: "empty", type: "paragraph" }, + ], + { id: "empty", placement: "start" }, + ); + + pressKeys(editor, "Backspace"); + + expect(editor.document.map((block) => block.id)).toEqual(["image"]); + editor._tiptapEditor.destroy(); + }); }); describe("KeyboardShortcutsExtension Delete", () => { @@ -471,6 +635,33 @@ describe("KeyboardShortcutsExtension hardBreakShortcut", () => { editor._tiptapEditor.destroy(); }); + // #1672: text typed after the line break used to lose the styles of the + // text before it. + it("keeps the styles of the text before the line break", () => { + const editor = createEditorWithBlocks( + [ + { + id: "p", + type: "paragraph", + content: [ + { type: "text", text: "Red", styles: { textColor: "red" } }, + ], + }, + ], + { id: "p", placement: "end" }, + ); + + keyDown(editor, { key: "Enter", keyCode: 13, shiftKey: true }); + // Typing: `insertText` takes the stored marks, as the browser input does. + const view = editor._tiptapEditor.view; + view.dispatch(view.state.tr.insertText("x")); + + expect(editor.getBlock("p")!.content).toEqual([ + { type: "text", text: "Red\nx", styles: { textColor: "red" } }, + ]); + editor._tiptapEditor.destroy(); + }); + it("splits the block on Enter by default", () => { const editor = createEditor("paragraph"); @@ -481,76 +672,94 @@ describe("KeyboardShortcutsExtension hardBreakShortcut", () => { editor._tiptapEditor.destroy(); }); +}); - it('inserts a hard break on Enter when hardBreakShortcut is "enter"', () => { - const editor = createEditor("hardBreakEnter"); +describe.each([ + { + setting: "meta.hardBreakShortcut (deprecated)", + enter: "hardBreakEnter", + none: "hardBreakNone", + plain: "hardBreakEnterPlain", + }, + { + setting: "keyboard", + enter: "keyboardEnter", + none: "keyboardNone", + plain: "keyboardEnterPlain", + }, +] as const)( + "hard breaks configured with $setting", + ({ enter, none, plain }) => { + it('inserts a hard break on Enter when hardBreakShortcut is "enter"', () => { + const editor = createEditor(enter); - pressKeys(editor, "Enter"); + pressKeys(editor, "Enter"); - expect(countHardBreaks(editor)).toBe(1); - expect(editor.document.length).toBe(1); + expect(countHardBreaks(editor)).toBe(1); + expect(editor.document.length).toBe(1); - editor._tiptapEditor.destroy(); - }); + editor._tiptapEditor.destroy(); + }); - it('inserts a hard break on Shift-Enter when hardBreakShortcut is "enter"', () => { - const editor = createEditor("hardBreakEnter"); + it('inserts a hard break on Shift-Enter when hardBreakShortcut is "enter"', () => { + const editor = createEditor(enter); - pressKeys(editor, "Shift-Enter"); + pressKeys(editor, "Shift-Enter"); - expect(countHardBreaks(editor)).toBe(1); - expect(editor.document.length).toBe(1); + expect(countHardBreaks(editor)).toBe(1); + expect(editor.document.length).toBe(1); - editor._tiptapEditor.destroy(); - }); + editor._tiptapEditor.destroy(); + }); - it('does not insert a hard break on Shift-Enter when hardBreakShortcut is "none"', () => { - const editor = createEditor("hardBreakNone"); + it('does not insert a hard break on Shift-Enter when hardBreakShortcut is "none"', () => { + const editor = createEditor(none); - pressKeys(editor, "Shift-Enter"); + pressKeys(editor, "Shift-Enter"); - expect(countHardBreaks(editor)).toBe(0); + expect(countHardBreaks(editor)).toBe(0); - editor._tiptapEditor.destroy(); - }); + editor._tiptapEditor.destroy(); + }); - it('splits the block on Enter when hardBreakShortcut is "none"', () => { - const editor = createEditor("hardBreakNone"); + it('splits the block on Enter when hardBreakShortcut is "none"', () => { + const editor = createEditor(none); - pressKeys(editor, "Enter"); + pressKeys(editor, "Enter"); - expect(countHardBreaks(editor)).toBe(0); - expect(editor.document.length).toBe(2); + expect(countHardBreaks(editor)).toBe(0); + expect(editor.document.length).toBe(2); - editor._tiptapEditor.destroy(); - }); + editor._tiptapEditor.destroy(); + }); - it('inserts a newline character on Enter when content is "plain"', () => { - const editor = createEditor("hardBreakEnterPlain"); + it('inserts a newline character on Enter when content is "plain"', () => { + const editor = createEditor(plain); - pressKeys(editor, "Enter"); + pressKeys(editor, "Enter"); - // A "plain" block can't hold a `hardBreak` node, so no node is inserted and - // the block is not split - a literal newline is added to its text instead. - expect(countHardBreaks(editor)).toBe(0); - expect(editor.document.length).toBe(1); - expect(getTextContent(editor)).toBe("Hello world\n"); + // A "plain" block can't hold a `hardBreak` node, so no node is inserted and + // the block is not split - a literal newline is added to its text instead. + expect(countHardBreaks(editor)).toBe(0); + expect(editor.document.length).toBe(1); + expect(getTextContent(editor)).toBe("Hello world\n"); - editor._tiptapEditor.destroy(); - }); + editor._tiptapEditor.destroy(); + }); - it('inserts a newline character on Shift-Enter when content is "plain"', () => { - const editor = createEditor("hardBreakEnterPlain"); + it('inserts a newline character on Shift-Enter when content is "plain"', () => { + const editor = createEditor("hardBreakEnterPlain"); - pressKeys(editor, "Shift-Enter"); + pressKeys(editor, "Shift-Enter"); - expect(countHardBreaks(editor)).toBe(0); - expect(editor.document.length).toBe(1); - expect(getTextContent(editor)).toBe("Hello world\n"); + expect(countHardBreaks(editor)).toBe(0); + expect(editor.document.length).toBe(1); + expect(getTextContent(editor)).toBe("Hello world\n"); - editor._tiptapEditor.destroy(); - }); -}); + editor._tiptapEditor.destroy(); + }); + }, +); describe("Delete preserves the caret before appended text", () => { function paragraph(id: string, children: PartialBlock[] = []): PartialBlock { diff --git a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts index 56f2c043bd..e3a698d3ad 100644 --- a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts +++ b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts @@ -1,5 +1,5 @@ import { type ChainedCommands, Extension } from "@tiptap/core"; -import { Fragment } from "prosemirror-model"; +import { Fragment, type Node } from "prosemirror-model"; import { TextSelection, Transaction } from "prosemirror-state"; import { @@ -12,6 +12,7 @@ import { unnestBlock, } from "../../../api/blockManipulation/commands/nestBlock/nestBlock.js"; import { fixContainersById } from "../../../api/blockManipulation/containers/fixContainer.js"; +import { nodeToBlock } from "../../../api/nodeConversions/nodeToBlock.js"; import { isContainerNode } from "../../../schema/blocks/children.js"; import { splitBlockCommand } from "../../../api/blockManipulation/commands/splitBlock/splitBlock.js"; import { updateBlockCommand } from "../../../api/blockManipulation/commands/updateBlock/updateBlock.js"; @@ -61,6 +62,8 @@ function deleteBlockAndAppendContent( current: Extract, next: Extract, remove: Pick = next.block, + // Whether `current` is its children's title (see `getMergeContent`). + currentIsTitle = false, ) { return chain .insertContentAt( @@ -68,7 +71,10 @@ function deleteBlockAndAppendContent( next.children?.node.content || Fragment.empty, ) .deleteRange({ from: remove.beforePos, to: remove.afterPos }) - .insertContentAt(current.contentEnd, getMergeContent(current, next) ?? null) + .insertContentAt( + current.contentEnd, + getMergeContent(current, next, currentIsTitle) ?? null, + ) .setTextSelection(current.contentEnd) .scrollIntoView() .run(); @@ -83,6 +89,22 @@ export const KeyboardShortcutsExtension = Extension.create<{ // TODO: The shortcuts need a refactor. Do we want to use a command priority // design as there is now, or clump the logic into a single function? addKeyboardShortcuts() { + const bnEditor = this.options.editor; + // The `keyboard` settings of the block that `node` holds. + function keyboardOf(node: Node) { + const block = nodeToBlock(node, bnEditor.prosemirrorState.doc); + return bnEditor.schema.blockSpecs[block.type].implementation.keyboard( + block, + ); + } + function canOutdentFrom(parent: Node) { + return keyboardOf(parent).childrenCanOutdent; + } + // A block whose Enter goes into its children is their title: its first + // child merges into it on Backspace. + // TODO: remove with #3124, which lets every first child merge into its + // parent. + const isTitle = (node: Node) => keyboardOf(node).enter === "into-children"; // handleBackspace is partially adapted from https://github.com/ueberdosis/tiptap/blob/ed56337470efb4fd277128ab7ef792b37cfae992/packages/core/src/extensions/keymap.ts const handleBackspace = () => this.editor.commands.first(({ chain, commands }) => [ @@ -90,7 +112,9 @@ export const KeyboardShortcutsExtension = Extension.create<{ () => commands.deleteSelection(), // Undoes an input rule if one was triggered in the last editor state change. () => commands.undoInputRule(), - // Reverts block content type to a paragraph if the selection is at the start of the block. + // Resets the block (`keyboard.resetsTo`, a paragraph by default) if the + // selection is at the start of the block and the block isn't already + // in that form. () => commands.command(({ state }) => { const blockInfo = getBlockInfoFromSelection(state); @@ -100,19 +124,24 @@ export const KeyboardShortcutsExtension = Extension.create<{ const selectionAtBlockStart = state.selection.from === blockInfo.contentStart; - const isParagraph = - blockInfo.content.node.type.name === "paragraph"; - - if (selectionAtBlockStart && !isParagraph) { - return commands.command( - updateBlockCommand(blockInfo.block.beforePos, { - type: "paragraph", - props: {}, - }), + if (!selectionAtBlockStart) { + return false; + } + + const resetsTo = keyboardOf(blockInfo.block.node).resetsTo; + const content = blockInfo.content.node; + const alreadyReset = + content.type.name === resetsTo.type && + Object.entries(resetsTo.props ?? {}).every( + ([prop, value]) => content.attrs[prop] === value, ); + if (alreadyReset) { + return false; } - return false; + return commands.command( + updateBlockCommand(blockInfo.block.beforePos, resetsTo as any), + ); }), // Removes a level of nesting if the block is indented if the selection is at the start of the block. () => @@ -126,10 +155,25 @@ export const KeyboardShortcutsExtension = Extension.create<{ state.selection.from === blockInfo.contentStart; if (selectionAtBlockStart) { + // A title's first child merges into the title instead (further + // down). TODO: remove with #3124. + const $block = state.doc.resolve(blockInfo.block.beforePos); + const parent = getParentBlockInfo( + state.doc, + blockInfo.block.beforePos, + ); + if ( + $block.index() === 0 && + parent && + isTitle(parent.block.node) + ) { + return false; + } return liftItem( tr, tr.doc.type.schema.nodes["blockContainer"], tr.doc.type.schema.nodes["blockGroup"], + canOutdentFrom, ); } @@ -149,15 +193,22 @@ export const KeyboardShortcutsExtension = Extension.create<{ state.doc, blockInfo.block.beforePos, ); - // A preceding container or owned body takes the move branch below. - // With no sibling, mergeBlocksCommand checks for an owning title. - if ( - prevSibling && - (!prevSibling.hasContent || - prevSibling.contentKind !== "inline" || - (prevSibling.children && prevSibling.hasOwnedChildren)) - ) { - return false; + // A preceding container takes the move branch below. A preceding + // block with content merges, into its last descendant when it has + // children, which must hold inline content: a code block's own + // text never takes the merge, but its last child can. With no + // sibling, mergeBlocksCommand checks for an owning title. + if (prevSibling) { + if (!prevSibling.hasContent) { + return false; + } + const mergeTarget = getLastDescendantBlockInfo(prevSibling); + if ( + !mergeTarget.hasContent || + mergeTarget.contentKind !== "inline" + ) { + return false; + } } const selectionAtBlockStart = @@ -168,7 +219,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ if (selectionAtBlockStart && selectionEmpty) { return chain() - .command(mergeBlocksCommand(posBetweenBlocks)) + .command(mergeBlocksCommand(posBetweenBlocks, isTitle)) .scrollIntoView() .run(); } @@ -191,10 +242,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ let target = getPrevBlockInfo(tr.doc, blockInfo.block.beforePos); let insertionPos: number | undefined; if (target) { - if ( - target.hasContent && - !(target.children && target.hasOwnedChildren) - ) { + if (target.hasContent) { return false; } } else { @@ -392,6 +440,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ children.node.childCount === 1 ? children : firstChildBlockInfo.block, + isTitle(blockInfo.block.node), ); } @@ -424,7 +473,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ if (selectionAtBlockEnd && selectionEmpty) { return chain() - .command(mergeBlocksCommand(posBetweenBlocks)) + .command(mergeBlocksCommand(posBetweenBlocks, isTitle)) .scrollIntoView() .run(); } @@ -516,6 +565,8 @@ export const KeyboardShortcutsExtension = Extension.create<{ chain(), blockInfo, nextBlockInfo, + nextBlockInfo.block, + isTitle(blockInfo.block.node), ); } @@ -614,6 +665,30 @@ export const KeyboardShortcutsExtension = Extension.create<{ const handleEnter = (withShift = false) => { return this.editor.commands.first(({ commands, tr }) => [ + // Resets an empty block (`keyboard.resetsTo`) if it resets on Enter + // (`keyboard.emptyEnterResets`), e.g. an empty list item turns into a + // paragraph. Its children stay. + () => + commands.command(({ state }) => { + const blockInfo = getBlockInfoFromSelection(state); + if ( + !blockInfo.hasContent || + !state.selection.empty || + !blockInfo.isContentEmpty + ) { + return false; + } + const keyboard = keyboardOf(blockInfo.block.node); + if (!keyboard.emptyEnterResets) { + return false; + } + return commands.command( + updateBlockCommand( + blockInfo.block.beforePos, + keyboard.resetsTo as any, + ), + ); + }), // Removes a level of nesting if the block is empty & indented, while the selection is also empty & at the start // of the block. () => @@ -639,16 +714,31 @@ export const KeyboardShortcutsExtension = Extension.create<{ blockEmpty && blockIndented ) { + // Only outdents where the parent says so + // (`keyboard.emptyChildEnter: "outdent"`); otherwise the block + // leaves at the end, or a new child is added, further down. + const parent = getParentBlockInfo( + state.doc, + blockContainer.beforePos, + ); + if ( + parent && + keyboardOf(parent.block.node).emptyChildEnter !== "outdent" + ) { + return false; + } return liftItem( tr, tr.doc.type.schema.nodes["blockContainer"], tr.doc.type.schema.nodes["blockGroup"], + canOutdentFrom, ); } return false; }), - // Creates a hard break if block is configured to do so. + // Creates a hard break if the block is configured to do so + // (`keyboard.enter` / `keyboard.shiftEnter`). () => commands.command(({ state }) => { const blockInfo = getBlockInfoFromSelection(state); @@ -656,21 +746,12 @@ export const KeyboardShortcutsExtension = Extension.create<{ const blockSpec = this.options.editor.schema.blockSpecs[blockInfo.blockNoteType]; - const blockHardBreakShortcut = - blockSpec?.implementation?.meta?.hardBreakShortcut ?? - "shift+enter"; - - if (blockHardBreakShortcut === "none") { - return false; - } + const keyboard = keyboardOf(blockInfo.block.node); if ( - // If shortcut is not configured, or is configured as "shift+enter", - // create a hard break for shift+enter, but not for enter. - (blockHardBreakShortcut === "shift+enter" && withShift) || - // If shortcut is configured as "enter", create a hard break for - // both enter and shift+enter. - blockHardBreakShortcut === "enter" + // Enter as a line break makes Shift-Enter one too. + keyboard.enter === "line-break" || + (withShift && keyboard.shiftEnter === "line-break") ) { // "plain" blocks (e.g. code/math/diagram source) hold text only // (their content is `text*`), which can't contain a `hardBreak` @@ -700,9 +781,10 @@ export const KeyboardShortcutsExtension = Extension.create<{ return false; }), - // If the block is empty and the last child of a container or an - // owned-children body, moves the block out (double Enter exits the - // container). The block lands at the nearest enclosing position that + // If the block is empty and the last child of a block whose empty + // children exit at the end (`keyboard.emptyChildEnter: + // "exit-at-end"`, the default for containers), moves the block out + // (double Enter exits the container). The block lands at the nearest enclosing position that // accepts it. E.g. out of a column it skips the columnList, which // holds only columns, and lands below it. Without this, Enter only // ever creates new blocks within the container, so the cursor could @@ -730,7 +812,12 @@ export const KeyboardShortcutsExtension = Extension.create<{ } const owner = getParentBlockInfo(tr.doc, blockInfo.block.beforePos); - if (!owner || !owner.hasOwnedChildren) { + if (!owner) { + return false; + } + if ( + keyboardOf(owner.block.node).emptyChildEnter !== "exit-at-end" + ) { return false; } // The first block of a body stays put: it is where the body @@ -777,7 +864,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ selectionAtBlockStart && selectionEmpty && blockEmpty && - !blockInfo.hasOwnedChildren + keyboardOf(blockInfo.block.node).enter !== "into-children" ) { const newBlockInsertionPos = blockContainer.afterPos; const newBlockContentPos = newBlockInsertionPos + 2; @@ -818,10 +905,43 @@ export const KeyboardShortcutsExtension = Extension.create<{ return false; }), - // Enter in a titled block's own content (a callout's title) starts its - // body rather than splitting the block in two: whatever follows the - // cursor becomes the body's first block, and the body the callout - // already had stays where it is. + // Enter at the start of non-empty content inserts an empty block above + // it: the same type when the block's splits keep their type (lists), + // a paragraph otherwise, with default props. The block itself, with + // its id, props (e.g. a checklist item's checked state) and children, + // stays where it is (#550). + () => + commands.command(({ state, tr, dispatch }) => { + const blockInfo = getBlockInfoFromSelection(state); + if ( + !blockInfo.hasContent || + blockInfo.contentKind === "table" || + !state.selection.empty || + blockInfo.isContentEmpty || + state.selection.from !== blockInfo.contentStart + ) { + return false; + } + + if (dispatch) { + const contentType = keyboardOf(blockInfo.block.node) + .splitKeepsType + ? blockInfo.content.node.type + : state.schema.nodes["paragraph"]; + const newBlock = state.schema.nodes["blockContainer"].create( + undefined, + contentType.create(), + ); + tr.insert(blockInfo.block.beforePos, newBlock).scrollIntoView(); + } + + return true; + }), + // Enter in the content of a block whose Enter goes into its children + // (`keyboard.enter: "into-children"`, e.g. an open toggle) starts its + // children rather than splitting the block in two: whatever follows + // the cursor becomes the first child, and the existing children stay + // where they are. () => commands.command(({ state, tr, dispatch }) => { const blockInfo = getBlockInfoFromSelection(state); @@ -829,7 +949,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ return false; } - if (!blockInfo.hasOwnedChildren) { + if (keyboardOf(blockInfo.block.node).enter !== "into-children") { return false; } if (!state.selection.empty) { @@ -895,12 +1015,15 @@ export const KeyboardShortcutsExtension = Extension.create<{ const blockEmpty = blockInfo.isContentEmpty; if (!blockEmpty) { + const keepType = + selectionAtBlockStart || + keyboardOf(blockInfo.block.node).splitKeepsType; chain() .deleteSelection() .command( splitBlockCommand( state.selection.from, - selectionAtBlockStart, + keepType, selectionAtBlockStart, ), ) diff --git a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/blockIdentity.browser.test.ts b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/blockIdentity.browser.test.ts new file mode 100644 index 0000000000..8a1f5d433b --- /dev/null +++ b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/blockIdentity.browser.test.ts @@ -0,0 +1,108 @@ +import { afterEach, describe, expect, it } from "vite-plus/test"; +import { userEvent } from "vite-plus/test/browser"; + +import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js"; +import type { PartialBlock } from "../../../blocks/defaultBlocks.js"; + +// Enter and Backspace at the start of a block keep the block's identity: its +// id, and props such as a checklist item's checked state, stay with its text +// (#550). + +let editor: BlockNoteEditor; +let root: HTMLElement; + +function mount(content: PartialBlock[]) { + root = document.createElement("div"); + document.body.appendChild(root); + editor = BlockNoteEditor.create({ initialContent: content }); + editor.mount(root); +} + +afterEach(() => { + editor._tiptapEditor.destroy(); + root.remove(); +}); + +async function press(key: string, at: { block: string }) { + editor.setTextCursorPosition(at.block, "start"); + editor.focus(); + await userEvent.keyboard(`{${key}}`); +} + +describe("Enter at the start of a non-empty block", () => { + it("inserts an empty paragraph above a paragraph, which keeps its id", async () => { + mount([{ id: "p", type: "paragraph", content: "Text" }]); + + await press("Enter", { block: "p" }); + + const [inserted, block] = editor.document; + expect(inserted.type).toBe("paragraph"); + expect(inserted.content).toEqual([]); + expect(block.id).toBe("p"); + expect(editor.getTextCursorPosition().block.id).toBe("p"); + }); + + it("keeps a checklist item's checked state with its text", async () => { + mount([ + { + id: "c", + type: "checkListItem", + props: { checked: true }, + content: "Done", + }, + ]); + + await press("Enter", { block: "c" }); + + const [inserted, block] = editor.document; + expect(inserted.type).toBe("checkListItem"); + expect(inserted.props).toMatchObject({ checked: false }); + expect(block.id).toBe("c"); + expect(block.props).toMatchObject({ checked: true }); + }); + + it("inserts an empty paragraph above a heading, which keeps its id and level", async () => { + mount([ + { id: "h", type: "heading", props: { level: 3 }, content: "Heading" }, + ]); + + await press("Enter", { block: "h" }); + + const [inserted, block] = editor.document; + expect(inserted.type).toBe("paragraph"); + expect(block.id).toBe("h"); + expect(block.props).toMatchObject({ level: 3 }); + }); + + it("keeps the block's children with it", async () => { + mount([ + { + id: "p", + type: "paragraph", + content: "Parent", + children: [{ id: "c1", type: "paragraph", content: "Child" }], + }, + ]); + + await press("Enter", { block: "p" }); + + const [inserted, block] = editor.document; + expect(inserted.children).toHaveLength(0); + expect(block.id).toBe("p"); + expect(block.children.map((child) => child.id)).toEqual(["c1"]); + }); +}); + +describe("Backspace at the start of a block after an empty block", () => { + it("removes the empty block, and the block keeps its id", async () => { + mount([ + { id: "empty", type: "paragraph" }, + { id: "p", type: "paragraph", content: "Text" }, + ]); + + await press("Backspace", { block: "p" }); + + expect(editor.document.map((block) => block.id)).toEqual(["p"]); + expect(editor.getTextCursorPosition().block.id).toBe("p"); + }); +}); diff --git a/packages/core/src/i18n/locales/ar.ts b/packages/core/src/i18n/locales/ar.ts index 094671d920..7691db22d4 100644 --- a/packages/core/src/i18n/locales/ar.ts +++ b/packages/core/src/i18n/locales/ar.ts @@ -184,6 +184,7 @@ export const ar: Dictionary = { }, toggle_blocks: { add_block_button: "تبديل فارغ. انقر لإضافة كتلة.", + toggle_button: "توسيع أو طي", }, code_block: { add_source_button_text: "إضافة كود المصدر", diff --git a/packages/core/src/i18n/locales/de.ts b/packages/core/src/i18n/locales/de.ts index bf77a36a01..1d77415b24 100644 --- a/packages/core/src/i18n/locales/de.ts +++ b/packages/core/src/i18n/locales/de.ts @@ -220,6 +220,7 @@ export const de: Dictionary = { toggle_blocks: { add_block_button: "Leerer aufklappbarer Bereich. Klicken, um einen Block hinzuzufügen.", + toggle_button: "Aufklappen oder zuklappen", }, code_block: { add_source_button_text: "Quellcode hinzufügen", diff --git a/packages/core/src/i18n/locales/en.ts b/packages/core/src/i18n/locales/en.ts index e5386f3020..ce9973105d 100644 --- a/packages/core/src/i18n/locales/en.ts +++ b/packages/core/src/i18n/locales/en.ts @@ -199,6 +199,7 @@ export const en = { }, toggle_blocks: { add_block_button: "Empty toggle. Click to add a block.", + toggle_button: "Expand or collapse", }, code_block: { add_source_button_text: "Add source code", diff --git a/packages/core/src/i18n/locales/es.ts b/packages/core/src/i18n/locales/es.ts index 743a1be05c..db65d4bb8d 100644 --- a/packages/core/src/i18n/locales/es.ts +++ b/packages/core/src/i18n/locales/es.ts @@ -199,6 +199,7 @@ export const es: Dictionary = { }, toggle_blocks: { add_block_button: "Toggle vacío. Haz clic para añadir un bloque.", + toggle_button: "Expandir o contraer", }, code_block: { add_source_button_text: "Agregar código fuente", diff --git a/packages/core/src/i18n/locales/fa.ts b/packages/core/src/i18n/locales/fa.ts index 6b2783ab68..3c54a0d02a 100644 --- a/packages/core/src/i18n/locales/fa.ts +++ b/packages/core/src/i18n/locales/fa.ts @@ -167,6 +167,7 @@ export const fa = { }, toggle_blocks: { add_block_button: "تاشوی خالی. برای افزودن بلوک کلیک کنید.", + toggle_button: "باز یا بسته کردن", }, code_block: { add_source_button_text: "افزودن کد منبع", diff --git a/packages/core/src/i18n/locales/fr.ts b/packages/core/src/i18n/locales/fr.ts index ad605db24a..924493157f 100644 --- a/packages/core/src/i18n/locales/fr.ts +++ b/packages/core/src/i18n/locales/fr.ts @@ -245,6 +245,7 @@ export const fr: Dictionary = { }, toggle_blocks: { add_block_button: "Liste repliable vide. Cliquez pour ajouter un bloc.", + toggle_button: "Déplier ou replier", }, code_block: { add_source_button_text: "Ajouter le code source", diff --git a/packages/core/src/i18n/locales/he.ts b/packages/core/src/i18n/locales/he.ts index 4662a94202..e129154711 100644 --- a/packages/core/src/i18n/locales/he.ts +++ b/packages/core/src/i18n/locales/he.ts @@ -201,6 +201,7 @@ export const he: Dictionary = { }, toggle_blocks: { add_block_button: "מתג ריק. לחץ כדי להוסיף בלוק.", + toggle_button: "הרחבה או כיווץ", }, code_block: { add_source_button_text: "הוסף קוד מקור", diff --git a/packages/core/src/i18n/locales/hr.ts b/packages/core/src/i18n/locales/hr.ts index 03eb016eed..905c0ad394 100644 --- a/packages/core/src/i18n/locales/hr.ts +++ b/packages/core/src/i18n/locales/hr.ts @@ -212,6 +212,7 @@ export const hr: Dictionary = { }, toggle_blocks: { add_block_button: "Prazan sklopivi blok. Klikni da dodaš sadržaj.", + toggle_button: "Proširi ili sažmi", }, code_block: { add_source_button_text: "Dodaj izvorni kôd", diff --git a/packages/core/src/i18n/locales/is.ts b/packages/core/src/i18n/locales/is.ts index 913b2324b0..76300b9c9e 100644 --- a/packages/core/src/i18n/locales/is.ts +++ b/packages/core/src/i18n/locales/is.ts @@ -213,6 +213,7 @@ export const is: Dictionary = { }, toggle_blocks: { add_block_button: "Tóm fellilína. Smelltu til að bæta við blokk.", + toggle_button: "Fella út eða inn", }, code_block: { add_source_button_text: "Bæta við frumkóða", diff --git a/packages/core/src/i18n/locales/it.ts b/packages/core/src/i18n/locales/it.ts index 44be22c1bd..34c8d98cc3 100644 --- a/packages/core/src/i18n/locales/it.ts +++ b/packages/core/src/i18n/locales/it.ts @@ -221,6 +221,7 @@ export const it: Dictionary = { }, toggle_blocks: { add_block_button: "Toggle vuoto. Clicca per aggiungere un blocco.", + toggle_button: "Espandi o comprimi", }, code_block: { add_source_button_text: "Aggiungi codice sorgente", diff --git a/packages/core/src/i18n/locales/ja.ts b/packages/core/src/i18n/locales/ja.ts index ead1f2fb30..c9f536d398 100644 --- a/packages/core/src/i18n/locales/ja.ts +++ b/packages/core/src/i18n/locales/ja.ts @@ -239,6 +239,7 @@ export const ja: Dictionary = { }, toggle_blocks: { add_block_button: "空のトグルです。クリックしてブロックを追加。", + toggle_button: "展開または折りたたみ", }, code_block: { add_source_button_text: "ソースコードを追加", diff --git a/packages/core/src/i18n/locales/ko.ts b/packages/core/src/i18n/locales/ko.ts index 2981ff1c36..bc3b8bb16e 100644 --- a/packages/core/src/i18n/locales/ko.ts +++ b/packages/core/src/i18n/locales/ko.ts @@ -212,6 +212,7 @@ export const ko: Dictionary = { }, toggle_blocks: { add_block_button: "비어 있는 토글입니다. 클릭하여 블록을 추가하세요.", + toggle_button: "펼치기 또는 접기", }, code_block: { add_source_button_text: "소스 코드 추가", diff --git a/packages/core/src/i18n/locales/nl.ts b/packages/core/src/i18n/locales/nl.ts index da599e017c..ad7e66e033 100644 --- a/packages/core/src/i18n/locales/nl.ts +++ b/packages/core/src/i18n/locales/nl.ts @@ -200,6 +200,7 @@ export const nl: Dictionary = { }, toggle_blocks: { add_block_button: "Lege uitklapper. Klik om een blok toe te voegen.", + toggle_button: "Uitklappen of inklappen", }, code_block: { add_source_button_text: "Broncode toevoegen", diff --git a/packages/core/src/i18n/locales/no.ts b/packages/core/src/i18n/locales/no.ts index 72efc096ed..53f4307127 100644 --- a/packages/core/src/i18n/locales/no.ts +++ b/packages/core/src/i18n/locales/no.ts @@ -218,6 +218,7 @@ export const no: Dictionary = { }, toggle_blocks: { add_block_button: "Tomt toggle. Klikk for å legge til en blokk.", + toggle_button: "Utvid eller skjul", }, code_block: { add_source_button_text: "Legg til kildekode", diff --git a/packages/core/src/i18n/locales/pl.ts b/packages/core/src/i18n/locales/pl.ts index d00039633c..0a116027e6 100644 --- a/packages/core/src/i18n/locales/pl.ts +++ b/packages/core/src/i18n/locales/pl.ts @@ -191,6 +191,7 @@ export const pl: Dictionary = { toggle_blocks: { add_block_button: "Brak bloków do rozwinięcia. Kliknij, aby dodać pierwszego.", + toggle_button: "Rozwiń lub zwiń", }, code_block: { add_source_button_text: "Dodaj kod źródłowy", diff --git a/packages/core/src/i18n/locales/pt.ts b/packages/core/src/i18n/locales/pt.ts index fe719ce023..adaabeec3b 100644 --- a/packages/core/src/i18n/locales/pt.ts +++ b/packages/core/src/i18n/locales/pt.ts @@ -191,6 +191,7 @@ export const pt: Dictionary = { }, toggle_blocks: { add_block_button: "Toggle vazio. Clique para adicionar um bloco.", + toggle_button: "Expandir ou recolher", }, code_block: { add_source_button_text: "Adicionar código-fonte", diff --git a/packages/core/src/i18n/locales/ru.ts b/packages/core/src/i18n/locales/ru.ts index a4a7987dfc..4777f58963 100644 --- a/packages/core/src/i18n/locales/ru.ts +++ b/packages/core/src/i18n/locales/ru.ts @@ -242,6 +242,7 @@ export const ru: Dictionary = { }, toggle_blocks: { add_block_button: "Пустой переключатель. Нажмите, чтобы добавить блок.", + toggle_button: "Развернуть или свернуть", }, code_block: { add_source_button_text: "Добавить исходный код", diff --git a/packages/core/src/i18n/locales/sk.ts b/packages/core/src/i18n/locales/sk.ts index 4e73dc7eca..f5e76ba457 100644 --- a/packages/core/src/i18n/locales/sk.ts +++ b/packages/core/src/i18n/locales/sk.ts @@ -199,6 +199,7 @@ export const sk = { }, toggle_blocks: { add_block_button: "Prázdne prepínanie. Kliknite pre pridanie bloku.", + toggle_button: "Rozbaliť alebo zbaliť", }, code_block: { add_source_button_text: "Pridať zdrojový kód", diff --git a/packages/core/src/i18n/locales/uk.ts b/packages/core/src/i18n/locales/uk.ts index e9d379ac0b..2537a523c7 100644 --- a/packages/core/src/i18n/locales/uk.ts +++ b/packages/core/src/i18n/locales/uk.ts @@ -224,6 +224,7 @@ export const uk: Dictionary = { }, toggle_blocks: { add_block_button: "Порожній перемикач. Натисніть, щоб додати блок.", + toggle_button: "Розгорнути або згорнути", }, code_block: { add_source_button_text: "Додати вихідний код", diff --git a/packages/core/src/i18n/locales/uz.ts b/packages/core/src/i18n/locales/uz.ts index 13aee55a73..d7db3c3ec0 100644 --- a/packages/core/src/i18n/locales/uz.ts +++ b/packages/core/src/i18n/locales/uz.ts @@ -260,6 +260,7 @@ export const uz: Dictionary = { toggle_blocks: { add_block_button: "Bo‘sh toggle. Blok qo‘shish uchun bosing.", + toggle_button: "Yoyish yoki yig‘ish", }, code_block: { diff --git a/packages/core/src/i18n/locales/vi.ts b/packages/core/src/i18n/locales/vi.ts index 8733fbf0ba..ffa510d747 100644 --- a/packages/core/src/i18n/locales/vi.ts +++ b/packages/core/src/i18n/locales/vi.ts @@ -198,6 +198,7 @@ export const vi: Dictionary = { }, toggle_blocks: { add_block_button: "Toggle trống. Nhấp để thêm khối.", + toggle_button: "Mở rộng hoặc thu gọn", }, code_block: { add_source_button_text: "Thêm mã nguồn", diff --git a/packages/core/src/i18n/locales/zh-tw.ts b/packages/core/src/i18n/locales/zh-tw.ts index 5ac37a80c7..6520b38046 100644 --- a/packages/core/src/i18n/locales/zh-tw.ts +++ b/packages/core/src/i18n/locales/zh-tw.ts @@ -240,6 +240,7 @@ export const zhTW: Dictionary = { }, toggle_blocks: { add_block_button: "空的切換區。點擊新增區塊。", + toggle_button: "展開或收合", }, code_block: { add_source_button_text: "新增原始碼", diff --git a/packages/core/src/i18n/locales/zh.ts b/packages/core/src/i18n/locales/zh.ts index 3f4c90bb56..0047477caa 100644 --- a/packages/core/src/i18n/locales/zh.ts +++ b/packages/core/src/i18n/locales/zh.ts @@ -240,6 +240,7 @@ export const zh: Dictionary = { }, toggle_blocks: { add_block_button: "空的切换区。点击添加区块。", + toggle_button: "展开或收起", }, code_block: { add_source_button_text: "添加源代码", diff --git a/packages/core/src/schema/blocks/children.test.ts b/packages/core/src/schema/blocks/children.test.ts index 0945e577c0..cf0fed0411 100644 --- a/packages/core/src/schema/blocks/children.test.ts +++ b/packages/core/src/schema/blocks/children.test.ts @@ -35,6 +35,11 @@ describe("childrenContentExpression", () => { // `validateChildrenConfigs` never builds the content expression — it only // resolves `allow`/`min` — so an `allow` that permits nothing is caught // here, at expression build, rather than by `validate` below. + it("accepts any blocks when `children` or `allow` is left out", () => { + expect(childrenContentExpression()).toBe("blockGroupChild+"); + expect(childrenContentExpression({ min: 2 })).toBe("blockGroupChild{2,}"); + }); + it("throws for an allow array that permits nothing", () => { expect(() => childrenContentExpression({ allow: [] })).toThrow( /permits nothing/, @@ -42,55 +47,63 @@ describe("childrenContentExpression", () => { }); }); -type ContainerFixture = { - children: ChildrenConfig; - content?: "none" | "inline" | "plain"; +type BlockFixture = { + content?: "none" | "inline" | "plain" | "table"; + container?: true; + children?: ChildrenConfig; placeable?: "anywhere" | "namedOnly"; }; -function specsWith(containers: Record) { +function specsWith(blocks: Record) { return { paragraph: { config: { content: "inline" as const } }, heading: { config: { content: "inline" as const } }, ...Object.fromEntries( - Object.entries(containers).map( - ([type, { children, content, placeable }]) => [ - type, - { - config: { - content: content ?? ("none" as const), - children, - placeable, - }, - }, - ], - ), + Object.entries(blocks).map(([type, config]) => [ + type, + { config: { ...config, content: config.content ?? ("none" as const) } }, + ]), ), }; } -const validate = (containers: Record) => () => - validateChildrenConfigs(specsWith(containers)); +// Typed loosely on purpose: the validator is what catches the combinations the +// types reject, for JS callers. +const validate = (blocks: Record) => () => + validateChildrenConfigs(specsWith(blocks) as any); describe("validateChildrenConfigs", () => { - it("accepts recursive containers, named-only children, and titled blocks", () => { - expect( - validate({ callout: { children: { allow: "blocks" } } }), - ).not.toThrow(); + it("accepts recursive containers and named-only children", () => { + expect(validate({ callout: { container: true } })).not.toThrow(); expect( validate({ // gridCell is a terminating alternative to the recursive grid. - grid: { children: { allow: ["gridCell", "grid"], min: 2 } }, - gridCell: { children: { allow: "blocks" }, placeable: "namedOnly" }, - alert: { children: { allow: "blocks" }, content: "inline" }, - source: { children: { allow: "blocks" }, content: "plain" }, + grid: { + container: true, + children: { allow: ["gridCell", "grid"], min: 2 }, + }, + gridCell: { container: true, placeable: "namedOnly" }, }), ).not.toThrow(); }); - it.each(["inline", "plain"] as const)( - "rejects %s child restrictions the shared blockGroup cannot enforce", + it.each(["inline", "plain", "table"] as const)( + "rejects `container` on a block with %s content", + (content) => { + expect(validate({ alert: { content, container: true } })).toThrow( + /only for blocks without content/, + ); + }, + ); + + // Any block can have any child blocks, so writing out the default is fine + // everywhere. Restricting them needs the block's own node. + it.each(["inline", "plain", "table", "none"] as const)( + "only accepts the default children on a %s block that isn't a container", (content) => { + expect( + validate({ alert: { content, children: { allow: "blocks" } } }), + ).not.toThrow(); for (const children of [ { allow: "blocks", min: 2 }, { allow: ["cell"] }, @@ -98,59 +111,40 @@ describe("validateChildrenConfigs", () => { expect( validate({ alert: { content, children }, - cell: { children: { allow: "blocks" } }, + cell: { container: true }, }), - ).toThrow(/blocks with inline or plain content support/); + ).toThrow(/requires `container: true`/); } }, ); - it("does not treat a titled block's content node as an allowed container", () => { + it("does not treat a block with content as an allowed container", () => { expect( validate({ - box: { children: { allow: ["alert"] } }, - alert: { content: "inline", children: { allow: "blocks" } }, + box: { container: true, children: { allow: ["alert"] } }, + alert: { content: "inline" }, }), ).toThrow(/regular block/); }); it("rejects named-only placement on a shared regular block wrapper", () => { expect( - validate({ - alert: { - content: "inline", - children: { allow: "blocks" }, - placeable: "namedOnly", - }, - }), + validate({ alert: { content: "inline", placeable: "namedOnly" } }), ).toThrow(/requires a container node/); }); - // Tables do not support owned child blocks. - it("rejects children combined with table content", () => { - for (const content of ["table"] as const) { - expect(() => - validateChildrenConfigs({ - box: { - config: { content, children: { allow: "blocks" } }, - }, - }), - ).toThrow(/not supported on table blocks/); - } - }); - it.each(["typo", "blockGroupChild", "toString"])( "rejects an allow entry that is not a configured block: %s", (allowed) => { - expect(validate({ box: { children: { allow: [allowed] } } })).toThrow( - /not a configured block type/, - ); + expect( + validate({ box: { container: true, children: { allow: [allowed] } } }), + ).toThrow(/not a configured block type/); }, ); it("rejects a regular block type in the allow array", () => { - expect(validate({ box: { children: { allow: ["heading"] } } })).toThrow( - /not yet supported/, - ); + expect( + validate({ box: { container: true, children: { allow: ["heading"] } } }), + ).toThrow(/not yet supported/); }); }); diff --git a/packages/core/src/schema/blocks/children.ts b/packages/core/src/schema/blocks/children.ts index 7b4dd04071..cf132162c3 100644 --- a/packages/core/src/schema/blocks/children.ts +++ b/packages/core/src/schema/blocks/children.ts @@ -7,16 +7,12 @@ export const CHILD_CONTAINER_GROUP = "childContainer"; export const BLOCK_GROUP_CHILD_GROUP = "blockGroupChild"; /** - * Whether a block config declares a *container block*: one whose own node - * holds its children. A block that has content of its own keeps its ordinary - * shape, and its `children` declare owned children instead. + * Whether a block config declares a *container block* (`container: true`): + * one whose own node holds its children. * @internal */ -export function isContainerConfig(config: { - content: string; - children?: unknown; -}): boolean { - return config.children !== undefined && config.content === "none"; +export function isContainerConfig(config: { container?: true }): boolean { + return config.container === true; } // Whether `type` is a node that holds child blocks directly: a container @@ -27,18 +23,6 @@ export function isContainerNode(type: NodeType): boolean { return type.isInGroup(CHILD_CONTAINER_GROUP) && type.isInGroup("bnBlock"); } -/** - * Whether `node` is a block whose children are owned children: a container - * block, or a `blockContainer` whose content node declares `children`. - */ -export function hasOwnedChildren(node: Node): boolean { - return ( - isContainerNode(node.type) || - (node.type.name === "blockContainer" && - node.firstChild?.type.spec.blockConfig?.children !== undefined) - ); -} - // Builds the `blockGroup` node that holds a block's children when converting // blocks to nodes. Transaction-level nesting (`sinkItem`, `findWrapping` in the // keyboard shortcuts) wraps existing nodes in a `blockGroup` instead, and the @@ -94,8 +78,10 @@ export function containerNodePriority(priority: number | undefined): number { * expression: which types may be its children (`allow`), followed by how few * of them it takes (`min`). */ -export function childrenContentExpression(children: ChildrenConfig): string { - const { allow, min = 1 } = children; +export function childrenContentExpression( + children: ChildrenConfig = {}, +): string { + const { allow = "blocks", min = 1 } = children; let allowed: string; if (allow === "blocks") { diff --git a/packages/core/src/schema/blocks/createSpec.browser.test.ts b/packages/core/src/schema/blocks/createSpec.browser.test.ts index 7505f47068..f981a3f9bc 100644 --- a/packages/core/src/schema/blocks/createSpec.browser.test.ts +++ b/packages/core/src/schema/blocks/createSpec.browser.test.ts @@ -32,7 +32,7 @@ const Card = createBlockSpec( type: "card" as const, propSchema: { tone: { default: "neutral" } }, content: "none", - children: { allow: "blocks" }, + container: true, }, { render: renderDiv, @@ -49,7 +49,7 @@ const Quote = createBlockSpec( type: "quote" as const, propSchema: {}, content: "none", - children: { allow: "blocks" }, + container: true, }, { render: renderDiv, @@ -133,7 +133,7 @@ describe("container `runsBefore`", () => { type, propSchema: {}, content: "none", - children: { allow: "blocks" }, + container: true, } as any, { render: renderDiv, diff --git a/packages/core/src/schema/blocks/createSpec.test.ts b/packages/core/src/schema/blocks/createSpec.test.ts index d8380e4f13..59e56d93fa 100644 --- a/packages/core/src/schema/blocks/createSpec.test.ts +++ b/packages/core/src/schema/blocks/createSpec.test.ts @@ -160,7 +160,7 @@ describe("block spec and node agreement", () => { }), type: "holder", content: "none", - children: { allow: "blocks" }, + container: true, }, {}, ), @@ -210,7 +210,7 @@ describe("container children parsing", () => { type: "mixedBox" as const, propSchema: {}, content: "none", - children: { allow: "blocks" }, + container: true, }, { render: renderDiv }, )(); @@ -267,7 +267,7 @@ describe("container render contract", () => { type: "probed" as const, propSchema: {}, content: "none" as const, - children: { allow: "blocks" }, + container: true, }, {}, )(), @@ -301,7 +301,7 @@ describe("container render contract", () => { type: "probed" as const, propSchema: {}, content: "inline" as const, - children: { allow: "blocks" }, + container: true, }, { renderFrame: () => { @@ -319,7 +319,7 @@ describe("container render contract", () => { type: "probed", propSchema: {}, content: "none", - children: { allow: "blocks" }, + container: true, }, { render: renderDiv, @@ -341,7 +341,7 @@ it("scopes external container parsing to childrenDOM", () => { type: "box", propSchema: {}, content: "none", - children: { allow: "blocks" }, + container: true, }, { render() { diff --git a/packages/core/src/schema/blocks/createSpec.ts b/packages/core/src/schema/blocks/createSpec.ts index 02e1902aa3..6f49213290 100644 --- a/packages/core/src/schema/blocks/createSpec.ts +++ b/packages/core/src/schema/blocks/createSpec.ts @@ -24,6 +24,7 @@ import { isContainerConfig, } from "./children.js"; import { applyContainerAttributes } from "./containerAttributes.js"; +import { createBlockKeyboard } from "./keyboard.js"; import { applyDOMAttributes, getBlockFromNodeView, @@ -330,7 +331,7 @@ function buildNode< return Node.create({ name: blockConfig.type, content: isContainer - ? childrenContentExpression(blockConfig.children!) + ? childrenContentExpression(blockConfig.children) : blockConfig.content === "inline" ? "inline*" : blockConfig.content === "plain" @@ -517,6 +518,10 @@ export function addNodeAndExtensionsToSpec< config: blockConfig, implementation: { ...blockImplementation, + keyboard: createBlockKeyboard(blockImplementation.keyboard, { + isContainer, + hardBreakShortcut: blockImplementation.meta?.hardBreakShortcut, + }), node, render: serialize, toExternalHTML: serialize, diff --git a/packages/core/src/schema/blocks/internal.ts b/packages/core/src/schema/blocks/internal.ts index cc8970940e..3074f4319b 100644 --- a/packages/core/src/schema/blocks/internal.ts +++ b/packages/core/src/schema/blocks/internal.ts @@ -6,6 +6,7 @@ import type { ExtensionFactoryInstance } from "../../editor/BlockNoteExtension.j import { mergeCSSClasses } from "../../util/browser.js"; import { camelToDataKebab } from "../../util/string.js"; import { PropSchema, Props } from "../propTypes.js"; +import { createBlockKeyboard } from "./keyboard.js"; import { BlockConfig, ChildrenConfig, LooseBlockSpec } from "./types.js"; // Function that uses the 'propSchema' of a blockConfig to create a TipTap @@ -269,6 +270,7 @@ export function createBlockSpecFromTiptapNode< // even though the node itself is hand-written. The node's own content // expression stays authoritative for the PM schema, while BlockNote-level // behavior (repair, seeding, validation) reads this config. + container?: true; children?: ChildrenConfig; placeable?: BlockConfig["placeable"]; }, @@ -283,13 +285,25 @@ export function createBlockSpecFromTiptapNode< type: config.type as T["type"], content: config.content, propSchema, - ...(config.children !== undefined ? { children: config.children } : {}), + // `BlockConfig` only allows `container` and restricted `children` with + // `content: "none"`, which a generic `T["content"]` can't show; the + // hand-written node's config is validated when the schema is created. + ...(config.container !== undefined + ? { container: config.container as any } + : {}), + ...(config.children !== undefined + ? { children: config.children as any } + : {}), ...(config.placeable !== undefined ? { placeable: config.placeable } : {}), }, implementation: { node: config.node, + keyboard: createBlockKeyboard(undefined, { + isContainer: config.container === true, + hardBreakShortcut: undefined, + }), render: defaultBlockToHTML, toExternalHTML: defaultBlockToHTML, }, diff --git a/packages/core/src/schema/blocks/keyboard.ts b/packages/core/src/schema/blocks/keyboard.ts new file mode 100644 index 0000000000..14c731c6a8 --- /dev/null +++ b/packages/core/src/schema/blocks/keyboard.ts @@ -0,0 +1,116 @@ +/** + * How the keyboard treats a block, as far as it differs from an ordinary + * paragraph. + * + * When settings meet, they are applied in this order: + * - `enter: "line-break"` first: Enter then never splits or resets the block. + * - `emptyEnterResets` before `enter: "into-children"`: Enter in an empty + * block resets it, even when its Enter otherwise goes into its children. + * - A child's `emptyEnterResets` before its parent's `emptyChildEnter`. + * - `enter: "into-children"` before `splitKeepsType`: the new first child is a + * paragraph. + * - Enter at the start of non-empty content always inserts an empty block + * above it, so the block keeps its id, type and props. + */ +export type BlockKeyboard = { + /** + * What Enter does in the block's content. + * - `"split"`: splits the block. The text after the caret goes into a new + * block after it. + * - `"into-children"`: the text after the caret goes into a new first child. + * - `"line-break"`: inserts a line break (a `"\n"` in `content: "plain"` + * blocks). Shift-Enter then does the same. + * @default "split" + */ + enter: "split" | "into-children" | "line-break"; + /** + * What Shift-Enter does in the block's content. + * @default "line-break" + */ + shiftEnter: "line-break" | "same-as-enter"; + /** + * Whether a block created by splitting this one with Enter has the same type, + * as in lists. Also applies to the empty block Enter inserts above the + * block's content. New blocks always get default props. + * @default false + */ + splitKeepsType: boolean; + /** + * What the block turns into when it is reset: by Backspace at the start of + * its content, and by Enter in an empty block when `emptyEnterResets` is set. + * Its content and children are kept. `props` are merged into the block's + * props, so return the block's own type to only change props. + * @default { type: "paragraph" } + */ + resetsTo: { type: string; props?: Record }; + /** + * Whether Enter in the empty block resets it (see `resetsTo`), as when an + * empty list item turns into a paragraph. + * @default false + */ + emptyEnterResets: boolean; + /** + * What Enter does in an empty child of this block. + * - `"outdent"`: any empty child is outdented, as for nested blocks + * (needs `childrenCanOutdent`). + * - `"exit-at-end"`: an empty last child moves out to after this block, as + * for containers. An empty child elsewhere gets a new child after it. + * - `"stay"`: an empty child always gets a new child after it. + * @default "outdent", or "exit-at-end" for container blocks + */ + emptyChildEnter: "outdent" | "exit-at-end" | "stay"; + /** + * Whether this block's children can be outdented out of it: Shift-Tab, the + * unnest button, and the outdent that Backspace and Enter do at the start of + * an empty or nested block. A container's children can never be outdented, + * so this has no effect on containers. + * @default true, or false for container blocks + */ + childrenCanOutdent: boolean; +}; + +/** + * The `keyboard` option of a block implementation: the settings that differ + * from the defaults, or a function of the block that returns them, so they can + * depend on the block's props (a toggle heading vs. a regular heading) or on + * view state the block owns (whether a toggle is open). + */ +export type BlockKeyboardOption = + | Partial + // Declared as a method so a spec for a specific block type still fits where + // a spec for any block is expected (method parameters are checked + // bivariantly), like `meta.highlight`. + | { keyboard(block: TBlock): Partial }["keyboard"]; + +/** + * Fills in the defaults of a block's `keyboard` option. The result is what a + * block spec in a schema holds: a function of the block that returns every + * setting. + * @internal + */ +export function createBlockKeyboard( + option: BlockKeyboardOption | undefined, + spec: { + isContainer: boolean; + /** The deprecated `meta.hardBreakShortcut`, read when set. */ + hardBreakShortcut: "shift+enter" | "enter" | "none" | undefined; + }, +): (block: TBlock) => BlockKeyboard { + const { isContainer, hardBreakShortcut } = spec; + const defaults: BlockKeyboard = { + enter: hardBreakShortcut === "enter" ? "line-break" : "split", + shiftEnter: hardBreakShortcut === "none" ? "same-as-enter" : "line-break", + splitKeepsType: false, + resetsTo: { type: "paragraph" }, + emptyEnterResets: false, + // A container's children can't be outdented (the schema doesn't allow + // them outside it), so an empty last child leaves it instead. + emptyChildEnter: isContainer ? "exit-at-end" : "outdent", + childrenCanOutdent: !isContainer, + }; + if (typeof option === "function") { + return (block) => ({ ...defaults, ...option(block) }); + } + const keyboard = { ...defaults, ...option }; + return () => keyboard; +} diff --git a/packages/core/src/schema/blocks/renderFrame.test.ts b/packages/core/src/schema/blocks/renderFrame.test.ts index cb0419b699..88d14e3a4d 100644 --- a/packages/core/src/schema/blocks/renderFrame.test.ts +++ b/packages/core/src/schema/blocks/renderFrame.test.ts @@ -33,7 +33,6 @@ const Toggle = createBlockSpec( }, }, content: "inline", - children: { allow: "blocks" }, }, { render: renderDiv, @@ -62,7 +61,6 @@ const FrameBox = createBlockSpec( }, }, content: "inline", - children: { allow: "blocks" }, }, { render: renderDiv, @@ -89,7 +87,6 @@ const ContentFrame = createBlockSpec( type: "contentFrame", propSchema: {}, content: "inline", - children: { allow: "blocks" }, }, { render: renderDiv, diff --git a/packages/core/src/schema/blocks/types.ts b/packages/core/src/schema/blocks/types.ts index f6a06eb73e..ba0c304956 100644 --- a/packages/core/src/schema/blocks/types.ts +++ b/packages/core/src/schema/blocks/types.ts @@ -19,6 +19,7 @@ import type { StyledText, } from "../inlineContent/types.js"; import type { PropSchema, Props } from "../propTypes.js"; +import type { BlockKeyboard, BlockKeyboardOption } from "./keyboard.js"; import type { StyleSchema } from "../styles/types.js"; export type BlockNoteDOMElement = @@ -39,6 +40,9 @@ export interface BlockConfigMeta< /** * Defines which keyboard shortcut should be used to insert a hard break into the block's inline content. * @default "shift+enter" + * @deprecated Use `keyboard.enter` and `keyboard.shiftEnter` instead: + * `"enter"` is `keyboard: { enter: "line-break" }`, and `"none"` is + * `keyboard: { shiftEnter: "same-as-enter" }`. */ hardBreakShortcut?: "shift+enter" | "enter" | "none"; @@ -113,16 +117,19 @@ export type AllowedChildType = string; export type ChildrenAllow = "blocks" | readonly AllowedChildType[]; /** - * Marks a block as a *container*: a block whose body is other blocks, exposed - * as `block.children` at runtime. + * Which child blocks a container block holds (`container: true`), exposed as + * `block.children` at runtime. * - * The config describes one uniform body, semantically a single implicit - * slot. Ordered multi-slot bodies (a `sequence` of slots) can be added later - * as a sibling form. + * The config describes one uniform set of children, semantically a single + * implicit slot. Ordered multi-slot children (a `sequence` of slots) can be + * added later as a sibling form. */ export type ChildrenConfig = { - /** What may appear as a child. See {@link ChildrenAllow}. */ - allow: ChildrenAllow; + /** + * What may appear as a child. See {@link ChildrenAllow}. + * @default "blocks" + */ + allow?: ChildrenAllow; /** * How few children the container may hold. When children drop below the * minimum, a container that can stand anywhere dissolves into its @@ -157,15 +164,23 @@ export interface BlockConfig< */ content: C; /** - * Declares owned child blocks, exposed on `block.children`. - * With `content: "none"`, the block is a pure container whose `render` - * mounts children through contentDOM (React: contentRef). - * With `content: "inline"` or `"plain"`, `children: { allow: "blocks" }` - * gives the block owned children below its own text. These children remain - * optional; their types and minimum count cannot be restricted. - * `renderFrame` independently styles the block's content and children. + * Makes the block a container: a block without content of its own + * (`content: "none"`) whose own node holds its child blocks. Its `render` + * mounts them through contentDOM (React: contentRef). Without it, a block's + * child blocks are indented below it. + */ + container?: C extends "none" ? true : never; + /** + * Which child blocks the block may have. Every block can have any child + * blocks (`{ allow: "blocks" }`, the default). Only a container + * (`container: true`) can restrict them to certain types or a minimum + * count, since the child blocks of other blocks all share one untyped + * group. How the keyboard treats a block's children (e.g. whether Enter in + * the block's text starts them) is set with its `keyboard` settings. */ - children?: ChildrenConfig; + children?: C extends "none" + ? ChildrenConfig + : { allow?: "blocks"; min?: undefined }; /** * Where this block may be placed. * @@ -294,8 +309,10 @@ export type LooseBlockSpec< config: BlockConfig; implementation: Omit< BlockImplementation, - "render" | "renderFrame" | "toExternalHTML" + "render" | "renderFrame" | "toExternalHTML" | "keyboard" > & { + /** Every keyboard setting of the block, with defaults filled in. */ + keyboard: (block: any) => BlockKeyboard; // purposefully stub the types for render and toExternalHTML since they reference the block render: ( /** @@ -365,8 +382,9 @@ export type BlockSpecs = { config: BlockSpec["config"]; implementation: Omit< BlockSpec["implementation"], - "render" | "renderFrame" | "toExternalHTML" + "render" | "renderFrame" | "toExternalHTML" | "keyboard" > & { + keyboard?: BlockKeyboardOption; // purposefully stub the types for render and toExternalHTML since they reference the block render: ( /** @@ -666,6 +684,14 @@ export type BlockImplementation< * Metadata */ meta?: BlockConfigMeta; + /** + * How the keyboard treats the block and its children (Enter, Shift-Enter, + * Backspace, and outdenting): the settings that differ from the defaults, or + * a function of the block that returns them. See {@link BlockKeyboard}. + */ + keyboard?: BlockKeyboardOption< + BlockFromConfig, any, any> + >; /** * A function that converts the block into a DOM element. */ @@ -707,7 +733,7 @@ export type BlockImplementation< * `render` from scratch). Return `true` (or `undefined`) when you have * patched `dom` in-place and PM should keep the existing view. * - * Only honored for container blocks (blocks with `children`), where + * Only honored for container blocks (`container: true`), where * recreating the node view would remount every child block: e.g. column * resizing patches widths in place through this hook. Non-container * blocks always recreate on attr changes (see diff --git a/packages/core/src/schema/blocks/validateChildren.ts b/packages/core/src/schema/blocks/validateChildren.ts index 5525dc8cc5..03dae96d5f 100644 --- a/packages/core/src/schema/blocks/validateChildren.ts +++ b/packages/core/src/schema/blocks/validateChildren.ts @@ -1,14 +1,25 @@ import { isContainerConfig } from "./children.js"; -import type { BlockConfig } from "./types.js"; +import type { BlockConfig, ChildrenConfig } from "./types.js"; /** Reject declarations ProseMirror would accept with different semantics. */ export function validateChildrenConfigs( blockSpecs: Record< string, - { config: Pick } + { + config: Pick< + BlockConfig, + "content" | "placeable" | "container" | "children" + >; + } >, ) { for (const [type, { config }] of Object.entries(blockSpecs)) { + if (config.container !== undefined && config.content !== "none") { + fail( + type, + '`container: true` is only for blocks without content (`content: "none"`): a container\'s own node holds nothing but its child blocks.', + ); + } if (config.placeable === "namedOnly" && !isContainerConfig(config)) { fail( type, @@ -19,21 +30,19 @@ export function validateChildrenConfigs( continue; } - const { allow, min } = config.children; - if (config.content === "table") { - fail(type, "`children` is not supported on table blocks."); - } + // Typed loosely: a JS caller can put anything here. + const { allow = "blocks", min } = config.children as ChildrenConfig; - // Text blocks share an optional child group, so only pure containers - // can restrict their children's types or minimum count. - if ( - config.content !== "none" && - (allow !== "blocks" || min !== undefined) - ) { - fail( - type, - 'blocks with inline or plain content support `children: { allow: "blocks" }` only. Child-type and minimum-count restrictions require a pure container.', - ); + // The child blocks of a block that isn't a container share one untyped + // group, which can't enforce types or counts. + if (!isContainerConfig(config)) { + if (allow !== "blocks" || min !== undefined) { + fail( + type, + 'restricting child types or a minimum count requires `container: true`. Other blocks can always have any child blocks (`{ allow: "blocks" }`).', + ); + } + continue; } // Every regular block is the same node (`blockContainer`), so naming one diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 2f1e703007..12ee25e7ce 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -1,6 +1,7 @@ export * from "./blocks/createSpec.js"; export * from "./blocks/internal.js"; export * from "./blocks/types.js"; +export type { BlockKeyboard, BlockKeyboardOption } from "./blocks/keyboard.js"; export * from "./inlineContent/createSpec.js"; export * from "./inlineContent/internal.js"; export * from "./inlineContent/types.js"; diff --git a/packages/diagram-block/src/block/createReactDiagramBlockSpec.tsx b/packages/diagram-block/src/block/createReactDiagramBlockSpec.tsx index a7fd54101e..006fdf52d6 100644 --- a/packages/diagram-block/src/block/createReactDiagramBlockSpec.tsx +++ b/packages/diagram-block/src/block/createReactDiagramBlockSpec.tsx @@ -39,7 +39,10 @@ export const createReactDiagramBlockSpec = createReactBlockSpec( // upstream, highlighting should start working with no change here. highlight: () => "mermaid", hasPreview: true, - hardBreakShortcut: "enter", + }, + // Multi-line source: Enter inserts a line break. + keyboard: { + enter: "line-break", }, parse: parseDiagramCodeElement, parseContent: parseDiagramCodeContent, diff --git a/packages/math-block/src/block/createReactMathBlockSpec.test.tsx b/packages/math-block/src/block/createReactMathBlockSpec.test.tsx index d2d2e31796..2b1879a59a 100644 --- a/packages/math-block/src/block/createReactMathBlockSpec.test.tsx +++ b/packages/math-block/src/block/createReactMathBlockSpec.test.tsx @@ -134,7 +134,7 @@ describe("Math block source popup keyboard handling", () => { await flush(); expect(isPopupOpen("math")).toBe(true); - // Math uses `hardBreakShortcut: "shift+enter"`, so unlike the diagram + // Math keeps the default Enter (no `keyboard.enter: "line-break"`), so unlike the diagram // block, a plain Enter closes the popup rather than extending the source // with a newline (that needs Shift+Enter - see the next test). pressKey("Enter"); diff --git a/packages/math-block/src/block/createReactMathBlockSpec.tsx b/packages/math-block/src/block/createReactMathBlockSpec.tsx index c8cb18c33d..cbd2b77382 100644 --- a/packages/math-block/src/block/createReactMathBlockSpec.tsx +++ b/packages/math-block/src/block/createReactMathBlockSpec.tsx @@ -29,7 +29,6 @@ export const createReactMathBlockSpec = createReactBlockSpec( isolating: false, highlight: () => "latex", hasPreview: true, - hardBreakShortcut: "shift+enter", }, parse: parseBlockMathMLElement, parseContent: parseBlockMathMLContent, diff --git a/packages/react/src/blocks/SourceWithPreview/block/SourceBlockWithPreview.tsx b/packages/react/src/blocks/SourceWithPreview/block/SourceBlockWithPreview.tsx index 1384b13cda..f42438991e 100644 --- a/packages/react/src/blocks/SourceWithPreview/block/SourceBlockWithPreview.tsx +++ b/packages/react/src/blocks/SourceWithPreview/block/SourceBlockWithPreview.tsx @@ -31,8 +31,8 @@ export const SourceBlockWithPreview = (props: SourceBlockWithPreviewProps) => { // block uses Enter for hard breaks (multi-line source, e.g. diagrams), // Enter inserts a newline instead of closing the popup. const enterSubmits = - editor.schema.blockSpecs[block.type]?.implementation?.meta - ?.hardBreakShortcut !== "enter"; + editor.schema.blockSpecs[block.type].implementation.keyboard(block) + .enter !== "line-break"; return ( { - if (action.type === "toggled") { - return !showChildren; - } - - if (action.type === "childAdded") { - return true; - } - - if (action.type === "lastChildRemoved") { - return false; - } - - throw new UnreachableCaseError(action); -}; - -export const ToggleWrapper = ( - props: Omit< - ReactCustomBlockRenderProps>, - "contentRef" - > & { - children: ReactNode; - toggledState?: { - set: (block: Block, isToggled: boolean) => void; - get: (block: Block) => boolean; - }; - }, -) => { - const { block, editor, children, toggledState } = props; - - const [showChildren, dispatch] = useReducer( - showChildrenReducer, - (toggledState || defaultToggledState).get(block), - ); - - const handleToggle = (block: Block) => { - const currentBlock = editor.getBlock(block); - if (!currentBlock) { - return; - } - (toggledState || defaultToggledState).set(currentBlock, !showChildren); - dispatch({ - type: "toggled", - }); - }; - - const handleChildAdded = (block: Block) => { - (toggledState || defaultToggledState).set(block, true); - dispatch({ - type: "childAdded", - }); - }; - - const handleLastChildRemoved = (block: Block) => { - (toggledState || defaultToggledState).set(block, false); - dispatch({ - type: "lastChildRemoved", - }); - }; - - const childCount = useEditorState({ - editor, - selector: ({ editor }) => { - if ( - !blockHasType(block, editor, block.type, { isToggleable: "boolean" }) && - !block.props.isToggleable - ) { - return 0; - } - - const newBlock = editor.getBlock(block); - if (!newBlock) { - return 0; - } - const newChildCount = newBlock.children.length || 0; - - if (newChildCount > childCount) { - // If a child block is added while children are hidden, show children. - if (!showChildren) { - handleChildAdded(newBlock); - } - } else if (newChildCount === 0 && newChildCount < childCount) { - // If the last child block is removed while children are shown, hide - // children. - if (showChildren) { - handleLastChildRemoved(newBlock); - } - } - - return newChildCount; - }, - }); - - if ("isToggleable" in block.props && !block.props.isToggleable) { - return children; - } - - return ( -
-
- - {children} -
- {editor.isEditable && showChildren && childCount === 0 && ( - - )} -
- ); -}; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 0553f8a30d..7d3251496d 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -24,7 +24,6 @@ export * from "./blocks/SourceWithPreview/block/useSourceBlockPreviewPopup.js"; export * from "./blocks/SourceWithPreview/inlineContent/SourceInlineContentWithPreview.js"; export * from "./blocks/SourceWithPreview/inlineContent/useSourceInlineContentPreviewPopup.js"; export * from "./blocks/Video/block.js"; -export * from "./blocks/ToggleWrapper/ToggleWrapper.js"; export * from "./components/FormattingToolbar/DefaultButtons/AddCommentButton.js"; export * from "./components/FormattingToolbar/DefaultButtons/AddTiptapCommentButton.js"; diff --git a/packages/react/src/schema/ReactBlockSpec.container.browser.test.tsx b/packages/react/src/schema/ReactBlockSpec.container.browser.test.tsx index 4c29c09a15..ac788b5d96 100644 --- a/packages/react/src/schema/ReactBlockSpec.container.browser.test.tsx +++ b/packages/react/src/schema/ReactBlockSpec.container.browser.test.tsx @@ -26,7 +26,7 @@ const createCallout = createReactBlockSpec( type: "callout", propSchema: { flavor: { default: "tip" } }, content: "none", - children: { allow: "blocks" }, + container: true, }, { render: function Callout(props) { diff --git a/packages/react/src/schema/ReactBlockSpec.frame.browser.test.tsx b/packages/react/src/schema/ReactBlockSpec.frame.browser.test.tsx index 09208b7915..2998c4eb70 100644 --- a/packages/react/src/schema/ReactBlockSpec.frame.browser.test.tsx +++ b/packages/react/src/schema/ReactBlockSpec.frame.browser.test.tsx @@ -37,7 +37,6 @@ function createFrameSchema(content: "inline" | "plain") { type: "framed", propSchema: { framed: { default: true } }, content, - children: { allow: "blocks" }, }, { render: (props) => ( diff --git a/packages/react/src/schema/ReactBlockSpec.tsx b/packages/react/src/schema/ReactBlockSpec.tsx index dc10df47af..0e5c9d7fb6 100644 --- a/packages/react/src/schema/ReactBlockSpec.tsx +++ b/packages/react/src/schema/ReactBlockSpec.tsx @@ -9,7 +9,6 @@ import { BlockNoteEditor, BlockSpec, camelToDataKebab, - ChildrenConfig, CustomBlockImplementation, Extension, ExtensionFactoryInstance, @@ -45,9 +44,9 @@ export type ReactCustomBlockRenderProps< editor: BlockNoteEditor, any, any>; // A block gets a `contentRef` for its `render` to mount its editable region: // its inline content, or, for a container, its child blocks. Only a - // `content: "none"` block without `children` (and the table block, whose + // `content: "none"` block that isn't a container (and the table block, whose // content is managed separately) has nothing to place. -} & (Config extends { children: ChildrenConfig } +} & (Config extends { container: true } ? { contentRef: (node: HTMLElement | null) => void } : Config["content"] extends "inline" | "plain" ? { contentRef: (node: HTMLElement | null) => void } diff --git a/packages/xl-multi-column/src/blocks/Columns/index.ts b/packages/xl-multi-column/src/blocks/Columns/index.ts index 8be04b6d1f..e621c269e8 100644 --- a/packages/xl-multi-column/src/blocks/Columns/index.ts +++ b/packages/xl-multi-column/src/blocks/Columns/index.ts @@ -14,7 +14,7 @@ export const ColumnBlock = createBlockSpec( }, }, content: "none", - children: { allow: "blocks" }, + container: true, placeable: "namedOnly", }, { @@ -45,6 +45,7 @@ export const ColumnListBlock = createBlockSpec( type: "columnList" as const, propSchema: {}, content: "none", + container: true, children: { allow: ["column"], min: 2, diff --git a/playground/src/examples.gen.tsx b/playground/src/examples.gen.tsx index 2e793bfe93..a593e36213 100644 --- a/playground/src/examples.gen.tsx +++ b/playground/src/examples.gen.tsx @@ -1433,24 +1433,6 @@ export const examples = { readme: 'In this example, we create a custom `Alert` block which is used to emphasize text, same as in the [minimal `Alert` block example](/examples/custom-schema/alert-block). However, in this example, we also add a command to insert the block via the Slash Menu, and an entry in the Formatting Toolbar\'s Block Type Select to change the current block to an `Alert`.\n\n**Try it out:** Press the "/" key to open the Slash Menu and insert an `Alert` block! Or highlight text in a paragraph, then change the block type to an `Alert` using the Block Type Select in the Formatting Toolbar!\n\n**Relevant Docs:**\n\n- [Minimal Alert Block Example](/examples/custom-schema/alert-block)\n- [Changing Slash Menu Items](/docs/react/components/suggestion-menus)\n- [Changing Block Type Select Items](/docs/react/components/formatting-toolbar)\n- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)\n- [Editor Setup](/docs/getting-started/editor-setup)', }, - { - projectSlug: "toggleable-blocks", - fullSlug: "custom-schema/toggleable-blocks", - pathFromRoot: "examples/06-custom-schema/06-toggleable-blocks", - config: { - playground: true, - docs: true, - author: "matthewlipski", - tags: ["Basic"], - }, - title: "Toggleable Custom Blocks", - group: { - pathFromRoot: "examples/06-custom-schema", - slug: "custom-schema", - }, - readme: - "This example shows how to create custom blocks with a toggle button to show/hide their children, like with the default toggle heading and list item blocks. This is done using the use the `ToggleWrapper` component from `@blocknote/react`.\n\n**Relevant Docs:**\n\n- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)\n- [Editor Setup](/docs/getting-started/editor-setup)\n- [Default Schema](/docs/features/blocks)", - }, { projectSlug: "configuring-blocks", fullSlug: "custom-schema/configuring-blocks", @@ -1512,7 +1494,7 @@ export const examples = { slug: "custom-schema", }, readme: - 'In this example, we create a custom `Panel` block that holds other blocks as its body, like a Notion-style callout wrapping a paragraph followed by a code block.\n\nThe block declares the `children` config on `BlockConfig`. `children: { allow: "blocks" }` makes it a container: its child blocks mount into the frame\'s `slot` (attached with `ref={contentRef}`), and live on `block.children` at runtime. A pure container like this draws its box in `renderFrame` alone, which re-renders live when props change — click the icon to cycle the panel\'s flavor and watch the box follow without rebuilding the body.\n\nWe also wire up a Slash Menu item to insert the panel, and render the document JSON next to the editor so you can inspect the structure of the nested blocks.\n\n**Try it out:**\n\n- Press the "/" key inside the panel\'s body and add a code block, heading, or list.\n- Click the panel\'s icon to cycle its flavor. The box re-renders in place; the children are untouched.\n- Watch the JSON panel on the right update as you edit; the panel\'s children appear in `block.children`.\n- Insert a new panel via the Slash Menu (search "panel").\n\n**Relevant Docs:**\n\n- [Container Blocks](/docs/features/custom-schemas/container-blocks)\n- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)\n- [Editor Setup](/docs/getting-started/editor-setup)', + 'In this example, we create a custom `Panel` block that holds other blocks as its body, such as a panel containing headings and paragraphs.\n\nThe block sets `container: true` on `BlockConfig`, which makes it a container: its child blocks mount into the rendered content region (attached with `ref={contentRef}`), and live on `block.children` at runtime. A pure container like this draws its box in `render`, which re-renders live when props change.\n\nWe also wire up a Slash Menu item to insert the panel.\n\n**Try it out:**\n\n- Press the "/" key inside the panel\'s body and add a code block, heading, or list.\n- Insert a new panel via the Slash Menu (search "panel").\n\n**Relevant Docs:**\n\n- [Container Blocks](/docs/features/custom-schemas/container-blocks)\n- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)\n- [Editor Setup](/docs/getting-started/editor-setup)', }, { projectSlug: "math-block", @@ -1618,7 +1600,7 @@ export const examples = { slug: "custom-schema", }, readme: - 'In this example, we create a custom `Callout` block with a real rich-text title and a body of child blocks (a titled block), like a Notion-style callout.\n\nThe block combines `content: "inline"` with the `children` config on `BlockConfig`. The title is ordinary inline content — formatting, links, and multiplayer cursors all work — while `children: { allow: "blocks" }` hosts the body blocks, which live on `block.children` at runtime. `render` draws the title row and `renderFrame` draws the box around the title and body together.\n\nWe also wire up a Slash Menu item to insert the callout, and render the document JSON next to the editor so you can inspect the structure of the titled block and its nested children.\n\n**Try it out:**\n\n- Press Enter at the end of the callout\'s title to jump into its body.\n- Press Backspace at the start of the first body block to merge it back into the title.\n- Press "/" inside the body and add a code block, heading, or list.\n- Watch the JSON panel on the right update as you edit; the title is `content` and the body is `block.children`.\n\n**Relevant Docs:**\n\n- [Container Blocks](/docs/features/custom-schemas/container-blocks)\n- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)\n- [Editor Setup](/docs/getting-started/editor-setup)', + 'In this example, we create a custom `Callout` block with a real rich-text title and child blocks inside it (a titled block), like a Notion-style callout.\n\nThe block has `content: "inline"`: the title is ordinary inline content — formatting, links, and multiplayer cursors all work — and its child blocks live on `block.children` at runtime. Its `keyboard` settings keep the child blocks inside the callout: Enter in the title adds a first child block, Shift-Tab doesn\'t move child blocks out, and Enter in an empty last child block leaves the callout. `render` draws the title row and `renderFrame` draws the box around the title and child blocks together.\n\nWe also wire up a Slash Menu item to insert the callout.\n\n**Try it out:**\n\n- Press Enter at the end of the callout\'s title to add a block inside the callout.\n- Press Enter in an empty last block inside the callout to leave the callout.\n- Press Backspace at the start of the first block inside the callout to merge it back into the title.\n- Press "/" inside the callout and add a code block, heading, or list.\n\n**Relevant Docs:**\n\n- [Container Blocks](/docs/features/custom-schemas/container-blocks)\n- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)\n- [Editor Setup](/docs/getting-started/editor-setup)', }, { projectSlug: "draggable-inline-content", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 03bd5b9292..8b97fcebb0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3382,49 +3382,6 @@ importers: specifier: ^8.0.0 version: 8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0) - examples/06-custom-schema/06-toggleable-blocks: - dependencies: - '@blocknote/ariakit': - specifier: latest - version: link:../../../packages/ariakit - '@blocknote/core': - specifier: latest - version: link:../../../packages/core - '@blocknote/mantine': - specifier: latest - version: link:../../../packages/mantine - '@blocknote/react': - specifier: latest - version: link:../../../packages/react - '@blocknote/shadcn': - specifier: latest - version: link:../../../packages/shadcn - '@mantine/core': - specifier: ^9.0.2 - version: 9.1.1(@mantine/hooks@9.1.1(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@mantine/hooks': - specifier: ^9.0.2 - version: 9.1.1(react@19.2.5) - react: - specifier: ^19.2.3 - version: 19.2.5 - react-dom: - specifier: ^19.2.3 - version: 19.2.5(react@19.2.5) - devDependencies: - '@types/react': - specifier: ^19.2.3 - version: 19.2.14 - '@types/react-dom': - specifier: ^19.2.3 - version: 19.2.3(@types/react@19.2.14) - '@vitejs/plugin-react': - specifier: ^6.0.1 - version: 6.0.1(babel-plugin-react-compiler@1.0.0)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0)) - vite: - specifier: ^8.0.0 - version: 8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0) - examples/06-custom-schema/07-configuring-blocks: dependencies: '@blocknote/ariakit': diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/heading/toggleable.html b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/heading/toggleable.html index bceb80b782..9906681744 100644 --- a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/heading/toggleable.html +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/heading/toggleable.html @@ -1,9 +1,15 @@
-
-