From 6676aaa799b2829486117d7b054ce7c20a4acf7e Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Thu, 20 Aug 2026 13:48:23 +0200 Subject: [PATCH 1/3] fix(core): replace ToggleWrapper with a decoration-based Collapsible extension Toggle blocks were built on a `ToggleWrapper` node view that each collapsible block had to render itself. Wrapping the block content in extra DOM put a non-editable element inside the editable tree, which is the root of most of the open toggle bugs: the caret gets trapped, drops land in the wrong place, and a block converted away from a toggle keeps its chevron. Collapsing is now a `CollapsibleExtension` that draws the chevron, the collapsed state and the "add a block" affordance as ProseMirror decorations, computed incrementally from the changed range. Blocks opt in with `meta.collapsible`, either a boolean or a predicate over props, so `heading` is collapsible only while `props.isToggleable` is set and stops being so the moment it isn't. Separately, `splitBlockTr` detached the child `blockGroup` along with the split, so pressing Enter in a block with children handed those children to the new block. It now lifts the group out before splitting and puts it back on the original block within the same transaction, keeping it a single undo step. Fixes #2020 Fixes #2378 Fixes #2124 Fixes #2109 Fixes #1875 --- .../06-toggleable-blocks/README.md | 2 +- .../06-toggleable-blocks/src/Toggle.tsx | 19 +- .../materializeChildren.ts | 70 +++ .../__snapshots__/splitBlock.test.ts.snap | 34 +- .../commands/splitBlock/splitBlock.test.ts | 24 ++ .../commands/splitBlock/splitBlock.ts | 30 ++ .../exporters/html/internalHTMLSerializer.ts | 16 - .../html/util/serializeBlocksInternalHTML.ts | 16 + packages/core/src/blocks/Heading/block.ts | 10 +- .../blocks/ListItem/ToggleListItem/block.ts | 15 +- .../ToggleWrapper/createToggleWrapper.ts | 197 --------- packages/core/src/blocks/index.ts | 1 - .../src/blocks/utils/listItemEnterHandler.ts | 7 + packages/core/src/editor/Block.css | 70 ++- .../managers/ExtensionManager/extensions.ts | 2 + .../Collapsible/Collapsible.test.ts | 407 ++++++++++++++++++ .../src/extensions/Collapsible/Collapsible.ts | 326 ++++++++++++++ .../Collapsible/blockDecorations.ts | 101 +++++ .../extensions/Collapsible/collapsibleDrop.ts | 119 +++++ .../Collapsible/collapsibleEnter.ts | 56 +++ .../src/extensions/DropCursor/DropCursor.ts | 22 + packages/core/src/extensions/index.ts | 1 + .../KeyboardShortcutsExtension.ts | 8 + packages/core/src/schema/blocks/types.ts | 14 + .../blocks/ToggleWrapper/ToggleWrapper.tsx | 162 ------- packages/react/src/editor/styles.css | 6 +- packages/react/src/index.ts | 1 - playground/src/examples.gen.tsx | 2 +- .../enterPreservesNestedBlocks.json | 44 +- tests/src/end-to-end/static/static.test.tsx | 12 +- tests/src/end-to-end/toggle/toggle.test.tsx | 180 ++++++++ .../blocknoteHTML/heading/toggleable.html | 41 +- .../blocknoteHTML/lists/basic.html | 41 +- .../blocknoteHTML/lists/nested.html | 41 +- .../lists/toggleWithChildren.html | 82 ++-- .../legacyToggleWrapperBlockNoteHTML.json | 74 ++++ .../parse/parseTestInstances.ts | 62 +++ .../core/schema/__snapshots__/blocks.json | 2 + 38 files changed, 1761 insertions(+), 556 deletions(-) create mode 100644 packages/core/src/api/blockManipulation/commands/materializeChildren/materializeChildren.ts delete mode 100644 packages/core/src/blocks/ToggleWrapper/createToggleWrapper.ts create mode 100644 packages/core/src/extensions/Collapsible/Collapsible.test.ts create mode 100644 packages/core/src/extensions/Collapsible/Collapsible.ts create mode 100644 packages/core/src/extensions/Collapsible/blockDecorations.ts create mode 100644 packages/core/src/extensions/Collapsible/collapsibleDrop.ts create mode 100644 packages/core/src/extensions/Collapsible/collapsibleEnter.ts delete mode 100644 packages/react/src/blocks/ToggleWrapper/ToggleWrapper.tsx create mode 100644 tests/src/end-to-end/toggle/toggle.test.tsx create mode 100644 tests/src/unit/core/formatConversion/parse/__snapshots__/html/legacyToggleWrapperBlockNoteHTML.json diff --git a/examples/06-custom-schema/06-toggleable-blocks/README.md b/examples/06-custom-schema/06-toggleable-blocks/README.md index 4bbaaa70e1..50c30fd9cd 100644 --- a/examples/06-custom-schema/06-toggleable-blocks/README.md +++ b/examples/06-custom-schema/06-toggleable-blocks/README.md @@ -1,6 +1,6 @@ # 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`. +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 by setting `meta.collapsible` on the block spec, which `CollapsibleExtension` picks up. **Relevant Docs:** diff --git a/examples/06-custom-schema/06-toggleable-blocks/src/Toggle.tsx b/examples/06-custom-schema/06-toggleable-blocks/src/Toggle.tsx index 244661f841..673c7ea468 100644 --- a/examples/06-custom-schema/06-toggleable-blocks/src/Toggle.tsx +++ b/examples/06-custom-schema/06-toggleable-blocks/src/Toggle.tsx @@ -1,5 +1,5 @@ import { defaultProps } from "@blocknote/core"; -import { createReactBlockSpec, ToggleWrapper } from "@blocknote/react"; +import { createReactBlockSpec } from "@blocknote/react"; // The Toggle block that we want to add to our editor. export const ToggleBlock = createReactBlockSpec( @@ -11,15 +11,12 @@ export const ToggleBlock = createReactBlockSpec( 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. - -

- - ), + // `meta.collapsible` is all it takes: `CollapsibleExtension` adds the + // chevron that hides the block's children, and remembers the state per + // block ID in local storage. + meta: { + collapsible: true, + }, + render: (props) =>

, }, ); diff --git a/packages/core/src/api/blockManipulation/commands/materializeChildren/materializeChildren.ts b/packages/core/src/api/blockManipulation/commands/materializeChildren/materializeChildren.ts new file mode 100644 index 0000000000..65e069990b --- /dev/null +++ b/packages/core/src/api/blockManipulation/commands/materializeChildren/materializeChildren.ts @@ -0,0 +1,70 @@ +import type { Node as PMNode } from "prosemirror-model"; +import { TextSelection, type Transaction } from "prosemirror-state"; + +import { + getBlockInfo, + getNearestBlockPos, +} from "../../../getBlockInfoFromPos.js"; +import { getPmSchema } from "../../../pmUtil.js"; + +/** + * Inserts `content` before whatever children the block at `blockPos` already + * has, creating the `blockGroup` that holds them if there aren't any yet. + * + * That group is `content: "blockGroupChild+"`, so it can't exist empty: a + * childless block doesn't have one, and there is no position inside it to + * insert at. Every path that puts the first block into such a block has to + * create the group and its first occupant together, which is what this does — + * a block that already has children just gets them prepended. + * + * @returns The position just before the first inserted child block. Use + * `TextSelection.near(tr.doc.resolve(pos), 1)` to put the text cursor in it. + */ +export function materializeChildren( + tr: Transaction, + blockPos: number, + content: PMNode | readonly PMNode[], +): number { + const info = getBlockInfo(getNearestBlockPos(tr.doc, blockPos)); + + const nodes = Array.isArray(content) ? content : [content as PMNode]; + + if (info.childContainer) { + const insertPos = info.childContainer.beforePos + 1; + tr.insert(insertPos, nodes); + + return insertPos; + } + + // No group yet, so it's created here along with its first occupant. It goes + // at the very end of the block, just inside it — which for a `blockContainer` + // is right after its content node, and only a `blockContainer` can be without + // a child container in the first place (columns and column lists always have + // one). + const schema = getPmSchema(tr); + const insertPos = info.bnBlock.afterPos - 1; + tr.insert(insertPos, schema.nodes["blockGroup"].create(null, nodes)); + + // `insertPos` is just before the new `blockGroup`, so the first child starts + // one position further in. + return insertPos + 1; +} + +/** + * Inserts an empty paragraph as the first child of the block at `blockPos` and + * puts the text cursor in it. + */ +export function insertEmptyFirstChild(tr: Transaction, blockPos: number) { + const schema = getPmSchema(tr); + const childPos = materializeChildren( + tr, + blockPos, + schema.nodes["blockContainer"].createAndFill( + undefined, + schema.nodes["paragraph"].createAndFill() ?? undefined, + )!, + ); + + tr.setSelection(TextSelection.near(tr.doc.resolve(childPos), 1)); + tr.scrollIntoView(); +} diff --git a/packages/core/src/api/blockManipulation/commands/splitBlock/__snapshots__/splitBlock.test.ts.snap b/packages/core/src/api/blockManipulation/commands/splitBlock/__snapshots__/splitBlock.test.ts.snap index 8cd297eaee..f76031684d 100644 --- a/packages/core/src/api/blockManipulation/commands/splitBlock/__snapshots__/splitBlock.test.ts.snap +++ b/packages/core/src/api/blockManipulation/commands/splitBlock/__snapshots__/splitBlock.test.ts.snap @@ -621,23 +621,6 @@ exports[`Test splitBlocks > Block has children 1`] = ` }, "type": "paragraph", }, - { - "children": [], - "content": [ - { - "styles": {}, - "text": "Para", - "type": "text", - }, - ], - "id": "paragraph-with-children", - "props": { - "backgroundColor": "default", - "textAlignment": "left", - "textColor": "default", - }, - "type": "paragraph", - }, { "children": [ { @@ -676,6 +659,23 @@ exports[`Test splitBlocks > Block has children 1`] = ` "type": "paragraph", }, ], + "content": [ + { + "styles": {}, + "text": "Para", + "type": "text", + }, + ], + "id": "paragraph-with-children", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], "content": [ { "styles": {}, diff --git a/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.test.ts b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.test.ts index ab02a865f0..0b1e2d9a2c 100644 --- a/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.test.ts +++ b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.test.ts @@ -77,6 +77,30 @@ describe("Test splitBlocks", () => { expect(getEditor().document).toMatchSnapshot(); }); + it("Block has children, split at end of content", () => { + getEditor().transact((tr) => { + setSelectionWithOffset( + tr.doc, + "paragraph-with-children", + "Paragraph with children".length, + ); + }); + + splitBlock(getEditor().transact((tr) => tr.selection.anchor)); + + // The children must stay with the original block rather than being + // reparented onto the newly created one. + const original = getEditor().document.find( + (block) => block.id === "paragraph-with-children", + )!; + expect(original.children.map((child) => child.id)).toEqual([ + "nested-paragraph-0", + ]); + + const newBlock = getEditor().document.find((block) => block.id === "0")!; + expect(newBlock.children).toEqual([]); + }); + it("Keep type", () => { getEditor().transact((tr) => { setSelectionWithOffset(tr.doc, "heading-0", 4); diff --git a/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts index 1e73471d23..da231fa7dd 100644 --- a/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts +++ b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts @@ -52,7 +52,37 @@ export const splitBlockTr = ( }, ]; + // A block's children live in a `blockGroup` that sits *after* its content + // inside the `blockContainer`. A plain split would therefore hand that group + // to the new block, i.e. the new block would steal the original's children. + // To avoid that, the group is detached before the split and put back on the + // original block afterwards. Both happen in the same transaction, so this is + // still a single undo step. + const childContainer = info.childContainer; + + if (childContainer) { + // The group sits after `posInBlock`, so deleting it doesn't shift the split + // position. + tr.delete(childContainer.beforePos, childContainer.afterPos); + } + tr.split(posInBlock, 2, types); + if (childContainer) { + // The original block starts before `posInBlock`, so its position is + // unaffected by the delete and the split. + const originalBlockInfo = getBlockInfo( + getNearestBlockPos(tr.doc, nearestBlockContainerPos.posBeforeNode), + ); + + if (!originalBlockInfo.isBlockContainer) { + throw new Error( + "Block that was just split is no longer a block container", + ); + } + + tr.insert(originalBlockInfo.blockContent.afterPos, childContainer.node); + } + return true; }; diff --git a/packages/core/src/api/exporters/html/internalHTMLSerializer.ts b/packages/core/src/api/exporters/html/internalHTMLSerializer.ts index 33376b2835..08a8e0a063 100644 --- a/packages/core/src/api/exporters/html/internalHTMLSerializer.ts +++ b/packages/core/src/api/exporters/html/internalHTMLSerializer.ts @@ -55,21 +55,6 @@ const makeCheckListItemsReadOnly = (element: HTMLElement) => { return element; }; -// Forces toggle blocks (toggle headings, toggle list items) to be expanded. -// This is because event listeners for the toggle button are lost when -// 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"]', - ); - hiddenToggleWrappers.forEach((toggleWrapper) => { - toggleWrapper.setAttribute("data-show-children", "true"); - }); - - return element; -}; - // Adds minimum cell widths, which would normally be done by the // `columnResizing` extension. This extension doesn't run when exporting to // HTML, so we have to add this manually. @@ -154,7 +139,6 @@ export const createInternalHTMLSerializer = < const transforms: ((element: HTMLElement) => HTMLElement)[] = [ addIndexToNumberedListItems, makeCheckListItemsReadOnly, - forceToggleBlocksShow, addTableMinCellWidths, addTableWrappers, addTrailingBreakToEmptyInlineContent, diff --git a/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts b/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts index 0f890b77ab..912f90dff6 100644 --- a/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts +++ b/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts @@ -2,6 +2,10 @@ import { DOMSerializer, Fragment, Node } from "prosemirror-model"; import { PartialBlock } from "../../../../blocks/defaultBlocks.js"; import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js"; +import { + createCollapseButton, + isBlockCollapsible, +} from "../../../../extensions/Collapsible/Collapsible.js"; import { BlockSchema, InlineContentSchema, @@ -197,6 +201,18 @@ function serializeBlock< contentDOM?: HTMLElement; }; + // `CollapsibleExtension` doesn't run when exporting, so the chevron is added + // here to keep the layout identical to the editor's. It can't fold anything, + // so it's rendered inert. + if (isBlockCollapsible(editor as any, block.type as string, props)) { + const collapseButton = createCollapseButton(true); + collapseButton.setAttribute("aria-hidden", "true"); + collapseButton.setAttribute("tabindex", "-1"); + + bc.dom.setAttribute("data-collapsible", "true"); + bc.contentDOM?.appendChild(collapseButton); + } + bc.contentDOM?.appendChild(ret.dom); if (block.children && block.children.length > 0) { diff --git a/packages/core/src/blocks/Heading/block.ts b/packages/core/src/blocks/Heading/block.ts index 6b14204cc8..82e2e38c1c 100644 --- a/packages/core/src/blocks/Heading/block.ts +++ b/packages/core/src/blocks/Heading/block.ts @@ -7,7 +7,6 @@ import { parseDefaultProps, } from "../defaultProps.js"; import { getDetailsContent } from "../getDetailsContent.js"; -import { createToggleWrapper } from "../ToggleWrapper/createToggleWrapper.js"; const HEADING_LEVELS = [1, 2, 3, 4, 5, 6] as const; @@ -63,6 +62,8 @@ export const createHeadingBlockSpec = createBlockSpec( ({ allowToggleHeadings = true }: HeadingOptions = {}) => ({ meta: { isolating: false, + // Handled by `CollapsibleExtension`. + collapsible: (block) => allowToggleHeadings && !!block.props.isToggleable, }, parse(e) { if (allowToggleHeadings && e.tagName === "DETAILS") { @@ -126,14 +127,9 @@ 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, diff --git a/packages/core/src/blocks/ListItem/ToggleListItem/block.ts b/packages/core/src/blocks/ListItem/ToggleListItem/block.ts index 54a7a39dfe..c82475046e 100644 --- a/packages/core/src/blocks/ListItem/ToggleListItem/block.ts +++ b/packages/core/src/blocks/ListItem/ToggleListItem/block.ts @@ -6,7 +6,6 @@ import { parseDefaultProps, } from "../../defaultProps.js"; import { getDetailsContent } from "../../getDetailsContent.js"; -import { createToggleWrapper } from "../../ToggleWrapper/createToggleWrapper.js"; import { handleEnter } from "../../utils/listItemEnterHandler.js"; export type ToggleListItemBlockConfig = ReturnType< @@ -29,6 +28,7 @@ export const createToggleListItemBlockSpec = createBlockSpec( { meta: { isolating: false, + collapsible: true, }, parse(element) { if (element.tagName === "DETAILS") { @@ -71,14 +71,13 @@ 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, + }; }, toExternalHTML(block) { const li = document.createElement("li"); 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/index.ts b/packages/core/src/blocks/index.ts index d40bba055c..59be0f764b 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 "./File/helpers/uploadToTmpFilesDotOrg_DEV_ONLY.js"; export * from "./PageBreak/getPageBreakSlashMenuItems.js"; diff --git a/packages/core/src/blocks/utils/listItemEnterHandler.ts b/packages/core/src/blocks/utils/listItemEnterHandler.ts index 12e558a453..41e3a45632 100644 --- a/packages/core/src/blocks/utils/listItemEnterHandler.ts +++ b/packages/core/src/blocks/utils/listItemEnterHandler.ts @@ -2,6 +2,7 @@ import { splitBlockTr } from "../../api/blockManipulation/commands/splitBlock/sp import { updateBlockTr } from "../../api/blockManipulation/commands/updateBlock/updateBlock.js"; import { getBlockInfoFromSelection } from "../../api/getBlockInfoFromPos.js"; import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { handleCollapsibleEnter } from "../../extensions/Collapsible/collapsibleEnter.js"; export const handleEnter = ( editor: BlockNoteEditor, @@ -23,6 +24,12 @@ export const handleEnter = ( return false; } + // Has to happen here rather than in the editor-wide Enter handling, which + // this handler runs ahead of. + if (editor.transact((tr) => handleCollapsibleEnter(editor, tr))) { + return true; + } + if (blockContent.node.childCount === 0) { editor.transact((tr) => { updateBlockTr(tr, blockContainer.beforePos, { diff --git a/packages/core/src/editor/Block.css b/packages/core/src/editor/Block.css index ef2867121d..4ce3fc43fa 100644 --- a/packages/core/src/editor/Block.css +++ b/packages/core/src/editor/Block.css @@ -306,41 +306,67 @@ NESTED BLOCKS justify-content: flex-end; } -/* Toggle */ -.bn-block:has( - > .bn-block-content > div > .bn-toggle-wrapper[data-show-children="false"] - ) - > .bn-block-group, -.bn-block:has( - > .react-renderer - > .bn-block-content - > div - > .bn-toggle-wrapper[data-show-children="false"] - ) - > .bn-block-group { +/* Collapsible blocks (toggle headings, toggle list items). All of this comes + from `CollapsibleExtension`: the buttons are widget decorations, so they sit + inside `.bn-block` alongside the content, and the `data-` attributes are node + decorations, which ProseMirror puts on `.bn-block-outer`. */ + +.bn-block-outer[data-collapsed="true"] > .bn-block > .bn-block-group { display: none; } -.bn-toggle-wrapper { - display: flex; +/* The chevron sits left of the content; everything below wraps onto its own + line. */ +.bn-block-outer[data-collapsible="true"] > .bn-block { align-items: center; + flex-direction: row; + flex-wrap: wrap; +} + +.bn-block-outer[data-collapsible="true"] > .bn-block > .bn-block-content { + flex: 1 1 0; + min-width: 0; + width: auto; +} + +.bn-block-outer[data-collapsible="true"] > .bn-block > .bn-collapse-add-block, +.bn-block-outer[data-collapsible="true"] > .bn-block > .bn-block-group { + flex: 0 0 100%; } -.bn-toggle-button { +.bn-collapse-button { color: var(--bn-colors-editor-text); padding: 3px; } -.bn-toggle-button > svg { +/* Headings pad 18px at the top rather than 3px (see HEADINGS above), which + lifts the centred chevron off the text; this pushes it back down by half the + difference. */ +.bn-block-outer[data-collapsible="true"]:has( + > .bn-block > .bn-block-content[data-content-type="heading"] + ) + > .bn-block + > .bn-collapse-button { + margin-top: 15px; +} + +.bn-collapse-button > svg { width: 18px; height: 18px; } -.bn-toggle-wrapper[data-show-children="true"] .bn-toggle-button { +.bn-block-outer[data-collapsible="true"]:not([data-collapsed="true"]) + > .bn-block + > .bn-collapse-button { transform: rotate(90deg); } -.bn-toggle-add-block-button { +.bn-collapse-button:disabled { + cursor: default; + opacity: 0.35; +} + +.bn-collapse-add-block-button { font-size: 16px; color: var(--bn-colors-side-menu); font-weight: normal; @@ -349,8 +375,8 @@ NESTED BLOCKS width: fit-content; } -.bn-toggle-button, -.bn-toggle-add-block-button { +.bn-collapse-button, +.bn-collapse-add-block-button { background: none; border: none; border-radius: var(--bn-border-radius-small); @@ -359,8 +385,8 @@ NESTED BLOCKS user-select: none; } -.bn-toggle-button:hover, -.bn-toggle-add-block-button:hover { +.bn-collapse-button:not(:disabled):hover, +.bn-collapse-add-block-button:hover { background-color: var(--bn-colors-hovered-background); } diff --git a/packages/core/src/editor/managers/ExtensionManager/extensions.ts b/packages/core/src/editor/managers/ExtensionManager/extensions.ts index 853cca2493..396e064260 100644 --- a/packages/core/src/editor/managers/ExtensionManager/extensions.ts +++ b/packages/core/src/editor/managers/ExtensionManager/extensions.ts @@ -11,6 +11,7 @@ import { createPasteFromClipboardExtension } from "../../../api/clipboard/fromCl import { createCopyToClipboardExtension } from "../../../api/clipboard/toClipboard/copyExtension.js"; import { BlockChangeExtension, + CollapsibleExtension, DropCursorExtension, FilePanelExtension, FormattingToolbarExtension, @@ -155,6 +156,7 @@ export function getDefaultExtensions( ) { const extensions = [ BlockChangeExtension(), + CollapsibleExtension(), DropCursorExtension(options), FilePanelExtension(options), FormattingToolbarExtension(options), diff --git a/packages/core/src/extensions/Collapsible/Collapsible.test.ts b/packages/core/src/extensions/Collapsible/Collapsible.test.ts new file mode 100644 index 0000000000..5f30b50f86 --- /dev/null +++ b/packages/core/src/extensions/Collapsible/Collapsible.test.ts @@ -0,0 +1,407 @@ +import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; + +import type { PartialBlock } from "../../blocks/defaultBlocks.js"; +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { CollapsibleExtension } from "./Collapsible.js"; + +/** + * @vitest-environment jsdom + */ + +// Editors are unmounted after each test — otherwise prosemirror-view's +// DOMObserver leaves a setTimeout alive that fires after vitest tears down +// jsdom. +const activeEditors: BlockNoteEditor[] = []; + +beforeEach(() => { + // Collapse state is persisted per block ID, so it would otherwise leak + // between tests that reuse an ID. + window.localStorage.clear(); +}); + +afterEach(() => { + while (activeEditors.length) { + activeEditors.pop()!.unmount(); + } + window.localStorage.clear(); +}); + +function createEditor(initialContent: PartialBlock[]) { + const editor = BlockNoteEditor.create({ initialContent }); + editor.mount(document.createElement("div")); + activeEditors.push(editor); + + return editor; +} + +/** Collapse state lives on the extension, not on the editor. */ +function collapsible(editor: BlockNoteEditor) { + return editor.getExtension(CollapsibleExtension)!; +} + +function blockOuter(editor: BlockNoteEditor, id: string) { + const element = editor.prosemirrorView.dom.querySelector( + `.bn-block-outer[data-id="${id}"]`, + ); + if (!element) { + throw new Error(`No block with ID ${id} rendered`); + } + + return element; +} + +function chevron(editor: BlockNoteEditor, id: string) { + return blockOuter(editor, id).querySelector( + ":scope > .bn-block > .bn-collapse-button", + ); +} + +function addChildButton(editor: BlockNoteEditor, id: string) { + return blockOuter(editor, id).querySelector( + ":scope > .bn-block > .bn-collapse-add-block > .bn-collapse-add-block-button", + ); +} + +/** + * Presses Enter the way ProseMirror does. `commands.keyboardShortcut()` isn't + * equivalent: it replays only the *steps* a handler dispatched, so a selection + * the handler set is dropped. + */ +function pressEnter(editor: BlockNoteEditor) { + const view = editor.prosemirrorView; + const event = new KeyboardEvent("keydown", { + key: "Enter", + code: "Enter", + bubbles: true, + cancelable: true, + }); + + view.someProp("handleKeyDown", (handler) => handler(view, event)); +} + +const TOGGLE_HEADING: PartialBlock = { + id: "toggle-heading", + type: "heading", + props: { level: 2, isToggleable: true }, + content: "Toggle Heading", +}; + +const TOGGLE_HEADING_WITH_CHILD: PartialBlock[] = [ + { + ...TOGGLE_HEADING, + children: [{ id: "child", type: "paragraph", content: "Child" }], + }, + { id: "after", type: "paragraph", content: "After" }, +]; + +describe("CollapsibleExtension", () => { + it("collapses and expands without touching the document", () => { + const editor = createEditor(TOGGLE_HEADING_WITH_CHILD); + const before = JSON.stringify(editor.document); + const outer = () => blockOuter(editor, "toggle-heading"); + + // Collapsible blocks start collapsed. + expect(collapsible(editor).isCollapsed({ id: "toggle-heading" })).toBe( + true, + ); + expect(outer().getAttribute("data-collapsed")).toBe("true"); + expect(outer().getAttribute("data-collapsible")).toBe("true"); + // The chevron is the disclosure control, so it's what carries the state + // for screen readers. + expect( + chevron(editor, "toggle-heading")!.getAttribute("aria-expanded"), + ).toBe("false"); + + collapsible(editor).setCollapsed({ id: "toggle-heading" }, false); + expect(collapsible(editor).isCollapsed({ id: "toggle-heading" })).toBe( + false, + ); + expect(outer().hasAttribute("data-collapsed")).toBe(false); + expect( + chevron(editor, "toggle-heading")!.getAttribute("aria-expanded"), + ).toBe("true"); + + collapsible(editor).setCollapsed({ id: "toggle-heading" }, true); + expect(outer().getAttribute("data-collapsed")).toBe("true"); + + expect(JSON.stringify(editor.document)).toBe(before); + }); + + it("clicking the chevron toggles the block", () => { + const editor = createEditor(TOGGLE_HEADING_WITH_CHILD); + + chevron(editor, "toggle-heading")!.click(); + expect(collapsible(editor).isCollapsed({ id: "toggle-heading" })).toBe( + false, + ); + + chevron(editor, "toggle-heading")!.click(); + expect(collapsible(editor).isCollapsed({ id: "toggle-heading" })).toBe( + true, + ); + }); + + it("honours a `toggle-${id}` entry written by a previous session", () => { + window.localStorage.setItem("toggle-toggle-heading", "true"); + + const editor = createEditor(TOGGLE_HEADING_WITH_CHILD); + + expect(collapsible(editor).isCollapsed({ id: "toggle-heading" })).toBe( + false, + ); + }); + + it("only decorates blocks whose spec opts in", () => { + const editor = createEditor([ + { id: "plain", type: "paragraph", content: "Paragraph" }, + { + id: "plain-heading", + type: "heading", + props: { level: 1 }, + content: "Heading", + }, + ...TOGGLE_HEADING_WITH_CHILD, + ]); + + expect(blockOuter(editor, "plain").hasAttribute("data-collapsible")).toBe( + false, + ); + expect(chevron(editor, "plain")).toBe(null); + expect(chevron(editor, "plain-heading")).toBe(null); + expect(chevron(editor, "toggle-heading")).not.toBe(null); + }); + + // #2124 + it("drops the chevron when a toggle heading is converted to a plain heading", () => { + const editor = createEditor(TOGGLE_HEADING_WITH_CHILD); + + editor.updateBlock("toggle-heading", { + type: "heading", + props: { isToggleable: false }, + }); + + expect(chevron(editor, "toggle-heading")).toBe(null); + // The block no longer folds, so its child is visible again. + expect( + blockOuter(editor, "toggle-heading").hasAttribute("data-collapsed"), + ).toBe(false); + }); + + it("still renders where reading `localStorage` throws", () => { + const original = Object.getOwnPropertyDescriptor(window, "localStorage"); + // How a server-side render sees it: JSDOM documents have an opaque origin, + // and accessing `localStorage` on one throws a SecurityError. + Object.defineProperty(window, "localStorage", { + configurable: true, + get() { + throw new Error("localStorage is not available for opaque origins"); + }, + }); + + try { + const editor = createEditor(TOGGLE_HEADING_WITH_CHILD); + + expect(chevron(editor, "toggle-heading")).not.toBe(null); + expect(collapsible(editor).isCollapsed({ id: "toggle-heading" })).toBe( + true, + ); + } finally { + Object.defineProperty(window, "localStorage", original!); + } + }); + + it("expands a collapsed block that gains a child", () => { + const editor = createEditor([ + { id: "toggle", type: "toggleListItem", content: "Toggle" }, + ]); + expect(collapsible(editor).isCollapsed({ id: "toggle" })).toBe(true); + + editor.updateBlock("toggle", { + children: [{ type: "paragraph", content: "Child" }], + }); + + // Otherwise the new child would be added straight into hidden content and + // look like it had been deleted. + expect(collapsible(editor).isCollapsed({ id: "toggle" })).toBe(false); + }); + + describe("add child affordance", () => { + it("is offered only while expanded, until the editor goes read-only", () => { + const editor = createEditor([TOGGLE_HEADING]); + + // Collapsed to start with, so nothing is offered yet. + expect(addChildButton(editor, "toggle-heading")).toBe(null); + + collapsible(editor).setCollapsed({ id: "toggle-heading" }, false); + expect(addChildButton(editor, "toggle-heading")).not.toBe(null); + // Expanding does reveal something, so the chevron isn't inert. + expect(chevron(editor, "toggle-heading")!.disabled).toBe(false); + + editor.isEditable = false; + expect(addChildButton(editor, "toggle-heading")).toBe(null); + // Nothing to reveal now: no children, and no way to add one. + expect(chevron(editor, "toggle-heading")!.disabled).toBe(true); + }); + + it("is not offered by a block that isn't collapsible, or one with children", () => { + const editor = createEditor([ + { id: "plain", type: "paragraph", content: "Paragraph" }, + ...TOGGLE_HEADING_WITH_CHILD, + ]); + collapsible(editor).setCollapsed({ id: "toggle-heading" }, false); + + expect(addChildButton(editor, "plain")).toBe(null); + expect(addChildButton(editor, "toggle-heading")).toBe(null); + }); + + it("adds a single child block in one undo step", () => { + const editor = createEditor([ + { id: "toggle", type: "toggleListItem", content: "Toggle" }, + ]); + collapsible(editor).setCollapsed({ id: "toggle" }, false); + + addChildButton(editor, "toggle")!.click(); + + const toggle = editor.document[0]; + expect(toggle.children).toHaveLength(1); + expect(toggle.children[0].type).toBe("paragraph"); + // The new child is where the cursor is. + expect(editor.getTextCursorPosition().block.id).toBe( + toggle.children[0].id, + ); + + editor.undo(); + expect(editor.document[0].children).toHaveLength(0); + }); + }); + + describe("Enter at the end of the title", () => { + // #1875 — the children are on screen, so that's where the next block + // visibly belongs. + it("starts a first child when the block is expanded, in one undo step", () => { + const editor = createEditor([ + { id: "toggle", type: "toggleListItem", content: "Toggle" }, + { id: "after", type: "paragraph", content: "After" }, + ]); + collapsible(editor).setCollapsed({ id: "toggle" }, false); + editor.setTextCursorPosition("toggle", "end"); + + pressEnter(editor); + + expect(editor.document.map((block) => block.id)).toEqual([ + "toggle", + "after", + ]); + + const toggle = editor.document[0]; + expect(toggle.children).toHaveLength(1); + expect(toggle.children[0].type).toBe("paragraph"); + expect(toggle.children[0].content).toEqual([]); + expect(editor.getTextCursorPosition().block.id).toBe( + toggle.children[0].id, + ); + + editor.undo(); + expect(editor.document[0].children).toHaveLength(0); + }); + + // #2378 — a new child would be hidden, so it'd look like nothing happened. + // Splitting off a sibling is what Notion does here, and the children stay + // with the block they belong to. + it("splits off a sibling when the block is collapsed", () => { + const editor = createEditor([ + { + id: "toggle", + type: "toggleListItem", + content: "Toggle", + children: [{ id: "child", type: "paragraph", content: "Child" }], + }, + ]); + expect(collapsible(editor).isCollapsed({ id: "toggle" })).toBe(true); + editor.setTextCursorPosition("toggle", "end"); + + pressEnter(editor); + + expect(editor.document).toHaveLength(2); + expect(editor.document[0].id).toBe("toggle"); + expect(editor.document[0].children.map((child) => child.id)).toEqual([ + "child", + ]); + expect(editor.document[1].type).toBe("toggleListItem"); + expect(editor.document[1].children).toHaveLength(0); + }); + + it("does the same on a toggle heading, and nothing on a plain one", () => { + const editor = createEditor([ + TOGGLE_HEADING, + { id: "heading", type: "heading", props: { level: 2 }, content: "H" }, + ]); + collapsible(editor).setCollapsed({ id: "toggle-heading" }, false); + + editor.setTextCursorPosition("toggle-heading", "end"); + pressEnter(editor); + expect(editor.document[0].children).toHaveLength(1); + expect(editor.getTextCursorPosition().block.id).toBe( + editor.document[0].children[0].id, + ); + + editor.setTextCursorPosition("heading", "end"); + pressEnter(editor); + const heading = editor.document.find((block) => block.id === "heading")!; + expect(heading.children).toHaveLength(0); + expect(editor.document).toHaveLength(3); + }); + + it("puts the new child first and leaves existing children in place", () => { + const editor = createEditor([ + { + id: "toggle", + type: "toggleListItem", + content: "Toggle", + children: [ + { id: "child-0", type: "paragraph", content: "Child 0" }, + { id: "child-1", type: "paragraph", content: "Child 1" }, + ], + }, + ]); + collapsible(editor).setCollapsed({ id: "toggle" }, false); + editor.setTextCursorPosition("toggle", "end"); + + pressEnter(editor); + + const children = editor.document[0].children; + expect(children).toHaveLength(3); + expect(children[0].content).toEqual([]); + expect(children.slice(1).map((child) => child.id)).toEqual([ + "child-0", + "child-1", + ]); + }); + + it("leaves the block's own Enter handling to run elsewhere in the title", () => { + const editor = createEditor([ + { id: "empty", type: "toggleListItem", content: "" }, + { + id: "toggle", + type: "toggleListItem", + content: "Toggle", + children: [{ id: "child", type: "paragraph", content: "Child" }], + }, + ]); + collapsible(editor).setCollapsed({ id: "empty" }, false); + collapsible(editor).setCollapsed({ id: "toggle" }, false); + + // An empty list item becomes a paragraph, as other list items do. + editor.setTextCursorPosition("empty", "end"); + pressEnter(editor); + expect(editor.document[0].type).toBe("paragraph"); + expect(editor.document[0].children).toHaveLength(0); + + // A cursor part-way through the title splits it, keeping the children. + editor.setTextCursorPosition("toggle", "start"); + pressEnter(editor); + const toggle = editor.document.find((block) => block.id === "toggle")!; + expect(toggle.children.map((child) => child.id)).toEqual(["child"]); + }); + }); +}); diff --git a/packages/core/src/extensions/Collapsible/Collapsible.ts b/packages/core/src/extensions/Collapsible/Collapsible.ts new file mode 100644 index 0000000000..6a5d98bdab --- /dev/null +++ b/packages/core/src/extensions/Collapsible/Collapsible.ts @@ -0,0 +1,326 @@ +import type { Slice } from "prosemirror-model"; +import { PluginKey } from "prosemirror-state"; +import { Decoration, DecorationSet, type EditorView } from "prosemirror-view"; + +import { insertEmptyFirstChild } from "../../api/blockManipulation/commands/materializeChildren/materializeChildren.js"; +import { getNearestBlockPos } from "../../api/getBlockInfoFromPos.js"; +import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { createExtension } from "../../editor/BlockNoteExtension.js"; +import { + createBlockDecorationPlugin, + INVALIDATE_BLOCK_DECORATIONS, +} from "./blockDecorations.js"; +import { + getCollapsibleDropTargetPos, + handleCollapsibleDrop, +} from "./collapsibleDrop.js"; + +export const collapsiblePluginKey = new PluginKey( + "blocknote-collapsible", +); + +/** + * Where a block's collapse state is read from and written to. Collapse state is + * per-user view state, so it lives outside the document. + */ +export type ToggledState = { + set: (block: { id: string }, isToggled: boolean) => void; + get: (block: { id: string }) => boolean; +}; + +const inMemoryToggledState = new Map(); + +const inMemoryStorage: Pick = { + getItem: (key) => inMemoryToggledState.get(key) ?? null, + setItem: (key, value) => void inMemoryToggledState.set(key, value), +}; + +/** + * `localStorage`, or an in-memory stand-in wherever it can't be used. Reading it + * throws on an opaque origin (how a server-side render sees a JSDOM document), + * and writing it throws where storage is disabled or full (private browsing, + * quota). Collapse state is presentational, so each operation degrades to the + * in-memory map rather than taking the surrounding transaction — or render — + * down with it. + */ +function collapseStorage(): Pick { + let storage: Storage | undefined; + try { + if (typeof window !== "undefined" && window.localStorage) { + storage = window.localStorage; + } + } catch { + // Falls through to the in-memory map. + } + + if (!storage) { + return inMemoryStorage; + } + + return { + getItem: (key) => { + try { + return storage.getItem(key); + } catch { + return inMemoryStorage.getItem(key); + } + }, + setItem: (key, value) => { + try { + storage.setItem(key, value); + } catch { + inMemoryStorage.setItem(key, value); + } + }, + }; +} + +export const defaultToggledState: ToggledState = { + set: (block, isToggled: boolean) => + collapseStorage().setItem( + `toggle-${block.id}`, + isToggled ? "true" : "false", + ), + get: (block) => collapseStorage().getItem(`toggle-${block.id}`) === "true", +}; + +export type CollapsibleOptions = { + /** + * Overrides where collapse state is persisted. Defaults to `localStorage`, + * keyed by block ID. + */ + toggledState?: ToggledState; +}; + +/** + * Whether a block of `type` with `props` declares itself collapsible. + * + * Shared with the HTML exporter, which has to reproduce what the extension + * renders without an editor view to read decorations from. + */ +export function isBlockCollapsible( + editor: BlockNoteEditor, + type: string, + props: Record, +): boolean { + const collapsible = + editor.schema.blockSpecs[type]?.implementation?.meta?.collapsible; + + return typeof collapsible === "function" + ? collapsible({ type, props }) + : !!collapsible; +} + +/** + * The chevron shown to the left of a collapsible block's content. It's the + * disclosure control for the block's children, so it carries `aria-expanded`. + */ +export function createCollapseButton(expanded: boolean): HTMLButtonElement { + const button = document.createElement("button"); + button.className = "bn-collapse-button"; + button.type = "button"; + button.setAttribute("aria-expanded", expanded ? "true" : "false"); + button.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 + ''; + + return button; +} + +/** + * Makes blocks collapsible. Blocks opt in via `meta.collapsible`, so this needs + * no knowledge of specific block types. + * + * Everything it renders is a decoration, never a document change — collapse is + * per-user state, and putting it in the document would push it into Yjs, undo + * history, and exports. + */ +export const CollapsibleExtension = createExtension( + ({ + editor, + options, + }: { + editor: BlockNoteEditor; + options: CollapsibleOptions | undefined; + }) => { + const toggledState = options?.toggledState ?? defaultToggledState; + + const isExpanded = (id: string) => toggledState.get({ id }); + + // Child counts as of each block's last decoration, so that a block which + // gains one can expand rather than appear to swallow it. + const childCounts = new Map(); + + /** Redraws the decorations, for state that isn't in the document. */ + function invalidate() { + // A headless editor has no view to redraw. + if (editor.headless) { + return; + } + + editor.transact((tr) => tr.setMeta(INVALIDATE_BLOCK_DECORATIONS, true)); + } + + // Editability decides whether the "add a block" button renders, and so + // whether the chevron is inert. Changing it doesn't dispatch a transaction + // of its own, but tiptap emits an update for it, which is what `onChange` + // listens to. + let lastEditable: boolean; + editor.onMount(() => { + lastEditable = editor.isEditable; + }); + editor.onChange(() => { + if (editor.isEditable !== lastEditable) { + lastEditable = editor.isEditable; + invalidate(); + } + }); + + function createChevron(id: string, expanded: boolean, disabled: boolean) { + const button = createCollapseButton(expanded); + button.disabled = disabled; + // Keeps the editor's selection (and focus) where it was. + button.addEventListener("mousedown", (event) => event.preventDefault()); + button.addEventListener("click", () => { + toggledState.set({ id }, !isExpanded(id)); + invalidate(); + }); + + return button; + } + + /** + * The "add a block" button shown under an expanded collapsible block with no + * children, so its chevron has something to reveal. Wrapped because the + * wrapper takes up a full line of the block's flex layout; the button itself + * stays only as wide as its label. + */ + function createAddBlockButton(getPos: () => number | undefined) { + const wrapper = document.createElement("div"); + wrapper.className = "bn-collapse-add-block"; + + const button = document.createElement("button"); + button.className = "bn-collapse-add-block-button"; + button.type = "button"; + button.textContent = editor.dictionary.toggle_blocks.add_block_button; + // Keeps the editor's selection (and focus) where it was. + button.addEventListener("mousedown", (event) => event.preventDefault()); + button.addEventListener("click", () => { + const pos = getPos(); + if (pos === undefined) { + return; + } + + editor.transact((tr) => + insertEmptyFirstChild( + tr, + getNearestBlockPos(tr.doc, pos).posBeforeNode, + ), + ); + editor.focus(); + }); + + wrapper.appendChild(button); + + return wrapper; + } + + const collapsiblePlugin = createBlockDecorationPlugin( + collapsiblePluginKey, + (info, pos, id) => { + const props = info.blockContent.node.attrs; + if (!isBlockCollapsible(editor, info.blockNoteType, props)) { + return []; + } + + const childCount = info.childContainer?.node.childCount ?? 0; + if (childCount > (childCounts.get(id) ?? childCount)) { + toggledState.set({ id }, true); + } + childCounts.set(id, childCount); + + const expanded = isExpanded(id); + // Nothing to reveal, and no "add a block" button either. + const disabled = childCount === 0 && !editor.isEditable; + + const decorations = [ + Decoration.node( + pos, + pos + info.bnBlock.node.nodeSize, + { + // For the CSS; screen readers use the chevron's `aria-expanded`. + "data-collapsible": "true", + ...(expanded ? {} : { "data-collapsed": "true" }), + }, + { blockId: id }, + ), + Decoration.widget( + pos + 1, + () => createChevron(id, expanded, disabled), + { + blockId: id, + side: -1, + // Everything the button's DOM depends on, so ProseMirror reuses + // it until one of them changes. + key: `bn-collapse-button:${id}:${expanded}:${disabled}`, + }, + ), + ]; + + // An expanded block with no children gets an "add a block" button, so + // its chevron has something to reveal. + if (expanded && editor.isEditable && childCount === 0) { + decorations.push( + Decoration.widget( + info.blockContent.afterPos, + (_view, getPos) => createAddBlockButton(getPos), + { blockId: id, side: 1, key: `bn-collapse-add-block:${id}` }, + ), + ); + } + + return decorations; + }, + { + handleDrop(view, event, slice, moved) { + return handleCollapsibleDrop( + getCollapsibleDropTargetPos( + editor, + isExpanded, + view, + event as DragEvent, + slice, + ), + view, + event as DragEvent, + slice, + moved, + ); + }, + }, + ); + + return { + key: "collapsible", + /** + * Whether `block`'s children are currently hidden. Collapsible blocks + * start collapsed. + */ + isCollapsed: (block: { id: string }) => !isExpanded(block.id), + /** + * Collapses or expands `block`. This only changes what the user sees — + * the document is untouched. + */ + setCollapsed: (block: { id: string }, collapsed: boolean) => { + toggledState.set({ id: block.id }, !collapsed); + invalidate(); + }, + getDropTargetPos: ( + view: EditorView, + event: { clientX: number; clientY: number }, + slice: Slice | undefined | null, + ) => getCollapsibleDropTargetPos(editor, isExpanded, view, event, slice), + prosemirrorPlugins: [collapsiblePlugin], + } as const; + }, +); diff --git a/packages/core/src/extensions/Collapsible/blockDecorations.ts b/packages/core/src/extensions/Collapsible/blockDecorations.ts new file mode 100644 index 0000000000..ddef28c1f3 --- /dev/null +++ b/packages/core/src/extensions/Collapsible/blockDecorations.ts @@ -0,0 +1,101 @@ +import { Plugin, type PluginKey, type Transaction } from "prosemirror-state"; +import { Decoration, DecorationSet, type EditorProps } from "prosemirror-view"; + +import { getBlockInfoWithManualOffset } from "../../api/getBlockInfoFromPos.js"; +import { getChangedRange } from "../../api/getChangedRange.js"; + +/** A `blockContainer`, as resolved during the walk below. */ +export type BlockContainerInfo = Extract< + ReturnType, + { isBlockContainer: true } +>; + +/** + * The decorations one block needs right now, or none. Every decoration must + * carry a `blockId` spec, so the stale ones can be found again. + */ +export type BlockDecorator = ( + info: BlockContainerInfo, + pos: number, + id: string, +) => Decoration[]; + +function nextDecorationSet( + tr: Transaction, + oldSet: DecorationSet, + rescanAll: boolean, + decorate: BlockDecorator, +): DecorationSet { + const mapped = oldSet.map(tr.mapping, tr.doc); + // What a block is decorated with depends on its own props and on how many + // children it has, both of which live inside it, so only blocks overlapping + // the changed range need rescanning — `nodesBetween` visits those plus every + // ancestor spanning them. `getChangedRange` rather than `tr.changedRange()`, + // because a prop-only update is an `AttrStep`, which the latter misses. + const range = (rescanAll ? null : getChangedRange(tr)) ?? { + from: 0, + to: tr.doc.content.size, + }; + + const rescanned = new Set(); + const added: Decoration[] = []; + + tr.doc.nodesBetween(range.from, range.to, (node, pos) => { + if (node.type.name !== "blockContainer") { + // `blockGroup`, `column` and `columnList` hold blocks; block and inline + // content do not, so this walks blocks rather than document content. + return node.type.isInGroup("childContainer"); + } + + const info = getBlockInfoWithManualOffset(node, pos); + const id = node.attrs.id; + + if (info.isBlockContainer && id) { + // Recorded even when `decorate` returns nothing, so that a block which + // stopped wanting decorations has its old ones dropped. + rescanned.add(id); + added.push(...decorate(info, pos, id)); + } + + return true; + }); + + const stale = mapped.find(undefined, undefined, (spec) => + rescanned.has(spec.blockId), + ); + + return mapped.remove(stale).add(tr.doc, added); +} + +/** + * Transaction meta that makes every block-decoration plugin rescan the whole + * document, for state the document itself doesn't hold. + */ +export const INVALIDATE_BLOCK_DECORATIONS = "bn-invalidate-block-decorations"; + +/** + * A plugin that decorates blocks, rebuilding only what changed: decorations are + * mapped through each transaction and `decorate` re-runs for the blocks that + * transaction touched. See {@link INVALIDATE_BLOCK_DECORATIONS} for the rest. + */ +export function createBlockDecorationPlugin( + key: PluginKey, + decorate: BlockDecorator, + props?: EditorProps, +) { + return new Plugin({ + key, + state: { + init: (_config, state) => + nextDecorationSet(state.tr, DecorationSet.empty, true, decorate), + apply: (tr, oldSet) => { + const invalidated = !!tr.getMeta(INVALIDATE_BLOCK_DECORATIONS); + + return tr.docChanged || invalidated + ? nextDecorationSet(tr, oldSet, invalidated, decorate) + : oldSet; + }, + }, + props: { decorations: (state) => key.getState(state), ...props }, + }); +} diff --git a/packages/core/src/extensions/Collapsible/collapsibleDrop.ts b/packages/core/src/extensions/Collapsible/collapsibleDrop.ts new file mode 100644 index 0000000000..b1251b556e --- /dev/null +++ b/packages/core/src/extensions/Collapsible/collapsibleDrop.ts @@ -0,0 +1,119 @@ +import type { Node as PMNode, Slice } from "prosemirror-model"; +import type { EditorView } from "prosemirror-view"; + +import { materializeChildren } from "../../api/blockManipulation/commands/materializeChildren/materializeChildren.js"; +import { + getBlockInfo, + getNearestBlockPos, +} from "../../api/getBlockInfoFromPos.js"; +import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { isBlockCollapsible } from "./Collapsible.js"; + +/** The whole blocks `slice` consists of, if that's all it is. */ +function getSliceBlocks(slice: Slice | undefined | null): PMNode[] | undefined { + if (!slice || slice.openStart !== 0 || slice.openEnd !== 0) { + return undefined; + } + + const blocks: PMNode[] = []; + slice.content.forEach((node) => blocks.push(node)); + + return blocks.length > 0 && + blocks.every((node) => node.type.isInGroup("blockGroupChild")) + ? blocks + : undefined; +} + +/** + * The position of a block a drop should land *inside* of rather than next to — + * an expanded, childless collapsible block, whose missing `blockGroup` leaves + * `dropPoint` nothing to find. The drop cursor and the drop handler both come + * through here, so they can't disagree. + */ +export function getCollapsibleDropTargetPos( + editor: BlockNoteEditor, + isExpanded: (id: string) => boolean, + view: EditorView, + event: { clientX: number; clientY: number }, + slice: Slice | undefined | null, +): number | undefined { + if (!editor.isEditable || !getSliceBlocks(slice)) { + return undefined; + } + + const coords = view.posAtCoords({ left: event.clientX, top: event.clientY }); + if (!coords) { + return undefined; + } + + const blockPos = getNearestBlockPos( + view.state.doc, + coords.inside >= 0 ? coords.inside : coords.pos, + ); + const info = getBlockInfo(blockPos); + + if ( + !info.isBlockContainer || + info.childContainer || + !info.bnBlock.node.attrs.id || + !isBlockCollapsible( + editor, + info.blockNoteType, + info.blockContent.node.attrs, + ) || + !isExpanded(info.bnBlock.node.attrs.id) + ) { + return undefined; + } + + // Only over the block's own content, so the space below it stays a normal + // "drop as a sibling" region. + const contentDOM = view.nodeDOM(info.blockContent.beforePos); + if (!(contentDOM instanceof HTMLElement)) { + return undefined; + } + + const rect = contentDOM.getBoundingClientRect(); + + return event.clientY < rect.top || event.clientY > rect.bottom + ? undefined + : blockPos.posBeforeNode; +} + +/** + * Drops the dragged blocks into the block under the pointer, as its children. + * + * @returns Whether the drop was handled. + */ +export function handleCollapsibleDrop( + targetPos: number | undefined, + view: EditorView, + event: DragEvent, + slice: Slice | undefined | null, + moved: boolean, +): boolean { + const blocks = getSliceBlocks(slice); + if (targetPos === undefined || !blocks) { + return false; + } + + const targetId = view.state.doc.nodeAt(targetPos)?.attrs.id; + const tr = view.state.tr; + + if (moved) { + tr.deleteSelection(); + } + + const mappedPos = tr.mapping.map(targetPos); + // Dragging a block onto itself deletes the drop target, so there's nothing + // left to drop into. + if (tr.doc.nodeAt(mappedPos)?.attrs.id !== targetId) { + return false; + } + + materializeChildren(tr, mappedPos, blocks); + view.dispatch(tr.setMeta("uiEvent", "drop")); + event.preventDefault(); + + return true; +} diff --git a/packages/core/src/extensions/Collapsible/collapsibleEnter.ts b/packages/core/src/extensions/Collapsible/collapsibleEnter.ts new file mode 100644 index 0000000000..79c8d0916b --- /dev/null +++ b/packages/core/src/extensions/Collapsible/collapsibleEnter.ts @@ -0,0 +1,56 @@ +import type { Transaction } from "prosemirror-state"; + +import { insertEmptyFirstChild } from "../../api/blockManipulation/commands/materializeChildren/materializeChildren.js"; +import { getBlockInfoFromSelection } from "../../api/getBlockInfoFromPos.js"; +import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { CollapsibleExtension, isBlockCollapsible } from "./Collapsible.js"; + +/** + * Enter at the end of an *expanded* collapsible block's title starts a first + * child rather than a sibling (#1875) — its children are on screen, so that's + * where the next block visibly belongs. A collapsed one splits off a sibling as + * usual, since a new child would be hidden. + * + * No-ops on anything else, so callers don't need to check first. + * + * @returns Whether the key press was handled. + */ +export function handleCollapsibleEnter( + editor: BlockNoteEditor, + tr: Transaction, +): boolean { + const info = getBlockInfoFromSelection(tr); + + if ( + !info.isBlockContainer || + !isBlockCollapsible( + editor, + info.blockNoteType, + info.blockContent.node.attrs, + ) + ) { + return false; + } + + const id = info.bnBlock.node.attrs.id; + if ( + !id || + editor.getExtension(CollapsibleExtension)?.isCollapsed({ id }) !== false + ) { + return false; + } + + // An empty block, or a cursor part-way through (or across) the title, is left + // to the block's own Enter handling: unindent or split. + if ( + !tr.selection.empty || + tr.selection.$anchor.parentOffset !== info.blockContent.node.content.size || + info.blockContent.node.childCount === 0 + ) { + return false; + } + + insertEmptyFirstChild(tr, info.bnBlock.beforePos); + + return true; +} diff --git a/packages/core/src/extensions/DropCursor/DropCursor.ts b/packages/core/src/extensions/DropCursor/DropCursor.ts index 77d24cc114..a64763c77a 100644 --- a/packages/core/src/extensions/DropCursor/DropCursor.ts +++ b/packages/core/src/extensions/DropCursor/DropCursor.ts @@ -8,8 +8,13 @@ import { hasExclusionClassname, type DropCursorPosition, } from "./utils.js"; +import { + getBlockInfo, + getNearestBlockPos, +} from "../../api/getBlockInfoFromPos.js"; import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import { createExtension } from "../../editor/BlockNoteExtension.js"; +import { CollapsibleExtension } from "../Collapsible/Collapsible.js"; export const DRAG_EXCLUSION_CLASSNAME = "bn-drag-exclude"; @@ -189,6 +194,23 @@ export const DropCursorExtension = createExtension< } } + // A collapsible block that's expanded and childless has no `blockGroup` + // yet, so `dropPoint` can't find a position inside it and would put the + // cursor next to it instead. `CollapsibleExtension` resolves the same + // target the drop handler will use, so the cursor can't end up somewhere + // other than where the block lands. + const collapsibleTargetPos = editor + .getExtension(CollapsibleExtension) + ?.getDropTargetPos(view, e, view.dragging?.slice); + if (collapsibleTargetPos !== undefined) { + const targetInfo = getBlockInfo( + getNearestBlockPos(view.state.doc, collapsibleTargetPos), + ); + if (targetInfo.isBlockContainer) { + target = targetInfo.blockContent.afterPos; + } + } + // Compute default position const $pos = view.state.doc.resolve(target); const isBlock = !$pos.parent.inlineContent; diff --git a/packages/core/src/extensions/index.ts b/packages/core/src/extensions/index.ts index eb1d455e33..9e456a4950 100644 --- a/packages/core/src/extensions/index.ts +++ b/packages/core/src/extensions/index.ts @@ -1,4 +1,5 @@ export * from "./BlockChange/BlockChange.js"; +export * from "./Collapsible/Collapsible.js"; export * from "./DropCursor/DropCursor.js"; export * from "./FilePanel/FilePanel.js"; export * from "./FormattingToolbar/FormattingToolbar.js"; diff --git a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts index 4d1758094a..aa927a505e 100644 --- a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts +++ b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts @@ -22,6 +22,7 @@ import { getBlockInfoFromSelection, } from "../../../api/getBlockInfoFromPos.js"; import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js"; +import { handleCollapsibleEnter } from "../../Collapsible/collapsibleEnter.js"; import { FilePanelExtension } from "../../FilePanel/FilePanel.js"; import { FormattingToolbarExtension } from "../../FormattingToolbar/FormattingToolbar.js"; @@ -915,6 +916,13 @@ export const KeyboardShortcutsExtension = Extension.create<{ return false; }), + // Starts a first child instead of splitting, at the end of an expanded + // collapsible block's title. Toggle list items don't reach this — their + // own Enter shortcut runs first, and calls the same handler. + () => + commands.command(({ tr }) => + handleCollapsibleEnter(this.options.editor, tr), + ), // Splits the current block, moving content inside that's after the cursor to a new text block below. Also // deletes the selection beforehand, if it's not empty. () => diff --git a/packages/core/src/schema/blocks/types.ts b/packages/core/src/schema/blocks/types.ts index 8d7e203e61..e1604f88e8 100644 --- a/packages/core/src/schema/blocks/types.ts +++ b/packages/core/src/schema/blocks/types.ts @@ -78,6 +78,20 @@ export interface BlockConfigMeta< * block's source is hidden behind its preview and edited via the popup. */ hasPreview?: boolean; + + /** + * Gives the block a chevron that hides its children, via + * `CollapsibleExtension`. Collapse state is per-user and never part of the + * document. + * + * The callback form opts in based on props (e.g. `heading` only when + * `props.isToggleable`). Like {@link highlight}, its parameter is untyped: a + * `TName`/`TProps`-typed one would make `BlockConfigMeta` contravariant and + * stop specs being collected into a schema. + */ + collapsible?: + | boolean + | ((block: { type: string; props: Record }) => boolean); } /** diff --git a/packages/react/src/blocks/ToggleWrapper/ToggleWrapper.tsx b/packages/react/src/blocks/ToggleWrapper/ToggleWrapper.tsx deleted file mode 100644 index b35fc8d779..0000000000 --- a/packages/react/src/blocks/ToggleWrapper/ToggleWrapper.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import { - Block, - BlockConfig, - blockHasType, - defaultToggledState, - UnreachableCaseError, -} from "@blocknote/core"; -import { ReactNode, useReducer } from "react"; - -import { useEditorState } from "../../hooks/useEditorState.js"; -import { ReactCustomBlockRenderProps } from "../../schema/ReactBlockSpec.js"; - -const showChildrenReducer = ( - showChildren: boolean, - action: - | { - type: "toggled"; - } - | { - type: "childAdded"; - } - | { - type: "lastChildRemoved"; - }, -) => { - 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/editor/styles.css b/packages/react/src/editor/styles.css index 507f2cd46f..13f63e4280 100644 --- a/packages/react/src/editor/styles.css +++ b/packages/react/src/editor/styles.css @@ -111,9 +111,11 @@ width: 100%; } -/* Indent line styling */ +/* Indent line styling. Collapsible blocks are excluded: their chevron already + shows where their children begin. */ .bn-block-group - .bn-block:not(:has(.bn-toggle-wrapper)) + .bn-block-outer:not([data-collapsible="true"]) + > .bn-block .bn-block-group .bn-block-outer:not([data-prev-depth-changed])::before { border-left: 1px solid var(--bn-colors-side-menu); 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/playground/src/examples.gen.tsx b/playground/src/examples.gen.tsx index 155a460786..8b732ab7d0 100644 --- a/playground/src/examples.gen.tsx +++ b/playground/src/examples.gen.tsx @@ -1419,7 +1419,7 @@ export const examples = { 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)", + "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 by setting `meta.collapsible` on the block spec, which `CollapsibleExtension` picks up.\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", diff --git a/tests/src/end-to-end/keyboardhandlers/__snapshots__/enterPreservesNestedBlocks.json b/tests/src/end-to-end/keyboardhandlers/__snapshots__/enterPreservesNestedBlocks.json index fe2cca639a..c849beeff2 100644 --- a/tests/src/end-to-end/keyboardhandlers/__snapshots__/enterPreservesNestedBlocks.json +++ b/tests/src/end-to-end/keyboardhandlers/__snapshots__/enterPreservesNestedBlocks.json @@ -25,28 +25,6 @@ "text": "H" } ] - } - ] - }, - { - "type": "blockContainer", - "attrs": { - "id": "3" - }, - "content": [ - { - "type": "paragraph", - "attrs": { - "backgroundColor": "default", - "textColor": "default", - "textAlignment": "left" - }, - "content": [ - { - "type": "text", - "text": "eading" - } - ] }, { "type": "blockGroup", @@ -102,6 +80,28 @@ ] } ] + }, + { + "type": "blockContainer", + "attrs": { + "id": "3" + }, + "content": [ + { + "type": "paragraph", + "attrs": { + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "eading" + } + ] + } + ] } ] } diff --git a/tests/src/end-to-end/static/static.test.tsx b/tests/src/end-to-end/static/static.test.tsx index 544b0c2fa4..f4c874e8ab 100644 --- a/tests/src/end-to-end/static/static.test.tsx +++ b/tests/src/end-to-end/static/static.test.tsx @@ -39,11 +39,16 @@ describe("Check static rendering", () => { // Playwright's `maxDiffPixels`: a small allowance for the image caption // text, which renders slightly differently (e.g. '×' vs 'x'). const masks = () => - ["video", "audio", 'input[type="checkbox"]', ".bn-toggle-button"] + ["video", "audio", 'input[type="checkbox"]', ".bn-collapse-button"] .flatMap((sel) => [...document.querySelectorAll(sel)]) .map((el) => page.elementLocator(el)); - const matchEquality = () => - expectElement(document.body).toMatchScreenshot( + const matchEquality = async () => { + // The code block's monospace webfont is only requested once a code + // block first paints, so the editor rendered first would otherwise be + // captured with the fallback font and the second one without it. + await document.fonts.ready; + + return expectElement(document.body).toMatchScreenshot( "static-rendering-equality", { comparatorOptions: { allowedMismatchedPixels: 200 }, @@ -54,6 +59,7 @@ describe("Check static rendering", () => { screenshotOptions: { scale: "css", mask: masks() }, }, ); + }; const liveEditor = await render(); await waitForSelector(EDITOR_SELECTOR); diff --git a/tests/src/end-to-end/toggle/toggle.test.tsx b/tests/src/end-to-end/toggle/toggle.test.tsx new file mode 100644 index 0000000000..d63fd870ca --- /dev/null +++ b/tests/src/end-to-end/toggle/toggle.test.tsx @@ -0,0 +1,180 @@ +import TestingApp from "@examples/01-basic/testing/src/App"; +import { describe, expect, test } from "vite-plus/test"; +import { render } from "vitest-browser-react"; +import { + DOC_TRAILING_BLOCK_SELECTOR, + DRAG_HANDLE_SELECTOR, + EDITOR_SELECTOR, + PARAGRAPH_SELECTOR, +} from "../../utils/const.js"; +import { browserName, userEvent } from "../../utils/context.js"; +import { focusOnEditor, sleep, waitForSelector } from "../../utils/editor.js"; +import { getRect, mouseSequence } from "../../utils/mouse.js"; +import { executeSlashCommand } from "../../utils/slashmenu.js"; + +const TOGGLE_SELECTOR = `[data-content-type="toggleListItem"]`; +const COLLAPSE_BUTTON_SELECTOR = `.bn-collapse-button`; +const ADD_BLOCK_BUTTON_SELECTOR = `.bn-collapse-add-block-button`; +const DROP_CURSOR_SELECTOR = `[class*="prosemirror-dropcursor"]`; + +/** The editor under test. Only `window.ProseMirror` is exposed globally. */ +function blockNoteEditor() { + return (window as any).ProseMirror.view.state.schema.cached.blockNoteEditor; +} + +/** The text of the block the selection is currently in. */ +function selectedBlockText(): string | undefined { + const { $from } = (window as any).ProseMirror.view.state.selection; + + for (let depth = $from.depth; depth > 0; depth--) { + const node = $from.node(depth); + if (node.type.name === "blockContainer") { + return node.firstChild?.textContent; + } + } + + return undefined; +} + +/** The editor's blocks, as a tree of `{ text, children }`. */ +function blockTree() { + type Entry = { text: string; children: Entry[] }; + + const walk = (group: any, into: Entry[]) => { + group.forEach((container: any) => { + const entry: Entry = { + text: container.firstChild.textContent, + children: [], + }; + into.push(entry); + + const childGroup = container.maybeChild(1); + if (childGroup) { + walk(childGroup, entry.children); + } + }); + }; + + const blocks: Entry[] = []; + walk((window as any).ProseMirror.state.doc.firstChild, blocks); + + return blocks; +} + +/** + * Creates a toggle list item holding `title` as the first block, expanded and + * with no children, followed by a paragraph holding `nextBlockText`. + */ +async function createExpandedEmptyToggle(title: string, nextBlockText: string) { + await render(); + await waitForSelector(EDITOR_SELECTOR); + await focusOnEditor(); + + await executeSlashCommand("Toggle List"); + await userEvent.keyboard(title); + + await userEvent.click(await waitForSelector(DOC_TRAILING_BLOCK_SELECTOR)); + await userEvent.keyboard(nextBlockText); + await sleep(100); + + await userEvent.click(await waitForSelector(COLLAPSE_BUTTON_SELECTOR)); + await waitForSelector(ADD_BLOCK_BUTTON_SELECTOR); +} + +describe("Toggle blocks", () => { + // #2109 (comment) — the "add a block" affordance used to swallow the caret, + // so ArrowDown out of an expanded empty toggle did nothing. + test("ArrowDown moves out of an expanded empty toggle", async () => { + await createExpandedEmptyToggle("Toggle", "After"); + + // Puts the caret at the end of the toggle's title. + await userEvent.click(await waitForSelector(TOGGLE_SELECTOR)); + await sleep(100); + expect(selectedBlockText()).toBe("Toggle"); + + await userEvent.keyboard("{ArrowDown}"); + await sleep(100); + + expect(selectedBlockText()).toBe("After"); + }); + + // #2109 — Playwright doesn't correctly simulate drag events in Firefox. + test.skipIf(browserName === "firefox")( + "a block dragged onto an expanded childless toggle becomes its child, where the drop cursor showed", + async () => { + await createExpandedEmptyToggle("Toggle", "Drag me"); + + const paragraphRect = getRect(await waitForSelector(PARAGRAPH_SELECTOR)); + + // Reveal the paragraph's drag handle, then pick it up. + await mouseSequence([ + { + type: "move", + x: paragraphRect.x + paragraphRect.width / 2, + y: paragraphRect.y + paragraphRect.height / 2, + steps: 5, + }, + ]); + await sleep(100); + + const handleRect = getRect(await waitForSelector(DRAG_HANDLE_SELECTOR)); + await mouseSequence([ + { + type: "move", + x: handleRect.x + handleRect.width / 2, + y: handleRect.y + handleRect.height / 2, + steps: 5, + }, + ]); + await sleep(100); + await mouseSequence([{ type: "down" }]); + await sleep(100); + + const toggleRect = getRect(await waitForSelector(TOGGLE_SELECTOR)); + const centreX = toggleRect.x + toggleRect.width / 2; + + // Two moves, because the first one after the button goes down starts the + // drag rather than producing a `dragover`. Both land inside the toggle's + // own content, so they resolve to the same drop target. + await mouseSequence([ + { type: "move", x: centreX, y: toggleRect.y + 1, steps: 5 }, + ]); + await sleep(300); + await mouseSequence([ + { + type: "move", + x: centreX, + y: toggleRect.y + toggleRect.height / 2, + steps: 5, + }, + ]); + await sleep(300); + + const dropCursorRect = getRect( + await waitForSelector(DROP_CURSOR_SELECTOR), + ); + + await mouseSequence([{ type: "up" }]); + await sleep(300); + + expect(blockTree()).toEqual([ + { text: "Toggle", children: [{ text: "Drag me", children: [] }] }, + ]); + + // The drop cursor has to have been drawn where the block actually + // landed, or the drag would have lied about the outcome. + const childRect = getRect(PARAGRAPH_SELECTOR); + expect(Math.abs(dropCursorRect.top - childRect.top)).toBeLessThan(16); + expect(Math.abs(dropCursorRect.left - childRect.left)).toBeLessThan(32); + + // The drop is one transaction, so one undo takes the block back out. + blockNoteEditor().undo(); + await sleep(200); + + expect(blockTree()).toEqual([ + { text: "Toggle", children: [] }, + { text: "Drag me", children: [] }, + ]); + }, + ); +}); 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..7b96a3de52 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,28 +1,35 @@
-
+
+
-
-
- -

Toggle Heading

-
-
+

Toggle Heading

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..9c77e46ecf 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 @@ -59,25 +59,32 @@
-
+
+
-
-
- -

Toggle List Item 1

-
-
+

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..e712649cfc 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 @@ -56,25 +56,32 @@

Check List Item 2

-
+
+
-
-
- -

Toggle List Item 1

-
-
+

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..daca172071 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,23 +1,30 @@
-
+
+
-
-
- -

Toggle List Item

-
-
+

Toggle List Item

@@ -37,30 +44,37 @@
-
+
+
-
-
- -

Toggle Heading

-
-
+

Toggle Heading

diff --git a/tests/src/unit/core/formatConversion/parse/__snapshots__/html/legacyToggleWrapperBlockNoteHTML.json b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/legacyToggleWrapperBlockNoteHTML.json new file mode 100644 index 0000000000..582e8b2ee2 --- /dev/null +++ b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/legacyToggleWrapperBlockNoteHTML.json @@ -0,0 +1,74 @@ +[ + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Toggle Child", + "type": "text", + }, + ], + "id": "2", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": [ + { + "styles": {}, + "text": "Toggle List Item", + "type": "text", + }, + ], + "id": "1", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "toggleListItem", + }, + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Heading Child", + "type": "text", + }, + ], + "id": "4", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": [ + { + "styles": {}, + "text": "Toggle Heading", + "type": "text", + }, + ], + "id": "3", + "props": { + "backgroundColor": "default", + "isToggleable": true, + "level": 2, + "textAlignment": "left", + "textColor": "default", + }, + "type": "heading", + }, +] \ No newline at end of file diff --git a/tests/src/unit/core/formatConversion/parse/parseTestInstances.ts b/tests/src/unit/core/formatConversion/parse/parseTestInstances.ts index 536a7ac784..7c31201360 100644 --- a/tests/src/unit/core/formatConversion/parse/parseTestInstances.ts +++ b/tests/src/unit/core/formatConversion/parse/parseTestInstances.ts @@ -1119,6 +1119,68 @@ l'utilisateur (bouton bleu en haut à droite de la conversation) +
+
+
+
+
+ +

Toggle List Item

+
+
+
+
+
+
+
+

Toggle Child

+
+
+
+
+
+
+
+
+
+
+
+ +

Toggle Heading

+
+
+
+
+
+
+
+

Heading Child

+
+
+
+
+
+
+
`, + }, + executeTest: testParseHTML, + }, ]; export const parseTestInstancesMarkdown: TestInstance< diff --git a/tests/src/unit/core/schema/__snapshots__/blocks.json b/tests/src/unit/core/schema/__snapshots__/blocks.json index ee48987244..a0a08aad0f 100644 --- a/tests/src/unit/core/schema/__snapshots__/blocks.json +++ b/tests/src/unit/core/schema/__snapshots__/blocks.json @@ -266,6 +266,7 @@ ], "implementation": { "meta": { + "collapsible": [Function], "isolating": false, }, "node": null, @@ -566,6 +567,7 @@ ], "implementation": { "meta": { + "collapsible": true, "isolating": false, }, "node": null, From 00354762697b16bf204dad008e268cab2f15e288 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Thu, 20 Aug 2026 15:21:35 +0200 Subject: [PATCH 2/3] fix(core): address review on the Collapsible extension - `splitBlockTr` kept the children on the first half unconditionally, which at a start-of-title split stranded them on the now-empty block while the title moved away. They now follow the content, leaving the block intact with a new one above it, as Notion does. - `Shift-Enter` reached the collapsible Enter handler for blocks opting out of hard breaks with `hardBreakShortcut: "none"`, starting a child instead of splitting. Restricted to plain Enter. - `handleCollapsibleDrop` compared an undefined target id against itself and passed, letting a drop land at an unvalidated position. - The child-count side table was never pruned. The count now rides on the block's own decoration, so ProseMirror discards it with the block. - The chevron gets `aria-controls` naming the group it discloses. - `createBlockDecorationPlugin` takes `Omit`, so passing a `decorations` prop that would be silently dropped won't compile. - `isBlockCollapsible` takes only the schema, removing a cast in the exporter. --- .../commands/splitBlock/splitBlock.test.ts | 20 +++ .../commands/splitBlock/splitBlock.ts | 8 +- .../html/util/serializeBlocksInternalHTML.ts | 2 +- packages/core/src/editor/Block.css | 13 +- .../Collapsible/Collapsible.test.ts | 125 +++++++++++++++++- .../src/extensions/Collapsible/Collapsible.ts | 74 ++++++++--- .../Collapsible/blockDecorations.ts | 21 ++- .../extensions/Collapsible/collapsibleDrop.ts | 6 + .../KeyboardShortcutsExtension.ts | 5 +- 9 files changed, 245 insertions(+), 29 deletions(-) diff --git a/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.test.ts b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.test.ts index 0b1e2d9a2c..3093676f3b 100644 --- a/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.test.ts +++ b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.test.ts @@ -101,6 +101,26 @@ describe("Test splitBlocks", () => { expect(newBlock.children).toEqual([]); }); + it("Block has children, split at start of content", () => { + getEditor().transact((tr) => { + setSelectionWithOffset(tr.doc, "paragraph-with-children", 0); + }); + + splitBlock(getEditor().transact((tr) => tr.selection.anchor)); + + // The first half keeps no content, so the children go with the second one, + // which does — rather than being stranded on an empty block. + const original = getEditor().document.find( + (block) => block.id === "paragraph-with-children", + )!; + expect(original.children).toEqual([]); + + const newBlock = getEditor().document.find((block) => block.id === "0")!; + expect(newBlock.children.map((child) => child.id)).toEqual([ + "nested-paragraph-0", + ]); + }); + it("Keep type", () => { getEditor().transact((tr) => { setSelectionWithOffset(tr.doc, "heading-0", 4); diff --git a/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts index da231fa7dd..5603801ced 100644 --- a/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts +++ b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts @@ -58,7 +58,13 @@ export const splitBlockTr = ( // To avoid that, the group is detached before the split and put back on the // original block afterwards. Both happen in the same transaction, so this is // still a single undo step. - const childContainer = info.childContainer; + // + // Splitting at the very start is the exception: there the first half keeps no + // content at all and the whole of it moves to the second, so the children + // follow it rather than being left behind on an empty block. That's what a + // plain `tr.split` already does. + const splitAtStart = posInBlock === info.blockContent.beforePos + 1; + const childContainer = splitAtStart ? undefined : info.childContainer; if (childContainer) { // The group sits after `posInBlock`, so deleting it doesn't shift the split diff --git a/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts b/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts index 912f90dff6..8c7c1b8fb0 100644 --- a/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts +++ b/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts @@ -204,7 +204,7 @@ function serializeBlock< // `CollapsibleExtension` doesn't run when exporting, so the chevron is added // here to keep the layout identical to the editor's. It can't fold anything, // so it's rendered inert. - if (isBlockCollapsible(editor as any, block.type as string, props)) { + if (isBlockCollapsible(editor, block.type as string, props)) { const collapseButton = createCollapseButton(true); collapseButton.setAttribute("aria-hidden", "true"); collapseButton.setAttribute("tabindex", "-1"); diff --git a/packages/core/src/editor/Block.css b/packages/core/src/editor/Block.css index 4ce3fc43fa..ffdda2d8b7 100644 --- a/packages/core/src/editor/Block.css +++ b/packages/core/src/editor/Block.css @@ -339,9 +339,16 @@ NESTED BLOCKS padding: 3px; } -/* Headings pad 18px at the top rather than 3px (see HEADINGS above), which - lifts the centred chevron off the text; this pushes it back down by half the - difference. */ +/* Headings pad 18px at the top (see HEADINGS above), so centring the chevron + against the whole content box leaves it sitting above the text. This pushes it + back down. + + The value is empirical, and only right for a single-line heading around the + default level — the correct offset depends on the heading's line box, which + scales with `--level`, and that custom property is set on the content element, + a sibling the button can't read it from. Not visible in the screenshot tests + either, which mask `.bn-collapse-button`. Wants a proper pass: align to + `flex-start` and offset by the first line's height. */ .bn-block-outer[data-collapsible="true"]:has( > .bn-block > .bn-block-content[data-content-type="heading"] ) diff --git a/packages/core/src/extensions/Collapsible/Collapsible.test.ts b/packages/core/src/extensions/Collapsible/Collapsible.test.ts index 5f30b50f86..b3a4f4bfa8 100644 --- a/packages/core/src/extensions/Collapsible/Collapsible.test.ts +++ b/packages/core/src/extensions/Collapsible/Collapsible.test.ts @@ -1,7 +1,13 @@ +import { TextSelection } from "prosemirror-state"; import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; -import type { PartialBlock } from "../../blocks/defaultBlocks.js"; +import { BlockNoteSchema } from "../../blocks/BlockNoteSchema.js"; +import { + defaultBlockSpecs, + type PartialBlock, +} from "../../blocks/defaultBlocks.js"; import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { createBlockSpec } from "../../schema/index.js"; import { CollapsibleExtension } from "./Collapsible.js"; /** @@ -26,8 +32,16 @@ afterEach(() => { window.localStorage.clear(); }); -function createEditor(initialContent: PartialBlock[]) { - const editor = BlockNoteEditor.create({ initialContent }); +function createEditor( + initialContent: PartialBlock[], + schema?: BlockNoteSchema, +): BlockNoteEditor { + // Spread rather than passed as `undefined`, which isn't the same as absent + // here — it overrides the default schema with nothing. + const editor = BlockNoteEditor.create({ + initialContent, + ...(schema ? { schema } : {}), + } as any); editor.mount(document.createElement("div")); activeEditors.push(editor); @@ -67,11 +81,15 @@ function addChildButton(editor: BlockNoteEditor, id: string) { * equivalent: it replays only the *steps* a handler dispatched, so a selection * the handler set is dropped. */ -function pressEnter(editor: BlockNoteEditor) { +function pressEnter( + editor: BlockNoteEditor, + { shift = false }: { shift?: boolean } = {}, +) { const view = editor.prosemirrorView; const event = new KeyboardEvent("keydown", { key: "Enter", code: "Enter", + shiftKey: shift, bubbles: true, cancelable: true, }); @@ -79,6 +97,28 @@ function pressEnter(editor: BlockNoteEditor) { view.someProp("handleKeyDown", (handler) => handler(view, event)); } +/** A collapsible block that takes no hard breaks, so Shift-Enter falls through. */ +const NO_HARD_BREAK_SCHEMA = BlockNoteSchema.create({ + blockSpecs: { + ...defaultBlockSpecs, + collapsibleNoHardBreak: createBlockSpec( + { + type: "collapsibleNoHardBreak", + propSchema: {}, + content: "inline", + }, + { + meta: { collapsible: true, hardBreakShortcut: "none" }, + render: () => { + const dom = document.createElement("p"); + + return { dom, contentDOM: dom }; + }, + }, + )(), + }, +}); + const TOGGLE_HEADING: PartialBlock = { id: "toggle-heading", type: "heading", @@ -171,6 +211,26 @@ describe("CollapsibleExtension", () => { expect(chevron(editor, "toggle-heading")).not.toBe(null); }); + it("points the chevron at the group it discloses, for screen readers", () => { + const editor = createEditor(TOGGLE_HEADING_WITH_CHILD); + const button = chevron(editor, "toggle-heading")!; + + const controls = button.getAttribute("aria-controls"); + expect(controls).toBe("bn-collapse-children-toggle-heading"); + // The link has to actually resolve, or it's worse than saying nothing. + expect( + editor.prosemirrorView.dom.querySelector(`#${controls}`)?.className, + ).toContain("bn-block-group"); + + // Nothing to point at while the block has no children. + const childless = createEditor([ + { id: "toggle", type: "toggleListItem", content: "Toggle" }, + ]); + expect(chevron(childless, "toggle")!.hasAttribute("aria-controls")).toBe( + false, + ); + }); + // #2124 it("drops the chevron when a toggle heading is converted to a plain heading", () => { const editor = createEditor(TOGGLE_HEADING_WITH_CHILD); @@ -397,11 +457,66 @@ describe("CollapsibleExtension", () => { expect(editor.document[0].type).toBe("paragraph"); expect(editor.document[0].children).toHaveLength(0); - // A cursor part-way through the title splits it, keeping the children. + // A cursor part-way through the title splits it, keeping the children on + // the half that keeps the title. editor.setTextCursorPosition("toggle", "start"); + editor.transact((tr) => + tr.setSelection(TextSelection.create(tr.doc, tr.selection.from + 3)), + ); pressEnter(editor); const toggle = editor.document.find((block) => block.id === "toggle")!; expect(toggle.children.map((child) => child.id)).toEqual(["child"]); }); + + // At the very start there's no title left on the original half to keep the + // children company, so they go with the text instead of being stranded on an + // empty block. Notion likewise leaves the toggle intact and puts the new + // block above it. + it("moves the children with the title when splitting at the start", () => { + const editor = createEditor([ + { + id: "toggle", + type: "toggleListItem", + content: "Toggle", + children: [{ id: "child", type: "paragraph", content: "Child" }], + }, + ]); + collapsible(editor).setCollapsed({ id: "toggle" }, false); + + editor.setTextCursorPosition("toggle", "start"); + pressEnter(editor); + + const [empty, withTitle] = editor.document; + expect(empty.content).toEqual([]); + expect(empty.children).toHaveLength(0); + expect(withTitle.content).toEqual([ + { type: "text", text: "Toggle", styles: {} }, + ]); + expect(withTitle.children.map((child) => child.id)).toEqual(["child"]); + }); + + // A block that opts out of hard breaks lets Shift-Enter fall through to the + // default Enter chain. It should still split there rather than start a + // child, the way it did before this handler existed. Needs a block with + // `hardBreakShortcut: "none"`: with the default, the hard-break handler runs + // first and this never gets the chance to go wrong. + it("ignores Shift-Enter", () => { + const editor = createEditor( + [{ id: "toggle", type: "collapsibleNoHardBreak", content: "Toggle" }], + NO_HARD_BREAK_SCHEMA, + ); + collapsible(editor).setCollapsed({ id: "toggle" }, false); + editor.setTextCursorPosition("toggle", "end"); + + pressEnter(editor, { shift: true }); + + expect(editor.document).toHaveLength(2); + expect(editor.document[0].children).toHaveLength(0); + + // ...whereas plain Enter still starts a child. + editor.setTextCursorPosition("toggle", "end"); + pressEnter(editor); + expect(editor.document[0].children).toHaveLength(1); + }); }); }); diff --git a/packages/core/src/extensions/Collapsible/Collapsible.ts b/packages/core/src/extensions/Collapsible/Collapsible.ts index 6a5d98bdab..cdc02b552d 100644 --- a/packages/core/src/extensions/Collapsible/Collapsible.ts +++ b/packages/core/src/extensions/Collapsible/Collapsible.ts @@ -96,10 +96,12 @@ export type CollapsibleOptions = { * Whether a block of `type` with `props` declares itself collapsible. * * Shared with the HTML exporter, which has to reproduce what the extension - * renders without an editor view to read decorations from. + * renders without an editor view to read decorations from. It takes only the + * schema rather than a whole editor, so an editor with a concrete block schema + * can be passed without a cast. */ export function isBlockCollapsible( - editor: BlockNoteEditor, + editor: Pick, "schema">, type: string, props: Record, ): boolean { @@ -111,15 +113,28 @@ export function isBlockCollapsible( : !!collapsible; } +/** The DOM id given to a collapsible block's child group, for `aria-controls`. */ +export function collapsibleChildrenId(blockId: string): string { + return `bn-collapse-children-${blockId}`; +} + /** * The chevron shown to the left of a collapsible block's content. It's the - * disclosure control for the block's children, so it carries `aria-expanded`. + * disclosure control for the block's children, so it carries `aria-expanded`, + * and `aria-controls` naming what it discloses where there is anything to name — + * a childless block has no group to point at. */ -export function createCollapseButton(expanded: boolean): HTMLButtonElement { +export function createCollapseButton( + expanded: boolean, + controls?: string, +): HTMLButtonElement { const button = document.createElement("button"); button.className = "bn-collapse-button"; button.type = "button"; button.setAttribute("aria-expanded", expanded ? "true" : "false"); + if (controls) { + button.setAttribute("aria-controls", controls); + } button.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 ''; @@ -147,10 +162,6 @@ export const CollapsibleExtension = createExtension( const isExpanded = (id: string) => toggledState.get({ id }); - // Child counts as of each block's last decoration, so that a block which - // gains one can expand rather than appear to swallow it. - const childCounts = new Map(); - /** Redraws the decorations, for state that isn't in the document. */ function invalidate() { // A headless editor has no view to redraw. @@ -176,8 +187,16 @@ export const CollapsibleExtension = createExtension( } }); - function createChevron(id: string, expanded: boolean, disabled: boolean) { - const button = createCollapseButton(expanded); + function createChevron( + id: string, + expanded: boolean, + disabled: boolean, + hasChildren: boolean, + ) { + const button = createCollapseButton( + expanded, + hasChildren ? collapsibleChildrenId(id) : undefined, + ); button.disabled = disabled; // Keeps the editor's selection (and focus) where it was. button.addEventListener("mousedown", (event) => event.preventDefault()); @@ -227,17 +246,26 @@ export const CollapsibleExtension = createExtension( const collapsiblePlugin = createBlockDecorationPlugin( collapsiblePluginKey, - (info, pos, id) => { + (info, pos, id, previous) => { const props = info.blockContent.node.attrs; if (!isBlockCollapsible(editor, info.blockNoteType, props)) { return []; } const childCount = info.childContainer?.node.childCount ?? 0; - if (childCount > (childCounts.get(id) ?? childCount)) { + // The count as of the last time this block was decorated, carried on the + // decoration so it lives and dies with the block. A block that has just + // gained a child expands, rather than appearing to swallow it — note + // that this writes collapse state from inside `apply`, which a remote + // insert can therefore trigger. It's idempotent, and the write is to + // per-user storage rather than the document. + const lastChildCount = previous.find( + (decoration) => decoration.spec.childCount !== undefined, + )?.spec.childCount; + + if (childCount > (lastChildCount ?? childCount)) { toggledState.set({ id }, true); } - childCounts.set(id, childCount); const expanded = isExpanded(id); // Nothing to reveal, and no "add a block" button either. @@ -252,21 +280,35 @@ export const CollapsibleExtension = createExtension( "data-collapsible": "true", ...(expanded ? {} : { "data-collapsed": "true" }), }, - { blockId: id }, + { blockId: id, childCount }, ), Decoration.widget( pos + 1, - () => createChevron(id, expanded, disabled), + () => createChevron(id, expanded, disabled, childCount > 0), { blockId: id, side: -1, // Everything the button's DOM depends on, so ProseMirror reuses // it until one of them changes. - key: `bn-collapse-button:${id}:${expanded}:${disabled}`, + key: `bn-collapse-button:${id}:${expanded}:${disabled}:${ + childCount > 0 + }`, }, ), ]; + // Names the group the chevron discloses, for `aria-controls`. + if (info.childContainer) { + decorations.push( + Decoration.node( + info.childContainer.beforePos, + info.childContainer.afterPos, + { id: collapsibleChildrenId(id) }, + { blockId: id }, + ), + ); + } + // An expanded block with no children gets an "add a block" button, so // its chevron has something to reveal. if (expanded && editor.isEditable && childCount === 0) { diff --git a/packages/core/src/extensions/Collapsible/blockDecorations.ts b/packages/core/src/extensions/Collapsible/blockDecorations.ts index ddef28c1f3..390a785243 100644 --- a/packages/core/src/extensions/Collapsible/blockDecorations.ts +++ b/packages/core/src/extensions/Collapsible/blockDecorations.ts @@ -13,11 +13,18 @@ export type BlockContainerInfo = Extract< /** * The decorations one block needs right now, or none. Every decoration must * carry a `blockId` spec, so the stale ones can be found again. + * + * `previous` holds the block's decorations from before this transaction, mapped + * to their current positions. A decorator that needs to compare against the last + * time it ran can keep that state in a decoration's spec and read it back here, + * rather than in a side table it would have to prune itself — ProseMirror drops + * a decoration when its block goes away. */ export type BlockDecorator = ( info: BlockContainerInfo, pos: number, id: string, + previous: readonly Decoration[], ) => Decoration[]; function nextDecorationSet( @@ -54,7 +61,14 @@ function nextDecorationSet( // Recorded even when `decorate` returns nothing, so that a block which // stopped wanting decorations has its old ones dropped. rescanned.add(id); - added.push(...decorate(info, pos, id)); + added.push( + ...decorate( + info, + pos, + id, + mapped.find(pos, pos + node.nodeSize, (spec) => spec.blockId === id), + ), + ); } return true; @@ -81,7 +95,10 @@ export const INVALIDATE_BLOCK_DECORATIONS = "bn-invalidate-block-decorations"; export function createBlockDecorationPlugin( key: PluginKey, decorate: BlockDecorator, - props?: EditorProps, + // `decorations` is this plugin's own: it serves the set built above, so a + // caller passing one would silently replace it. Excluded rather than merged, + // since a caller wanting extra decorations should return them from `decorate`. + props?: Omit, ) { return new Plugin({ key, diff --git a/packages/core/src/extensions/Collapsible/collapsibleDrop.ts b/packages/core/src/extensions/Collapsible/collapsibleDrop.ts index b1251b556e..1c4dde1e91 100644 --- a/packages/core/src/extensions/Collapsible/collapsibleDrop.ts +++ b/packages/core/src/extensions/Collapsible/collapsibleDrop.ts @@ -97,7 +97,13 @@ export function handleCollapsibleDrop( return false; } + // Without one there's nothing to check the target against below, so the drop + // would land at a position nothing has vouched for. const targetId = view.state.doc.nodeAt(targetPos)?.attrs.id; + if (!targetId) { + return false; + } + const tr = view.state.tr; if (moved) { diff --git a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts index aa927a505e..f86ce5ebb7 100644 --- a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts +++ b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts @@ -918,8 +918,11 @@ export const KeyboardShortcutsExtension = Extension.create<{ }), // Starts a first child instead of splitting, at the end of an expanded // collapsible block's title. Toggle list items don't reach this — their - // own Enter shortcut runs first, and calls the same handler. + // own Enter shortcut runs first, and calls the same handler. Plain Enter + // only: `Shift-Enter` gets here too once a block opts out of hard breaks + // with `hardBreakShortcut: "none"`, and should still split. () => + !withShift && commands.command(({ tr }) => handleCollapsibleEnter(this.options.editor, tr), ), From df71e84fbc98466d302f8b0a21a44e0f3520e43c Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Thu, 20 Aug 2026 16:29:22 +0200 Subject: [PATCH 3/3] fix(core): spread caller props before the decoration plugin's own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Belt and braces with the `Omit` parameter type: the type stops a `decorations` prop being passed, and the order stops one replacing the plugin's set — and taking the collapse controls with it — if it arrives from an untyped caller. --- .../core/src/extensions/Collapsible/blockDecorations.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/core/src/extensions/Collapsible/blockDecorations.ts b/packages/core/src/extensions/Collapsible/blockDecorations.ts index 390a785243..f87c35d0f2 100644 --- a/packages/core/src/extensions/Collapsible/blockDecorations.ts +++ b/packages/core/src/extensions/Collapsible/blockDecorations.ts @@ -95,9 +95,10 @@ export const INVALIDATE_BLOCK_DECORATIONS = "bn-invalidate-block-decorations"; export function createBlockDecorationPlugin( key: PluginKey, decorate: BlockDecorator, - // `decorations` is this plugin's own: it serves the set built above, so a - // caller passing one would silently replace it. Excluded rather than merged, - // since a caller wanting extra decorations should return them from `decorate`. + // `decorations` is this plugin's own: it serves the set built above. Excluded + // rather than merged, since a caller wanting extra decorations should return + // them from `decorate`. It's also spread ahead of `decorations` below, so an + // untyped caller can't replace the set and take the collapse controls with it. props?: Omit, ) { return new Plugin({ @@ -113,6 +114,6 @@ export function createBlockDecorationPlugin( : oldSet; }, }, - props: { decorations: (state) => key.getState(state), ...props }, + props: { ...props, decorations: (state) => key.getState(state) }, }); }