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..3093676f3b 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,50 @@ 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("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 1e73471d23..5603801ced 100644 --- a/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts +++ b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts @@ -52,7 +52,43 @@ 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. + // + // 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 + // 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..8c7c1b8fb0 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, 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..ffdda2d8b7 100644 --- a/packages/core/src/editor/Block.css +++ b/packages/core/src/editor/Block.css @@ -306,41 +306,74 @@ 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 (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"] + ) + > .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 +382,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 +392,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..b3a4f4bfa8 --- /dev/null +++ b/packages/core/src/extensions/Collapsible/Collapsible.test.ts @@ -0,0 +1,522 @@ +import { TextSelection } from "prosemirror-state"; +import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; + +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"; + +/** + * @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[], + 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); + + 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, + { shift = false }: { shift?: boolean } = {}, +) { + const view = editor.prosemirrorView; + const event = new KeyboardEvent("keydown", { + key: "Enter", + code: "Enter", + shiftKey: shift, + bubbles: true, + cancelable: true, + }); + + 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", + 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); + }); + + 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); + + 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 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 new file mode 100644 index 0000000000..cdc02b552d --- /dev/null +++ b/packages/core/src/extensions/Collapsible/Collapsible.ts @@ -0,0 +1,368 @@ +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. 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: Pick, "schema">, + type: string, + props: Record, +): boolean { + const collapsible = + editor.schema.blockSpecs[type]?.implementation?.meta?.collapsible; + + return typeof collapsible === "function" + ? collapsible({ type, props }) + : !!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`, + * 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, + 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 + ''; + + 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 }); + + /** 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, + 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()); + 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, previous) => { + const props = info.blockContent.node.attrs; + if (!isBlockCollapsible(editor, info.blockNoteType, props)) { + return []; + } + + const childCount = info.childContainer?.node.childCount ?? 0; + // 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); + } + + 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, childCount }, + ), + Decoration.widget( + pos + 1, + () => 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}:${ + 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) { + 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..f87c35d0f2 --- /dev/null +++ b/packages/core/src/extensions/Collapsible/blockDecorations.ts @@ -0,0 +1,119 @@ +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. + * + * `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( + 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, + mapped.find(pos, pos + node.nodeSize, (spec) => spec.blockId === 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, + // `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({ + 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: { ...props, decorations: (state) => key.getState(state) }, + }); +} diff --git a/packages/core/src/extensions/Collapsible/collapsibleDrop.ts b/packages/core/src/extensions/Collapsible/collapsibleDrop.ts new file mode 100644 index 0000000000..1c4dde1e91 --- /dev/null +++ b/packages/core/src/extensions/Collapsible/collapsibleDrop.ts @@ -0,0 +1,125 @@ +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; + } + + // 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) { + 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..f86ce5ebb7 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,16 @@ 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. 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), + ), // 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,