diff --git a/docs/vercel.json b/docs/vercel.json index 7aadf9be7a..e63fa7e15a 100644 --- a/docs/vercel.json +++ b/docs/vercel.json @@ -1,4 +1,5 @@ { "cleanUrls": true, + "installCommand": "cd .. && corepack enable && pnpm install", "buildCommand": "cd .. && corepack enable && pnpm run build && pnpm --filter @blocknote/dev-scripts run gen && cd docs && pnpm exec fumadocs-mdx && NODE_OPTIONS='--max-old-space-size=6144' pnpm exec next build" } diff --git a/examples/08-extensions/01-tiptap-arrow-conversion/.bnexample.json b/examples/08-extensions/01-tiptap-arrow-conversion/.bnexample.json index 1893df45b9..ca1451aaa8 100644 --- a/examples/08-extensions/01-tiptap-arrow-conversion/.bnexample.json +++ b/examples/08-extensions/01-tiptap-arrow-conversion/.bnexample.json @@ -5,6 +5,6 @@ "tags": ["Extension"], "pro": true, "dependencies": { - "@tiptap/core": "^3.13.0" + "@tiptap/core": "^3.29.2" } } diff --git a/examples/vanilla-js/vanilla-custom-side-menu/package.json b/examples/vanilla-js/vanilla-custom-side-menu/package.json index 7c71b5a417..730e23e22c 100644 --- a/examples/vanilla-js/vanilla-custom-side-menu/package.json +++ b/examples/vanilla-js/vanilla-custom-side-menu/package.json @@ -25,6 +25,6 @@ "@types/react": "^19.2.3", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", - "vite-plus": "catalog:" + "vite-plus": "^0.1.24" } } diff --git a/packages/core/src/editor/Block.css b/packages/core/src/editor/Block.css index 29d31765a2..80a707f02e 100644 --- a/packages/core/src/editor/Block.css +++ b/packages/core/src/editor/Block.css @@ -735,9 +735,59 @@ NESTED BLOCKS .bn-block-column { flex: 1; - padding: 12px 20px; + padding: 12px 15px 12px 25px; /* scroll if we overflow, for example when tables or images are in the column */ overflow-x: auto; + /* Lets the trailing block widget grow to fill the column's leftover height + when a sibling column is taller. */ + display: flex; + flex-direction: column; +} + +/* Shows separators between a column list's columns while the mouse is over +it. This clarifies where each column (and its clickable empty space) ends. +The class is managed by the multi-column package's column resize plugin. The +transition delay stops the separators from flashing in when the mouse just +passes through the column list. */ +.bn-editor[contenteditable="true"] + .bn-column-list-hovered + > .bn-block-column:not(:last-child) { + box-shadow: 4px 0 0 #eee; + transition: box-shadow 0.2s ease 0.2s; +} + +.bn-editor[contenteditable="true"] + .bn-column-list-hovered + > .bn-block-column:not(:last-child):where(.dark, .dark *) { + box-shadow: 4px 0 0 rgb(70, 70, 70); +} + +/* The border between two columns when the cursor is near their boundary or +they're being resized, rendered on the boundary's left column. The class is +managed by the multi-column package's column resize plugin. */ +.bn-editor[contenteditable="true"] + .bn-column-list-hovered + > .bn-block-column.bn-column-resize-border { + box-shadow: 4px 0 0 #ccc; + cursor: col-resize; + transition: box-shadow 0.2s ease; +} + +.bn-editor[contenteditable="true"] + .bn-column-list-hovered + > .bn-block-column.bn-column-resize-border:where(.dark, .dark *) { + box-shadow: 4px 0 0 rgb(110, 110, 110); +} + +.bn-editor[contenteditable="true"] + .bn-block-column.bn-column-resize-border + + .bn-block-column { + cursor: col-resize; +} + +.bn-block-column > .bn-trailing-block { + height: auto; + flex-grow: 1; } .bn-block-column:first-child { diff --git a/packages/core/src/extensions/SideMenu/SideMenu.ts b/packages/core/src/extensions/SideMenu/SideMenu.ts index d53a8444cc..fddd2712e9 100644 --- a/packages/core/src/extensions/SideMenu/SideMenu.ts +++ b/packages/core/src/extensions/SideMenu/SideMenu.ts @@ -773,6 +773,14 @@ export const SideMenuExtension = createExtension(({ editor }) => { editor.blur(); }, + /** + * Whether the side menu is currently frozen (e.g. because the drag handle + * menu is open). + */ + get menuFrozen() { + return view!.menuFrozen; + }, + /** * Freezes the side menu. When frozen, the side menu will stay * attached to the same block regardless of which block is hovered by the diff --git a/packages/core/src/extensions/TrailingNode/TrailingNode.ts b/packages/core/src/extensions/TrailingNode/TrailingNode.ts index d7bcfe002c..ea8bc47cd5 100644 --- a/packages/core/src/extensions/TrailingNode/TrailingNode.ts +++ b/packages/core/src/extensions/TrailingNode/TrailingNode.ts @@ -13,15 +13,10 @@ import { const PLUGIN_KEY = new PluginKey("trailingNode"); -// Skip the widget when the editor isn't editable, or when the document already -// ends with an empty paragraph block (since the user can just type into it). -function shouldShowTrailingWidget(doc: PMNode, isEditable: boolean): boolean { - if (!isEditable) { - return false; - } - - const rootGroup = doc.lastChild; - const lastBlock = rootGroup?.lastChild; +// Skip the widget when the container already ends with an empty paragraph +// block (since the user can just type into it). +function containerNeedsTrailingWidget(container: PMNode): boolean { + const lastBlock = container.lastChild; const lastContent = lastBlock?.firstChild; return !( @@ -31,12 +26,48 @@ function shouldShowTrailingWidget(doc: PMNode, isEditable: boolean): boolean { ); } +// Returns the position at the end of each container that should render a +// trailing widget: the root blockGroup, and columns from the multi-column +// package. Nested blockGroups (a block's children) are excluded, as they have +// no empty space below them for a widget to occupy. +function getTrailingWidgetPositions(doc: PMNode): number[] { + // When the schema has no columns, the root blockGroup is the only possible + // container, so traversing the doc to find others can be skipped. + if (!doc.type.schema.nodes["column"]) { + const rootGroup = doc.lastChild; + return rootGroup && containerNeedsTrailingWidget(rootGroup) + ? [doc.content.size - 1] + : []; + } + + const positions: number[] = []; + + doc.descendants((node, pos, parent) => { + if (node.isTextblock) { + return false; + } + + const isContainer = + node.type.name === "column" || + (node.type.name === "blockGroup" && parent?.type.name === "doc"); + + if (isContainer && containerNeedsTrailingWidget(node)) { + positions.push(pos + node.nodeSize - 1); + } + + return true; + }); + + return positions; +} + /** * Renders a fake trailing block as a widget decoration after the last block of - * the document. Clicking it inserts a real trailing block and moves the - * selection into it. This way the trailing block is not part of the document - * content, so it doesn't appear when the editor is read-only or when the - * content is exported. + * the document, as well as of any other container that blocks can be appended + * to (e.g. columns). Clicking it inserts a real trailing block in the + * container and moves the selection into it. This way the trailing block is + * not part of the document content, so it doesn't appear when the editor is + * read-only or when the content is exported. */ export const TrailingNodeExtension = createExtension( ({ editor }: ExtensionOptions) => { @@ -52,17 +83,33 @@ export const TrailingNodeExtension = createExtension( // based on this click. event.preventDefault(); + const view = editor.prosemirrorView; + if (!view) { + return; + } + + // The widget may have been remapped since it was created, so its + // container is resolved from its current DOM position instead of + // captured up front. + const container = view.state.doc.resolve( + view.posAtDOM(el, 0), + ).parent; + const lastBlockId = container.lastChild?.attrs["id"]; + if (!lastBlockId) { + return; + } + editor.transact((tr) => { const [insertedBlock] = editor.insertBlocks( [{ type: "paragraph" }], - editor.document[editor.document.length - 1], + lastBlockId, "after", ); editor.setTextCursorPosition(insertedBlock, "start"); tr.scrollIntoView(); }); - editor.prosemirrorView?.focus(); + view.focus(); }); return el; }, @@ -70,29 +117,44 @@ export const TrailingNodeExtension = createExtension( ); } - // Maps the existing DecorationSet through the transaction, then - // incrementally adds or removes the widget only if the show/hide state - // crossed over. The underlying Decoration (and its rendered DOM) stays - // reference-stable across transactions. + // Maps the existing DecorationSet through the transaction, then diffs it + // against the containers that should currently show a widget, only adding + // and removing where the two differ. Decorations (and their rendered DOM) + // stay reference-stable across transactions for unchanged containers. function nextDecorationSet( tr: Transaction, oldSet: DecorationSet, isEditable: boolean, ): DecorationSet { const mapped = oldSet.map(tr.mapping, tr.doc); - const existing = mapped.find(); - const wasShowing = existing.length > 0; - const shouldShow = shouldShowTrailingWidget(tr.doc, isEditable); + const desiredPositions = new Set( + isEditable ? getTrailingWidgetPositions(tr.doc) : [], + ); + + const keptPositions = new Set(); + const stale: Decoration[] = []; + for (const decoration of mapped.find()) { + if ( + desiredPositions.has(decoration.from) && + !keptPositions.has(decoration.from) + ) { + keptPositions.add(decoration.from); + } else { + stale.push(decoration); + } + } + const missing = [...desiredPositions].filter( + (pos) => !keptPositions.has(pos), + ); - if (wasShowing === shouldShow) { - return mapped; + let next = mapped; + if (stale.length > 0) { + next = next.remove(stale); } - if (wasShowing) { - return mapped.remove(existing); + if (missing.length > 0) { + next = next.add(tr.doc, missing.map(createTrailingWidget)); } - return mapped.add(tr.doc, [ - createTrailingWidget(tr.doc.content.size - 1), - ]); + return next; } return { @@ -133,8 +195,9 @@ export const TrailingNodeExtension = createExtension( props: { decorations: (state) => PLUGIN_KEY.getState(state), // Prevents ProseMirror from trying to move the selection into the - // trailing block, which causes the text caret to flicker in it - // before returning to its previous position. + // trailing block at the end of the document, which causes the text + // caret to flicker in it before returning to its previous + // position. handleKeyDown: (view, event) => { if (event.key !== "ArrowRight" && event.key !== "ArrowDown") { return false; @@ -150,8 +213,11 @@ export const TrailingNodeExtension = createExtension( return false; } + const rootGroup = view.state.doc.lastChild; if ( - !shouldShowTrailingWidget(view.state.doc, editor.isEditable) + !editor.isEditable || + !rootGroup || + !containerNeedsTrailingWidget(rootGroup) ) { return false; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 981ba21e48..0e83fa76f2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -37,6 +37,7 @@ export { selectedFragmentToHTML } from "./api/clipboard/toClipboard/copyExtensio // Node conversions export * from "./api/nodeConversions/blockToNode.js"; +export * from "./api/nodeConversions/fragmentToBlocks.js"; export * from "./api/nodeConversions/nodeToBlock.js"; export * from "./extensions/tiptap-extensions/UniqueID/UniqueID.js"; diff --git a/packages/react/src/components/SideMenu/SideMenu.tsx b/packages/react/src/components/SideMenu/SideMenu.tsx index 33fdf9d42a..e060eb64f7 100644 --- a/packages/react/src/components/SideMenu/SideMenu.tsx +++ b/packages/react/src/components/SideMenu/SideMenu.tsx @@ -1,9 +1,6 @@ -import { SideMenuExtension } from "@blocknote/core/extensions"; -import { ReactNode, useMemo } from "react"; +import { ReactNode } from "react"; import { useComponentsContext } from "../../editor/ComponentsContext.js"; -import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; -import { useExtensionState } from "../../hooks/useExtension.js"; import { AddBlockButton } from "./DefaultButtons/AddBlockButton.js"; import { DragHandleButton } from "./DefaultButtons/DragHandleButton.js"; import { SideMenuProps } from "./SideMenuProps.js"; @@ -20,41 +17,8 @@ import { SideMenuProps } from "./SideMenuProps.js"; export const SideMenu = (props: SideMenuProps & { children?: ReactNode }) => { const Components = useComponentsContext()!; - const editor = useBlockNoteEditor(); - - const block = useExtensionState(SideMenuExtension, { - editor, - selector: (state) => state?.block, - }); - - const dataAttributes = useMemo(() => { - if (block === undefined) { - return {}; - } - - const attrs: Record = { - "data-block-type": block.type, - }; - - if (block.type === "heading") { - attrs["data-level"] = (block.props as any).level.toString(); - } - - if ( - editor.schema.blockSpecs[block.type].implementation.meta?.fileBlockAccept - ) { - if (block.props.url) { - attrs["data-url"] = "true"; - } else { - attrs["data-url"] = "false"; - } - } - - return attrs; - }, [block, editor.schema.blockSpecs]); - return ( - + {props.children || ( <> diff --git a/packages/react/src/components/SideMenu/SideMenuController.tsx b/packages/react/src/components/SideMenu/SideMenuController.tsx index 4f70e4b4c4..b83a7af977 100644 --- a/packages/react/src/components/SideMenu/SideMenuController.tsx +++ b/packages/react/src/components/SideMenu/SideMenuController.tsx @@ -1,5 +1,6 @@ +import { Block, BlockNoteEditor } from "@blocknote/core"; import { SideMenuExtension } from "@blocknote/core/extensions"; -import { autoUpdate, ReferenceElement } from "@floating-ui/react"; +import { autoUpdate, offset, ReferenceElement } from "@floating-ui/react"; import { FC, useCallback, useMemo } from "react"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; @@ -9,6 +10,52 @@ import { FloatingUIOptions } from "../Popovers/FloatingUIOptions.js"; import { SideMenu } from "./SideMenu.js"; import { SideMenuProps } from "./SideMenuProps.js"; +// Returns the vertical offset of the side menu for the given block. Blocks +// whose first line is taller than the side menu need the menu shifted down to +// stay vertically centered on that line. This is done with a position offset +// instead of stretching the menu element to the first line's height, as the +// taller (invisible) element would block mouse interactions with content +// rendered next to the block, e.g. the column resize borders. The values are +// ported from the per-block-type CSS height rules that used to stretch the +// menu: (first line height - 30px menu height) / 2. +function getBlockOffset( + editor: BlockNoteEditor, + block: Block, +): number { + if (block.type === "heading") { + switch (block.props.level) { + case 1: + return 39; + case 2: + return 27; + case 3: + return 18.5; + default: + return 0; + } + } + + // File blocks without a URL all render the same "Add file" button, + // regardless of their type. + if ( + editor.schema.blockSpecs[block.type]?.implementation.meta + ?.fileBlockAccept && + !block.props.url + ) { + return 12; + } + + if (block.type === "file") { + return 4; + } + + if (block.type === "audio" || block.type === "table") { + return 15; + } + + return 0; +} + export const SideMenuController = (props: { sideMenu?: FC; floatingUIOptions?: Partial; @@ -71,6 +118,13 @@ export const SideMenuController = (props: { useFloatingOptions: { open: show, placement: "left-start", + // Vertically centers the menu on the block's first line. On the + // "left-start" placement, the cross axis is the vertical one. + middleware: [ + offset({ + crossAxis: block ? getBlockOffset(editor, block) : 0, + }), + ], whileElementsMounted, ...props.floatingUIOptions?.useFloatingOptions, }, @@ -89,7 +143,7 @@ export const SideMenuController = (props: { ...props.floatingUIOptions?.elementProps, }, }), - [props.floatingUIOptions, show, whileElementsMounted], + [props.floatingUIOptions, show, block, editor, whileElementsMounted], ); const Component = props.sideMenu || SideMenu; diff --git a/packages/react/src/editor/styles.css b/packages/react/src/editor/styles.css index c83123d24b..507f2cd46f 100644 --- a/packages/react/src/editor/styles.css +++ b/packages/react/src/editor/styles.css @@ -255,39 +255,13 @@ inline styles, it is added to the base z-index. */ --bn-ui-base-z-index: 0; } -/* Matches Side Menu height to block line height */ +/* Matches Side Menu height to block line height. For blocks with a taller +first line (e.g. headings), the menu is not stretched - instead, the +SideMenuController offsets its position to keep it centered on the line. */ .bn-side-menu { height: 30px; } -.bn-side-menu[data-block-type="heading"][data-level="1"] { - height: 108px; -} - -.bn-side-menu[data-block-type="heading"][data-level="2"] { - height: 84px; -} - -.bn-side-menu[data-block-type="heading"][data-level="3"] { - height: 67px; -} - -.bn-side-menu[data-block-type="file"] { - height: 38px; -} - -.bn-side-menu[data-block-type="audio"] { - height: 60px; -} - -.bn-side-menu[data-url="false"] { - height: 54px; -} - -.bn-side-menu[data-block-type="table"] { - height: 60px; -} - /* Thread sidebar styling */ .bn-threads-sidebar { border-radius: var(--bn-border-radius-medium); diff --git a/packages/xl-multi-column/src/extensions/ColumnResize/ColumnResizeExtension.ts b/packages/xl-multi-column/src/extensions/ColumnResize/ColumnResizeExtension.ts index 50d95c1292..5713466a6d 100644 --- a/packages/xl-multi-column/src/extensions/ColumnResize/ColumnResizeExtension.ts +++ b/packages/xl-multi-column/src/extensions/ColumnResize/ColumnResizeExtension.ts @@ -22,9 +22,15 @@ type ColumnDefaultState = { }; type ColumnHoverState = { - type: "hover"; + type: "hover-column"; leftColumn: ColumnData; rightColumn: ColumnData; + columnList: ColumnData; +}; + +type ColumnHoverColumnListState = { + type: "hover-column-list"; + columnList: ColumnData; }; type ColumnResizeState = { @@ -32,9 +38,14 @@ type ColumnResizeState = { startPos: number; leftColumn: ColumnDataWithWidths; rightColumn: ColumnDataWithWidths; + columnList: ColumnData; }; -type ColumnState = ColumnDefaultState | ColumnHoverState | ColumnResizeState; +type ColumnState = + | ColumnDefaultState + | ColumnHoverState + | ColumnHoverColumnListState + | ColumnResizeState; const columnResizePluginKey = new PluginKey("ColumnResizePlugin"); @@ -56,7 +67,7 @@ class ColumnResizePluginView implements PluginView { getColumnHoverOrDefaultState = ( event: MouseEvent, - ): ColumnDefaultState | ColumnHoverState => { + ): ColumnDefaultState | ColumnHoverState | ColumnHoverColumnListState => { if (!this.editor.isEditable) { return { type: "default" }; } @@ -78,6 +89,25 @@ class ColumnResizePluginView implements PluginView { return { type: "default" }; } + const columnListElement = columnElement.closest( + ".bn-block-column-list", + ) as HTMLElement | null; + if (!columnListElement) { + return { type: "default" }; + } + + const columnListId = columnListElement.getAttribute("data-id")!; + const columnListNodeAndPos = getNodeById(columnListId, this.view.state.doc); + if (!columnListNodeAndPos) { + return { type: "default" }; + } + + const columnList: ColumnData = { + element: columnListElement, + id: columnListId, + ...columnListNodeAndPos, + }; + const startPos = event.clientX; const columnElementDOMRect = columnElement.getBoundingClientRect(); @@ -98,11 +128,11 @@ class ColumnResizePluginView implements PluginView { ? columnElement.nextElementSibling : undefined; - // Do nothing if the cursor is not within the resize margin or if there - // is no column before or after the one hovered by the cursor, depending - // on which side the cursor is on. + // If the cursor is not within the resize margin, or if there is no + // column before or after the one hovered by the cursor (depending on + // which side the cursor is on), only the column list counts as hovered. if (!adjacentColumnElement) { - return { type: "default" }; + return { type: "hover-column-list", columnList }; } const leftColumnElement = @@ -134,7 +164,8 @@ class ColumnResizePluginView implements PluginView { } return { - type: "hover", + type: "hover-column", + columnList, leftColumn: { element: leftColumnElement, id: leftColumnId, @@ -153,7 +184,7 @@ class ColumnResizePluginView implements PluginView { // by moving the mouse. mouseDownHandler = (event: MouseEvent) => { let newState: ColumnState = this.getColumnHoverOrDefaultState(event); - if (newState.type === "default") { + if (newState.type === "default" || newState.type === "hover-column-list") { return; } @@ -184,6 +215,7 @@ class ColumnResizePluginView implements PluginView { widthPx: rightColumnWidthPx, widthPercent: rightColumnWidthPercent, }, + columnList: newState.columnList, }; this.view.dispatch( @@ -206,26 +238,62 @@ class ColumnResizePluginView implements PluginView { // If the user isn't currently resizing columns, we want to update the // plugin state to maybe show or hide the resize border between columns. if (pluginState.type !== "resize") { + // Mouse moves over the side menu keep the current hover state so the + // borders don't flicker away while the cursor crosses it on the way to + // a column boundary. Hovering one of the menu's buttons is different - + // there the user's likely target is the button, so the resize border + // is hidden while the lighter separators stay visible. + if ( + event.target instanceof Element && + event.target.closest(".bn-side-menu") + ) { + if ( + pluginState.type === "hover-column" && + event.target.closest(".bn-button") + ) { + this.view.dispatch( + this.view.state.tr.setMeta(columnResizePluginKey, { + type: "hover-column-list", + columnList: pluginState.columnList, + }), + ); + } + return; + } + const newState = this.getColumnHoverOrDefaultState(event); // Prevent unnecessary state updates (when the state before and after // is the same). - const bothDefaultStates = - pluginState.type === "default" && newState.type === "default"; - const sameColumnIds = - pluginState.type !== "default" && - newState.type !== "default" && + if (pluginState.type === "default" && newState.type === "default") { + return; + } + + if ( + pluginState.type === "hover-column-list" && + newState.type === "hover-column-list" && + pluginState.columnList.id === newState.columnList.id + ) { + return; + } + + if ( + pluginState.type === "hover-column" && + newState.type === "hover-column" && pluginState.leftColumn.id === newState.leftColumn.id && - pluginState.rightColumn.id === newState.rightColumn.id; - if (bothDefaultStates || sameColumnIds) { + pluginState.rightColumn.id === newState.rightColumn.id + ) { return; } - // Since the resize bar overlaps the side menu, we don't want to show it - // if the side menu is already open. + // Since the resize border overlaps the side menu's drag handle menu, + // we don't want to show it while that menu is open. Note that this + // deliberately checks whether the menu is frozen and not whether the + // side menu is shown - the side menu shows whenever hovering a block, + // so checking that would suppress the resize border almost always. if ( - newState.type === "hover" && - this.editor.getExtension(SideMenuExtension)?.store.state?.show + newState.type === "hover-column" && + this.editor.getExtension(SideMenuExtension)?.menuFrozen ) { return; } @@ -315,32 +383,46 @@ const createColumnResizePlugin = (editor: BlockNoteEditor) => new Plugin({ key: columnResizePluginKey, props: { - // This adds a border between the columns when the user is - // resizing them or when the cursor is near their boundary. decorations: (state) => { const pluginState = columnResizePluginKey.getState(state); if (!pluginState || pluginState.type === "default") { return DecorationSet.empty; } - return DecorationSet.create(state.doc, [ - Decoration.node( - pluginState.leftColumn.posBeforeNode, - pluginState.leftColumn.posBeforeNode + - pluginState.leftColumn.node.nodeSize, - { - style: "box-shadow: 4px 0 0 #ccc; cursor: col-resize", - }, - ), + // This highlights the hovered column list - core's stylesheet uses + // the class to show separators between its columns. + const decorations = [ Decoration.node( - pluginState.rightColumn.posBeforeNode, - pluginState.rightColumn.posBeforeNode + - pluginState.rightColumn.node.nodeSize, + pluginState.columnList.posBeforeNode, + pluginState.columnList.posBeforeNode + + pluginState.columnList.node.nodeSize, { - style: "cursor: col-resize", + class: "bn-column-list-hovered", }, ), - ]); + ]; + + // This adds a border between the columns when the user is + // resizing them or when the cursor is near their boundary. It's + // rendered on the left column of the pair - core's stylesheet also + // styles its right sibling. + if ( + pluginState.type === "hover-column" || + pluginState.type === "resize" + ) { + decorations.push( + Decoration.node( + pluginState.leftColumn.posBeforeNode, + pluginState.leftColumn.posBeforeNode + + pluginState.leftColumn.node.nodeSize, + { + class: "bn-column-resize-border", + }, + ), + ); + } + + return DecorationSet.create(state.doc, decorations); }, }, state: { diff --git a/packages/xl-multi-column/src/extensions/DropCursor/multiColumnDropCursor.ts b/packages/xl-multi-column/src/extensions/DropCursor/multiColumnDropCursor.ts index 61defa7886..77d93b7f4a 100644 --- a/packages/xl-multi-column/src/extensions/DropCursor/multiColumnDropCursor.ts +++ b/packages/xl-multi-column/src/extensions/DropCursor/multiColumnDropCursor.ts @@ -55,11 +55,8 @@ export function detectEdgePosition( let position: "regular" | "left" | "right" = "regular"; - if ( - event.clientX <= - blockRect.left + - blockRect.width * PERCENTAGE_OF_BLOCK_WIDTH_CONSIDERED_SIDE_DROP - ) { + if (event.clientX <= blockRect.left) { + // for left edge, there's no margin to consider (drop must be to left of the block) position = "left"; } else if ( event.clientX >= diff --git a/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts b/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts index 7c8e0b312e..a762f78d96 100644 --- a/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts +++ b/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts @@ -2,6 +2,7 @@ import type { BlockNoteEditor } from "@blocknote/core"; import { UniqueID, createExtension, + fragmentToBlocks, getBlockInfo, nodeToBlock, } from "@blocknote/core"; @@ -32,16 +33,30 @@ export function createMultiColumnHandleDropPlugin( return false; // Let ProseMirror handle regular drops } - if (slice.content.childCount === 0) { + // `fragmentToBlocks` instead of converting the fragment's children + // directly, as multi-block selections can produce fragments where the + // blocks are nested in e.g. a `blockGroup` node. + const draggedBlocks = fragmentToBlocks(slice.content); + if (draggedBlocks.length === 0) { return false; // Let ProseMirror handle empty slice drops } - - const draggedBlock = nodeToBlock( - slice.content.child(0), - view.state.doc, - ); + const draggedBlockIds = new Set(draggedBlocks.map((block) => block.id)); if (blockInfo.blockNoteType === "column") { + // The user is dropping the target column's entire contents on the + // column's own edge - the new column would just replace the + // emptied target in the same position, so do nothing. This also + // keeps the column's ID and width instead of resetting them. + let allTargetChildrenDragged = true; + blockInfo.bnBlock.node.forEach((child) => { + if (!draggedBlockIds.has(child.attrs.id)) { + allTargetChildrenDragged = false; + } + }); + if (allTargetChildrenDragged) { + return true; + } + // Insert new column in existing columnList const parentBlock = view.state.doc .resolve(blockInfo.bnBlock.beforePos) @@ -79,31 +94,63 @@ export function createMultiColumnHandleDropPlugin( }); } - const index = columnList.children.findIndex( - (b) => b.id === blockInfo.bnBlock.node.attrs.id, - ); + const targetColumnId = blockInfo.bnBlock.node.attrs.id; - const newChildren = columnList.children - // If the dragged block is in one of the columns, remove it. + // Tracks which of the dragged blocks were already in the column + // list - removing those from their old position is handled by + // filtering the column list's children instead of `removeBlocks`. + const blocksAlreadyInColumnList = new Set(); + const remainingColumns = columnList.children + // If any of the dragged blocks are in one of the columns, remove + // them. .map((column) => ({ ...column, - children: column.children.filter( - (block) => block.id !== draggedBlock.id, - ), + children: column.children.filter((block) => { + if (!draggedBlockIds.has(block.id)) { + return true; + } + + blocksAlreadyInColumnList.add(block.id); + return false; + }), })) - // Remove empty columns (can happen when dragged block is removed). - .filter((column) => column.children.length > 0) - // Insert the dragged block in the correct position. - .toSpliced(edgePos.position === "left" ? index : index + 1, 0, { - type: "column", - children: [draggedBlock], - props: {}, - content: undefined, - id: UniqueID.options.generateID(), - }); + // Remove empty columns (can happen when dragged blocks are + // removed). + .filter((column) => column.children.length > 0); + + // The insertion index is computed on the remaining columns, as + // removing an emptied column before the drop target shifts the + // target's position in the list. + const targetIndex = remainingColumns.findIndex( + (column) => column.id === targetColumnId, + ); + if (targetIndex === -1) { + // The target column can only be missing if the drag emptied it, + // which is handled as a no-op above. + throw new Error( + "Drop target column not found in the remaining columns", + ); + } + const insertionIndex = + edgePos.position === "left" ? targetIndex : targetIndex + 1; + + // Insert the dragged blocks as a new column in the correct + // position. + const newChildren = remainingColumns.toSpliced(insertionIndex, 0, { + type: "column", + children: draggedBlocks, + props: {}, + content: undefined, + id: UniqueID.options.generateID(), + }); - if (editor.getBlock(draggedBlock.id)) { - editor.removeBlocks([draggedBlock]); + const blocksToRemove = draggedBlocks.filter( + (block) => + editor.getBlock(block.id) && + !blocksAlreadyInColumnList.has(block.id), + ); + if (blocksToRemove.length > 0) { + editor.removeBlocks(blocksToRemove); } editor.updateBlock(columnList, { @@ -113,19 +160,22 @@ export function createMultiColumnHandleDropPlugin( // Create new columnList with blocks as columns const block = nodeToBlock(blockInfo.bnBlock.node, view.state.doc); - // The user is dropping next to the original block being dragged - do + // The user is dropping next to one of the blocks being dragged - do // nothing. - if (block.id === draggedBlock.id) { + if (draggedBlockIds.has(block.id)) { return true; } - const blocks = + const columns = edgePos.position === "left" - ? [draggedBlock, block] - : [block, draggedBlock]; + ? [draggedBlocks, [block]] + : [[block], draggedBlocks]; - if (editor.getBlock(draggedBlock.id)) { - editor.removeBlocks([draggedBlock]); + const blocksToRemove = draggedBlocks.filter((draggedBlock) => + editor.getBlock(draggedBlock.id), + ); + if (blocksToRemove.length > 0) { + editor.removeBlocks(blocksToRemove); } editor.replaceBlocks( @@ -133,10 +183,10 @@ export function createMultiColumnHandleDropPlugin( [ { type: "columnList", - children: blocks.map((b) => { + children: columns.map((children) => { return { type: "column", - children: [b], + children, }; }), }, diff --git a/playground/src/examples.gen.tsx b/playground/src/examples.gen.tsx index 85037877a4..c0eb48c336 100644 --- a/playground/src/examples.gen.tsx +++ b/playground/src/examples.gen.tsx @@ -1853,7 +1853,7 @@ export const examples = { tags: ["Extension"], pro: true, dependencies: { - "@tiptap/core": "^3.13.0", + "@tiptap/core": "^3.29.2", } as any, }, title: "TipTap extension (arrow InputRule)", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c4afc0c985..792387e2fc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4903,7 +4903,7 @@ importers: specifier: ^6.0.1 version: 6.0.1(babel-plugin-react-compiler@1.0.0)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0)) vite-plus: - specifier: 'catalog:' + specifier: ^0.1.24 version: 0.1.24(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.1.0))(terser@5.46.2)(tsx@4.21.0)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0))(yaml@2.9.0) packages/ariakit: @@ -6046,6 +6046,9 @@ importers: '@blocknote/shadcn': specifier: workspace:^ version: link:../packages/shadcn + '@blocknote/xl-multi-column': + specifier: workspace:^ + version: link:../packages/xl-multi-column '@playwright/test': specifier: 1.60.0 version: 1.60.0 @@ -11568,6 +11571,7 @@ packages: crypto-js@4.2.0: resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} + deprecated: Active development of CryptoJS has been discontinued. This library is no longer maintained. css-tree@3.2.1: resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} diff --git a/tests/package.json b/tests/package.json index 3ec4b42e3a..70dd65a13b 100644 --- a/tests/package.json +++ b/tests/package.json @@ -14,6 +14,7 @@ "@blocknote/mantine": "workspace:^", "@blocknote/react": "workspace:^", "@blocknote/shadcn": "workspace:^", + "@blocknote/xl-multi-column": "workspace:^", "@playwright/test": "1.60.0", "@tailwindcss/vite": "^4.1.14", "@tiptap/pm": "^3.29.2", diff --git a/tests/src/end-to-end/copypaste/copypaste.test.tsx b/tests/src/end-to-end/copypaste/copypaste.test.tsx index 0ec3db7215..eb5400db18 100644 --- a/tests/src/end-to-end/copypaste/copypaste.test.tsx +++ b/tests/src/end-to-end/copypaste/copypaste.test.tsx @@ -4,7 +4,10 @@ import NonEditableApp from "@examples/06-custom-schema/08-non-editable-block/src import { beforeEach, describe, test } from "vite-plus/test"; import { render } from "vitest-browser-react"; import { browserName, MOD, userEvent } from "../../utils/context.js"; -import { EDITOR_SELECTOR } from "../../utils/const.js"; +import { + DOC_TRAILING_BLOCK_SELECTOR, + EDITOR_SELECTOR, +} from "../../utils/const.js"; import { copyPaste, copyPasteAll, @@ -201,7 +204,7 @@ describe("Check Copy/Paste From Non-Editable Block", () => { // Click the trailing block to create a new empty paragraph and focus // the editor there. - await userEvent.click(await waitForSelector(".bn-trailing-block")); + await userEvent.click(await waitForSelector(DOC_TRAILING_BLOCK_SELECTOR)); await userEvent.keyboard(`{${MOD}>}v{/${MOD}}`); diff --git a/tests/src/end-to-end/dragdrop/dragdrop.test.tsx b/tests/src/end-to-end/dragdrop/dragdrop.test.tsx index 2873daec38..d435842966 100644 --- a/tests/src/end-to-end/dragdrop/dragdrop.test.tsx +++ b/tests/src/end-to-end/dragdrop/dragdrop.test.tsx @@ -1,8 +1,7 @@ -import PdfFileApp from "@examples/06-custom-schema/04-pdf-file-block/src/App"; import TestingApp from "@examples/01-basic/testing/src/App"; +import PdfFileApp from "@examples/06-custom-schema/04-pdf-file-block/src/App"; import { describe, expect, test } from "vite-plus/test"; import { render } from "vitest-browser-react"; -import { browserName, userEvent } from "../../utils/context.js"; import { EDITOR_SELECTOR, H_ONE_BLOCK_SELECTOR, @@ -12,6 +11,7 @@ import { PARAGRAPH_SELECTOR, PDF_SELECTOR, } from "../../utils/const.js"; +import { browserName, userEvent } from "../../utils/context.js"; import { insertHeading, insertParagraph } from "../../utils/copypaste.js"; import { compareDocToSnapshot, diff --git a/tests/src/end-to-end/multicolumn/__snapshots__/dragMultipleBlocksToNewColumn.json b/tests/src/end-to-end/multicolumn/__snapshots__/dragMultipleBlocksToNewColumn.json new file mode 100644 index 0000000000..cb3898851a --- /dev/null +++ b/tests/src/end-to-end/multicolumn/__snapshots__/dragMultipleBlocksToNewColumn.json @@ -0,0 +1,155 @@ +{ + "type": "doc", + "content": [ + { + "type": "blockGroup", + "content": [ + { + "type": "blockContainer", + "attrs": { + "id": "0" + }, + "content": [ + { + "type": "paragraph", + "attrs": { + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Paragraph outside columns" + } + ] + } + ] + }, + { + "type": "columnList", + "attrs": { + "id": "1" + }, + "content": [ + { + "type": "column", + "attrs": { + "id": "2", + "width": 1 + }, + "content": [ + { + "type": "blockContainer", + "attrs": { + "id": "3" + }, + "content": [ + { + "type": "paragraph", + "attrs": { + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Left column block" + } + ] + } + ] + } + ] + }, + { + "type": "column", + "attrs": { + "id": "8", + "width": 1 + }, + "content": [ + { + "type": "blockContainer", + "attrs": { + "id": "5" + }, + "content": [ + { + "type": "bulletListItem", + "attrs": { + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Right block 1" + } + ] + } + ] + }, + { + "type": "blockContainer", + "attrs": { + "id": "6" + }, + "content": [ + { + "type": "bulletListItem", + "attrs": { + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Right block 2" + } + ] + } + ] + } + ] + }, + { + "type": "column", + "attrs": { + "id": "4", + "width": 1 + }, + "content": [ + { + "type": "blockContainer", + "attrs": { + "id": "7" + }, + "content": [ + { + "type": "bulletListItem", + "attrs": { + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Right block 3" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/src/end-to-end/multicolumn/__snapshots__/dragOnlyBlockToLaterColumnEdge.json b/tests/src/end-to-end/multicolumn/__snapshots__/dragOnlyBlockToLaterColumnEdge.json new file mode 100644 index 0000000000..b81ee143bb --- /dev/null +++ b/tests/src/end-to-end/multicolumn/__snapshots__/dragOnlyBlockToLaterColumnEdge.json @@ -0,0 +1,133 @@ +{ + "type": "doc", + "content": [ + { + "type": "blockGroup", + "content": [ + { + "type": "blockContainer", + "attrs": { + "id": "0" + }, + "content": [ + { + "type": "paragraph", + "attrs": { + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Paragraph outside columns" + } + ] + } + ] + }, + { + "type": "columnList", + "attrs": { + "id": "1" + }, + "content": [ + { + "type": "column", + "attrs": { + "id": "4", + "width": 1 + }, + "content": [ + { + "type": "blockContainer", + "attrs": { + "id": "5" + }, + "content": [ + { + "type": "paragraph", + "attrs": { + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Column B block" + } + ] + } + ] + } + ] + }, + { + "type": "column", + "attrs": { + "id": "8", + "width": 1 + }, + "content": [ + { + "type": "blockContainer", + "attrs": { + "id": "3" + }, + "content": [ + { + "type": "paragraph", + "attrs": { + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Column A block" + } + ] + } + ] + } + ] + }, + { + "type": "column", + "attrs": { + "id": "6", + "width": 1 + }, + "content": [ + { + "type": "blockContainer", + "attrs": { + "id": "7" + }, + "content": [ + { + "type": "paragraph", + "attrs": { + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Column C block" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/src/end-to-end/multicolumn/__snapshots__/dragOnlyBlockToOtherSide.json b/tests/src/end-to-end/multicolumn/__snapshots__/dragOnlyBlockToOtherSide.json new file mode 100644 index 0000000000..679323a479 --- /dev/null +++ b/tests/src/end-to-end/multicolumn/__snapshots__/dragOnlyBlockToOtherSide.json @@ -0,0 +1,146 @@ +{ + "type": "doc", + "content": [ + { + "type": "blockGroup", + "content": [ + { + "type": "blockContainer", + "attrs": { + "id": "0" + }, + "content": [ + { + "type": "paragraph", + "attrs": { + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Paragraph outside columns" + } + ] + } + ] + }, + { + "type": "columnList", + "attrs": { + "id": "1" + }, + "content": [ + { + "type": "column", + "attrs": { + "id": "4", + "width": 1 + }, + "content": [ + { + "type": "blockContainer", + "attrs": { + "id": "5" + }, + "content": [ + { + "type": "bulletListItem", + "attrs": { + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Right block 1" + } + ] + } + ] + }, + { + "type": "blockContainer", + "attrs": { + "id": "6" + }, + "content": [ + { + "type": "bulletListItem", + "attrs": { + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Right block 2" + } + ] + } + ] + }, + { + "type": "blockContainer", + "attrs": { + "id": "7" + }, + "content": [ + { + "type": "bulletListItem", + "attrs": { + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Right block 3" + } + ] + } + ] + } + ] + }, + { + "type": "column", + "attrs": { + "id": "8", + "width": 1 + }, + "content": [ + { + "type": "blockContainer", + "attrs": { + "id": "3" + }, + "content": [ + { + "type": "paragraph", + "attrs": { + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Left column block" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/src/end-to-end/multicolumn/__snapshots__/dragOnlyBlockToOwnColumnEdge.json b/tests/src/end-to-end/multicolumn/__snapshots__/dragOnlyBlockToOwnColumnEdge.json new file mode 100644 index 0000000000..a4e0f20db9 --- /dev/null +++ b/tests/src/end-to-end/multicolumn/__snapshots__/dragOnlyBlockToOwnColumnEdge.json @@ -0,0 +1,146 @@ +{ + "type": "doc", + "content": [ + { + "type": "blockGroup", + "content": [ + { + "type": "blockContainer", + "attrs": { + "id": "0" + }, + "content": [ + { + "type": "paragraph", + "attrs": { + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Paragraph outside columns" + } + ] + } + ] + }, + { + "type": "columnList", + "attrs": { + "id": "1" + }, + "content": [ + { + "type": "column", + "attrs": { + "id": "2", + "width": 1 + }, + "content": [ + { + "type": "blockContainer", + "attrs": { + "id": "3" + }, + "content": [ + { + "type": "paragraph", + "attrs": { + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Left column block" + } + ] + } + ] + } + ] + }, + { + "type": "column", + "attrs": { + "id": "4", + "width": 1 + }, + "content": [ + { + "type": "blockContainer", + "attrs": { + "id": "5" + }, + "content": [ + { + "type": "bulletListItem", + "attrs": { + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Right block 1" + } + ] + } + ] + }, + { + "type": "blockContainer", + "attrs": { + "id": "6" + }, + "content": [ + { + "type": "bulletListItem", + "attrs": { + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Right block 2" + } + ] + } + ] + }, + { + "type": "blockContainer", + "attrs": { + "id": "7" + }, + "content": [ + { + "type": "bulletListItem", + "attrs": { + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Right block 3" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/src/end-to-end/multicolumn/__snapshots__/trailingBlockInColumn.json b/tests/src/end-to-end/multicolumn/__snapshots__/trailingBlockInColumn.json new file mode 100644 index 0000000000..9f17f6e8fe --- /dev/null +++ b/tests/src/end-to-end/multicolumn/__snapshots__/trailingBlockInColumn.json @@ -0,0 +1,223 @@ +{ + "type": "doc", + "content": [ + { + "type": "blockGroup", + "content": [ + { + "type": "blockContainer", + "attrs": { + "id": "0" + }, + "content": [ + { + "type": "paragraph", + "attrs": { + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Welcome to this demo!" + } + ] + } + ] + }, + { + "type": "columnList", + "attrs": { + "id": "1" + }, + "content": [ + { + "type": "column", + "attrs": { + "id": "2", + "width": 0.8 + }, + "content": [ + { + "type": "blockContainer", + "attrs": { + "id": "3" + }, + "content": [ + { + "type": "paragraph", + "attrs": { + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "This paragraph is in a column!" + } + ] + } + ] + }, + { + "type": "blockContainer", + "attrs": { + "id": "11" + }, + "content": [ + { + "type": "paragraph", + "attrs": { + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Inside the column" + } + ] + } + ] + } + ] + }, + { + "type": "column", + "attrs": { + "id": "4", + "width": 1.4 + }, + "content": [ + { + "type": "blockContainer", + "attrs": { + "id": "5" + }, + "content": [ + { + "type": "heading", + "attrs": { + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left", + "level": 1, + "isToggleable": false + }, + "content": [ + { + "type": "text", + "text": "So is this heading!" + } + ] + } + ] + } + ] + }, + { + "type": "column", + "attrs": { + "id": "6", + "width": 0.8 + }, + "content": [ + { + "type": "blockContainer", + "attrs": { + "id": "7" + }, + "content": [ + { + "type": "paragraph", + "attrs": { + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "You can have multiple blocks in a column too" + } + ] + } + ] + }, + { + "type": "blockContainer", + "attrs": { + "id": "8" + }, + "content": [ + { + "type": "bulletListItem", + "attrs": { + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Block 1" + } + ] + } + ] + }, + { + "type": "blockContainer", + "attrs": { + "id": "9" + }, + "content": [ + { + "type": "bulletListItem", + "attrs": { + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Block 2" + } + ] + } + ] + }, + { + "type": "blockContainer", + "attrs": { + "id": "10" + }, + "content": [ + { + "type": "bulletListItem", + "attrs": { + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Block 3" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/src/end-to-end/multicolumn/multicolumn.test.tsx b/tests/src/end-to-end/multicolumn/multicolumn.test.tsx index 40abf1b596..77cd1cd21d 100644 --- a/tests/src/end-to-end/multicolumn/multicolumn.test.tsx +++ b/tests/src/end-to-end/multicolumn/multicolumn.test.tsx @@ -1,14 +1,24 @@ import App from "@examples/01-basic/03-multi-column/src/App"; -import { beforeEach, describe, test } from "vite-plus/test"; +import { beforeEach, describe, expect, test } from "vite-plus/test"; import { render } from "vitest-browser-react"; import { MOD, page, userEvent } from "../../utils/context.js"; -import { EDITOR_SELECTOR } from "../../utils/const.js"; +import { + COLUMN_TRAILING_BLOCK_SELECTOR, + DOC_TRAILING_BLOCK_SELECTOR, + DRAG_HANDLE_SELECTOR, + EDITOR_SELECTOR, +} from "../../utils/const.js"; import { compareDocToSnapshot, focusOnEditor, waitForSelector, } from "../../utils/editor.js"; -import { clickAt, getRect } from "../../utils/mouse.js"; +import { + clickAt, + getRect, + mouseSequence, + moveMouseOverElement, +} from "../../utils/mouse.js"; beforeEach(async () => { await render(); @@ -33,7 +43,17 @@ describe("Check Multi-Column Behaviour", () => { test("Check Delete before column with single block", async () => { await focusOnEditor(); - await userEvent.click(await waitForSelector(".bn-block-column")); + // Clicks the end of the column's single block. Clicking the column itself + // would hit its trailing block widget and append a new block instead. + const target = page.getByText("This paragraph is in a column!").element(); + const range = document.createRange(); + range.selectNodeContents(target); + const lineRects = range.getClientRects(); + const lastLineRect = lineRects[lineRects.length - 1]; + await clickAt( + lastLineRect.right - 1, + lastLineRect.y + lastLineRect.height / 2, + ); await userEvent.keyboard("{Delete}"); @@ -48,10 +68,63 @@ describe("Check Multi-Column Behaviour", () => { await compareDocToSnapshot("deleteBeforeColumnList"); }); + test("Check column borders stay visible when hovering the side menu", async () => { + await focusOnEditor(); + + // Hovering a block in a column shows the borders between the columns. + await moveMouseOverElement(page.getByText("So is this heading!").element()); + await waitForSelector(".bn-column-list-hovered"); + + // The borders must stay visible when the mouse moves onto the side menu, + // which is rendered over the boundary between the first two columns. + await moveMouseOverElement(await waitForSelector(".bn-side-menu")); + expect(document.querySelector(".bn-column-list-hovered")).not.toBeNull(); + }); + test("Check resize border hides when hovering a side menu button", async () => { + await focusOnEditor(); + + // Hovering a block in a column shows the side menu for it. + const heading = page.getByText("So is this heading!").element(); + await moveMouseOverElement(heading); + await waitForSelector(".bn-side-menu"); + + // Moving the mouse near the boundary between the first two columns shows + // the resize border on it. + const headingRect = getRect(heading); + const columnRect = getRect(heading.closest(".bn-block-column")!); + await mouseSequence([ + { + type: "move", + x: columnRect.x + 5, + y: headingRect.y + headingRect.height / 2, + steps: 5, + }, + ]); + await waitForSelector(".bn-column-resize-border"); + + // Hovering one of the side menu's buttons hides the resize border, as the + // button is the likelier target there, while the lighter separators + // between the columns stay visible. + await moveMouseOverElement(await waitForSelector(DRAG_HANDLE_SELECTOR)); + expect(document.querySelector(".bn-column-resize-border")).toBeNull(); + expect(document.querySelector(".bn-column-list-hovered")).not.toBeNull(); + }); + test("Check clicking a column's trailing block appends a block to the column", async () => { + await focusOnEditor(); + + // The first column is shorter than its siblings, so its trailing block + // widget fills the leftover space below its last block (#2820). + await userEvent.click( + await waitForSelector(COLUMN_TRAILING_BLOCK_SELECTOR), + ); + await userEvent.keyboard("Inside the column"); + + await compareDocToSnapshot("trailingBlockInColumn"); + }); test("Check Delete end of column list", async () => { await focusOnEditor(); - await userEvent.click(await waitForSelector(".bn-trailing-block")); + await userEvent.click(await waitForSelector(DOC_TRAILING_BLOCK_SELECTOR)); await userEvent.keyboard("Paragraph"); await userEvent.keyboard(`{${MOD}>}{ArrowLeft}{/${MOD}}`); await userEvent.keyboard("{ArrowLeft}"); diff --git a/tests/src/end-to-end/multicolumn/multicolumnDrop.test.tsx b/tests/src/end-to-end/multicolumn/multicolumnDrop.test.tsx new file mode 100644 index 0000000000..1c90c86d16 --- /dev/null +++ b/tests/src/end-to-end/multicolumn/multicolumnDrop.test.tsx @@ -0,0 +1,222 @@ +import { BlockNoteSchema } from "@blocknote/core"; +import "@blocknote/core/fonts/inter.css"; +import { BlockNoteView } from "@blocknote/mantine"; +import "@blocknote/mantine/style.css"; +import { useCreateBlockNote } from "@blocknote/react"; +import { + multiColumnDropCursor, + withMultiColumn, +} from "@blocknote/xl-multi-column"; +import { beforeEach, describe, test } from "vite-plus/test"; +import { render } from "vitest-browser-react"; +import { EDITOR_SELECTOR } from "../../utils/const.js"; +import { browserName, page, userEvent } from "../../utils/context.js"; +import { compareDocToSnapshot, waitForSelector } from "../../utils/editor.js"; +import { clickAt, dragAndDropBlock, getRect } from "../../utils/mouse.js"; + +// A two-column layout where the left column contains a single block, for +// reproducing drops that empty out a column. The right column's blocks are +// bullet list items, as multi-block selections of those produce drag +// fragments where the blocks are nested in a `blockGroup` node. +function TwoColumnApp() { + const editor = useCreateBlockNote({ + schema: withMultiColumn(BlockNoteSchema.create()), + dropCursor: multiColumnDropCursor, + initialContent: [ + { + type: "paragraph", + content: "Paragraph outside columns", + }, + { + type: "columnList", + children: [ + { + type: "column", + children: [ + { + type: "paragraph", + content: "Left column block", + }, + ], + }, + { + type: "column", + children: [ + { + type: "bulletListItem", + content: "Right block 1", + }, + { + type: "bulletListItem", + content: "Right block 2", + }, + { + type: "bulletListItem", + content: "Right block 3", + }, + ], + }, + ], + }, + ], + }); + + return ; +} + +// Focuses the editor. Clicks a specific block instead of the editor itself, +// as the editor's center is covered by the side menu in this layout. +async function focusOnOutsideParagraph() { + await userEvent.click(page.getByText("Paragraph outside columns").element()); +} + +// Returns a block's content element, which spans the full column width - the +// text element inside it hugs the text, so its edges are too far from the +// column's edges for an edge drop. +function blockContent(text: string) { + return page.getByText(text).element().closest(".bn-block-content")!; +} + +// A three-column layout where each column contains a single block, for +// reproducing edge drops where an emptied column precedes the drop target. +function ThreeColumnApp() { + const editor = useCreateBlockNote({ + schema: withMultiColumn(BlockNoteSchema.create()), + dropCursor: multiColumnDropCursor, + initialContent: [ + { + type: "paragraph", + content: "Paragraph outside columns", + }, + { + type: "columnList", + children: [ + { + type: "column", + children: [{ type: "paragraph", content: "Column A block" }], + }, + { + type: "column", + children: [{ type: "paragraph", content: "Column B block" }], + }, + { + type: "column", + children: [{ type: "paragraph", content: "Column C block" }], + }, + ], + }, + ], + }); + + return ; +} + +// Playwright doesn't correctly simulate drag events in Firefox, hence the +// `skipIf`s. +describe("Check Multi-Column Drop Behaviour", () => { + beforeEach(async () => { + await render(); + await waitForSelector(EDITOR_SELECTOR); + }); + + // Dragging the only block of a column to the other side of the column list + // used to throw: the block was removed from the document before the column + // list was updated, which dissolved the (then single-column) column list + // that the update targeted. + test.skipIf(browserName === "firefox")( + "Check dragging a column's only block to a new column on the other side", + async () => { + await focusOnOutsideParagraph(); + + await dragAndDropBlock( + page.getByText("Left column block").element(), + blockContent("Right block 1"), + false, + ); + + await compareDocToSnapshot("dragOnlyBlockToOtherSide"); + }, + ); + + // Dragging a multi-block selection to the edge of a column used to only + // move the first block of the selection into the new column, or throw when + // the drag fragment nests the blocks in a `blockGroup` node. + test.skipIf(browserName === "firefox")( + "Check dragging multiple blocks to a new column", + async () => { + await focusOnOutsideParagraph(); + + // Selects the first two blocks in the right column. + const firstBlockRect = getRect(page.getByText("Right block 1").element()); + await clickAt(firstBlockRect.x + 1, firstBlockRect.y + 1); + await userEvent.keyboard("{Shift>}{ArrowDown}{ArrowRight}{/Shift}"); + + // Drags the selection to the right edge of the left column, which + // should move both blocks into a new column between the two existing + // ones. The drop target is the column element rather than its block + // content: the left column has right padding, and the column's edge + // drop zone is narrower than it - so the drop must be inside the + // padding. + await dragAndDropBlock( + page.getByText("Right block 1").element(), + page + .getByText("Left column block") + .element() + .closest(".bn-block-column")!, + false, + ); + + await compareDocToSnapshot("dragMultipleBlocksToNewColumn"); + }, + ); + + // Dropping a column's only block on that column's own edge used to move + // the block to the other side of the column list: the insertion index was + // computed before the emptied column was filtered out, so it pointed one + // slot too far to the right. It's now a no-op, so the document (including + // the column's ID) should stay unchanged. + test.skipIf(browserName === "firefox")( + "Check dragging a column's only block to its own column's edge", + async () => { + await focusOnOutsideParagraph(); + + await dragAndDropBlock( + page.getByText("Left column block").element(), + page + .getByText("Left column block") + .element() + .closest(".bn-block-column")!, + false, + ); + + await compareDocToSnapshot("dragOnlyBlockToOwnColumnEdge"); + }, + ); +}); + +describe("Check Multi-Column Drop Behaviour With Three Columns", () => { + beforeEach(async () => { + await render(); + await waitForSelector(EDITOR_SELECTOR); + }); + + // Dragging the only block of the first column onto the second column's + // right edge should place the new column directly after the second column. + // The insertion index used to be computed before the emptied first column + // was filtered out, which shifted the new column one slot too far to the + // right (after the third column). + test.skipIf(browserName === "firefox")( + "Check dragging a column's only block to a later column's edge", + async () => { + await focusOnOutsideParagraph(); + + await dragAndDropBlock( + page.getByText("Column A block").element(), + page.getByText("Column B block").element().closest(".bn-block-column")!, + false, + ); + + await compareDocToSnapshot("dragOnlyBlockToLaterColumnEdge"); + }, + ); +}); diff --git a/tests/src/utils/const.ts b/tests/src/utils/const.ts index 379f01d840..93d7f13b73 100644 --- a/tests/src/utils/const.ts +++ b/tests/src/utils/const.ts @@ -1,6 +1,9 @@ export const EDITOR_SELECTOR = `.bn-editor`; export const BLOCK_CONTAINER_SELECTOR = `[data-node-type="blockContainer"]`; export const BLOCK_GROUP_SELECTOR = `[data-node-type="blockGroup"]`; +/* The document-level trailing block, as opposed to the ones columns render. */ +export const DOC_TRAILING_BLOCK_SELECTOR = `.bn-editor > .bn-block-group > .bn-trailing-block`; +export const COLUMN_TRAILING_BLOCK_SELECTOR = `.bn-block-column > .bn-trailing-block`; export const H_ONE_BLOCK_SELECTOR = `[data-content-type=heading]:not([data-level])`; export const H_TWO_BLOCK_SELECTOR = `[data-content-type=heading][data-level="2"]`; diff --git a/tests/src/utils/copypaste.ts b/tests/src/utils/copypaste.ts index 472df07cf6..d6d08991e0 100644 --- a/tests/src/utils/copypaste.ts +++ b/tests/src/utils/copypaste.ts @@ -1,3 +1,4 @@ +import { DOC_TRAILING_BLOCK_SELECTOR } from "./const.js"; import { MOD, userEvent } from "./context.js"; export function selectAll() { @@ -10,8 +11,9 @@ export async function copyPaste() { await userEvent.keyboard("{Escape}"); // The trailing block isn't always present (e.g. when the editor's last block // can't have one), so fall back to the last paragraph. - const trailingBlock = - document.querySelector(".bn-trailing-block"); + const trailingBlock = document.querySelector( + DOC_TRAILING_BLOCK_SELECTOR, + ); if (trailingBlock) { await userEvent.click(trailingBlock); } else {