diff --git a/examples/01-basic/03-all-blocks/App.tsx b/examples/01-basic/03-all-blocks/App.tsx index 0bd5d9ba97..3dbb425b24 100644 --- a/examples/01-basic/03-all-blocks/App.tsx +++ b/examples/01-basic/03-all-blocks/App.tsx @@ -39,6 +39,9 @@ export default function App() { children: [ { type: "column", + props: { + width: 0.8, + }, children: [ { type: "paragraph", @@ -48,6 +51,9 @@ export default function App() { }, { type: "column", + props: { + width: 1.2, + }, children: [ { type: "paragraph", diff --git a/packages/core/src/editor/Block.css b/packages/core/src/editor/Block.css index f46f621c05..1578ffd9d2 100644 --- a/packages/core/src/editor/Block.css +++ b/packages/core/src/editor/Block.css @@ -403,6 +403,7 @@ NESTED BLOCKS [data-file-block] .bn-file-caption { font-size: 0.8em; padding-block: 4px; + word-break: break-word; } [data-file-block] .bn-file-caption:empty { @@ -519,9 +520,20 @@ NESTED BLOCKS .bn-block[data-node-type="columnList"] { display: flex; flex-direction: row; - gap: 10px; + /* gap: 10px; */ } .bn-block[data-node-type="columnList"] > div { flex: 1; + padding: 12px 20px; + /* Important that we use scroll instead of hidden for tables */ + overflow-x: scroll; +} + +.bn-block[data-node-type="columnList"] > div:first-child { + padding-left: 0; +} + +.bn-block[data-node-type="columnList"] > div:last-child { + padding-right: 0; } diff --git a/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts b/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts index c1d91401be..8af20491fe 100644 --- a/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts +++ b/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts @@ -222,7 +222,11 @@ export class SideMenuView< show: true, referencePos: new DOMRect( column - ? column.getBoundingClientRect().x + ? // We take the first child as column elements have some default + // padding. This is a little weird since this child element will + // be the first block, but since it's always non-nested and we + // only take the x coordinate, it's ok. + column.firstElementChild!.getBoundingClientRect().x : ( this.pmView.dom.firstChild as HTMLElement ).getBoundingClientRect().x, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7a45f99e66..e171b5af64 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2,6 +2,7 @@ import * as locales from "./i18n/locales/index.js"; export * from "./api/exporters/html/externalHTMLExporter.js"; export * from "./api/exporters/html/internalHTMLSerializer.js"; export * from "./api/getBlockInfoFromPos.js"; +export * from "./api/nodeUtil.js"; export * from "./api/testUtil/index.js"; export * from "./blocks/AudioBlockContent/AudioBlockContent.js"; export * from "./blocks/CodeBlockContent/CodeBlockContent.js"; diff --git a/packages/multi-column/package.json b/packages/multi-column/package.json index cb4d229a2f..91fd3291dd 100644 --- a/packages/multi-column/package.json +++ b/packages/multi-column/package.json @@ -45,6 +45,7 @@ }, "dependencies": { "@blocknote/core": "*", + "@tiptap/core": "^2.7.1", "prosemirror-model": "^1.23.0", "prosemirror-state": "^1.4.3", "prosemirror-tables": "^1.3.7", diff --git a/packages/multi-column/src/blocks/Columns/index.ts b/packages/multi-column/src/blocks/Columns/index.ts index bd23b147d8..98bf0ba3d2 100644 --- a/packages/multi-column/src/blocks/Columns/index.ts +++ b/packages/multi-column/src/blocks/Columns/index.ts @@ -3,10 +3,11 @@ import { ColumnList } from "../../pm-nodes/ColumnList.js"; import { createBlockSpecFromStronglyTypedTiptapNode } from "@blocknote/core"; -export const ColumnBlock = createBlockSpecFromStronglyTypedTiptapNode( - Column, - {} -); +export const ColumnBlock = createBlockSpecFromStronglyTypedTiptapNode(Column, { + width: { + default: 1, + }, +}); export const ColumnListBlock = createBlockSpecFromStronglyTypedTiptapNode( ColumnList, diff --git a/packages/multi-column/src/extensions/ColumnResize/ColumnResizeExtension.ts b/packages/multi-column/src/extensions/ColumnResize/ColumnResizeExtension.ts new file mode 100644 index 0000000000..a9a3ba445a --- /dev/null +++ b/packages/multi-column/src/extensions/ColumnResize/ColumnResizeExtension.ts @@ -0,0 +1,357 @@ +import { BlockNoteEditor, getNodeById } from "@blocknote/core"; +import { Extension } from "@tiptap/core"; +import { Node } from "prosemirror-model"; +import { Plugin, PluginKey, PluginView } from "prosemirror-state"; +import { Decoration, DecorationSet, EditorView } from "prosemirror-view"; + +type ColumnData = { + element: HTMLElement; + id: string; + node: Node; + posBeforeNode: number; +}; + +type ColumnDataWithWidths = ColumnData & { + widthPx: number; + widthPercent: number; +}; + +type ColumnDefaultState = { + type: "default"; +}; + +type ColumnHoverState = { + type: "hover"; + leftColumn: ColumnData; + rightColumn: ColumnData; +}; + +type ColumnResizeState = { + type: "resize"; + startPos: number; + leftColumn: ColumnDataWithWidths; + rightColumn: ColumnDataWithWidths; +}; + +type ColumnState = ColumnDefaultState | ColumnHoverState | ColumnResizeState; + +const columnResizePluginKey = new PluginKey("ColumnResizePlugin"); + +class ColumnResizePluginView implements PluginView { + editor: BlockNoteEditor; + view: EditorView; + + readonly RESIZE_MARGIN_WIDTH_PX = 20; + readonly COLUMN_MIN_WIDTH_PERCENT = 0.5; + + constructor(editor: BlockNoteEditor, view: EditorView) { + this.editor = editor; + this.view = view; + + this.view.dom.addEventListener("mousedown", this.mouseDownHandler); + document.body.addEventListener("mousemove", this.mouseMoveHandler); + document.body.addEventListener("mouseup", this.mouseUpHandler); + } + + getColumnHoverOrDefaultState = ( + event: MouseEvent + ): ColumnDefaultState | ColumnHoverState => { + const target = event.target as HTMLElement; + + // Do nothing if the event target is outside the editor. + if (!this.view.dom.contains(target)) { + return { type: "default" }; + } + + const columnElement = target.closest( + ".bn-block-column" + ) as HTMLElement | null; + + // Do nothing if a column element does not exist in the event target's + // ancestors. + if (!columnElement) { + return { type: "default" }; + } + + const startPos = event.clientX; + const columnElementDOMRect = columnElement.getBoundingClientRect(); + + // Whether the cursor is within the width margin to trigger a resize. + const cursorElementSide = + startPos < columnElementDOMRect.left + this.RESIZE_MARGIN_WIDTH_PX + ? "left" + : startPos > columnElementDOMRect.right - this.RESIZE_MARGIN_WIDTH_PX + ? "right" + : "none"; + + // The column element before or after the one hovered by the cursor, + // depending on which side the cursor is on. + const adjacentColumnElement = + cursorElementSide === "left" + ? columnElement.previousElementSibling + : cursorElementSide === "right" + ? 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 (!adjacentColumnElement) { + return { type: "default" }; + } + + const leftColumnElement = + cursorElementSide === "left" + ? (adjacentColumnElement as HTMLElement) + : columnElement; + + const rightColumnElement = + cursorElementSide === "left" + ? columnElement + : (adjacentColumnElement as HTMLElement); + + const leftColumnId = leftColumnElement.getAttribute("data-id")!; + const rightColumnId = rightColumnElement.getAttribute("data-id")!; + + const leftColumnNodeAndPos = getNodeById(leftColumnId, this.view.state.doc); + + const rightColumnNodeAndPos = getNodeById( + rightColumnId, + this.view.state.doc + ); + + if ( + !leftColumnNodeAndPos || + !rightColumnNodeAndPos || + !leftColumnNodeAndPos.posBeforeNode + ) { + throw new Error("Column not found"); + } + + return { + type: "hover", + leftColumn: { + element: leftColumnElement, + id: leftColumnId, + ...leftColumnNodeAndPos, + }, + rightColumn: { + element: rightColumnElement, + id: rightColumnId, + ...rightColumnNodeAndPos, + }, + }; + }; + + // When the user mouses down near the boundary between two columns, we + // want to set the plugin state to resize, so the columns can be resized + // by moving the mouse. + mouseDownHandler = (event: MouseEvent) => { + let newState: ColumnState = this.getColumnHoverOrDefaultState(event); + if (newState.type === "default") { + return; + } + + event.preventDefault(); + + const startPos = event.clientX; + + const leftColumnWidthPx = + newState.leftColumn.element.getBoundingClientRect().width; + const rightColumnWidthPx = + newState.rightColumn.element.getBoundingClientRect().width; + + const leftColumnWidthPercent = newState.leftColumn.node.attrs + .width as number; + const rightColumnWidthPercent = newState.rightColumn.node.attrs + .width as number; + + newState = { + type: "resize", + startPos, + leftColumn: { + ...newState.leftColumn, + widthPx: leftColumnWidthPx, + widthPercent: leftColumnWidthPercent, + }, + rightColumn: { + ...newState.rightColumn, + widthPx: rightColumnWidthPx, + widthPercent: rightColumnWidthPercent, + }, + }; + + this.view.dispatch( + this.view.state.tr.setMeta(columnResizePluginKey, newState) + ); + + this.editor.sideMenu.freezeMenu(); + }; + + // If the plugin isn't in a resize state, we want to update it to either a + // hover state if the mouse is near the boundary between two columns, or + // default otherwise. If the plugin is in a resize state, we want to + // update the column widths based on the horizontal mouse movement. + mouseMoveHandler = (event: MouseEvent) => { + const pluginState = columnResizePluginKey.getState(this.view.state); + if (!pluginState) { + return; + } + + // 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") { + 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" && + pluginState.leftColumn.id === newState.leftColumn.id && + pluginState.rightColumn.id === newState.rightColumn.id; + if (bothDefaultStates || sameColumnIds) { + return; + } + + // Since the resize bar overlaps the side menu, we don't want to show it + // if the side menu is already open. + if (newState.type === "hover" && this.editor.sideMenu.view?.state?.show) { + return; + } + + // Update the plugin state. + this.view.dispatch( + this.view.state.tr.setMeta(columnResizePluginKey, newState) + ); + + return; + } + + const widthChangePx = event.clientX - pluginState.startPos; + // We need to scale the width change by the left column's width in + // percent, otherwise the rate at which the resizing happens will change + // based on the width of the left column. + const scaledWidthChangePx = + widthChangePx * pluginState.leftColumn.widthPercent; + const widthChangePercent = + (pluginState.leftColumn.widthPx + scaledWidthChangePx) / + pluginState.leftColumn.widthPx - + 1; + + let newLeftColumnWidth = + pluginState.leftColumn.widthPercent + widthChangePercent; + let newRightColumnWidth = + pluginState.rightColumn.widthPercent - widthChangePercent; + + // Ensures that the column widths do not go below the minimum width. + // There is no maximum width, the user can resize the columns as much as + // they want provided the others don't go below the minimum width. + if (newLeftColumnWidth < this.COLUMN_MIN_WIDTH_PERCENT) { + newRightColumnWidth -= this.COLUMN_MIN_WIDTH_PERCENT - newLeftColumnWidth; + newLeftColumnWidth = this.COLUMN_MIN_WIDTH_PERCENT; + } else if (newRightColumnWidth < this.COLUMN_MIN_WIDTH_PERCENT) { + newLeftColumnWidth -= this.COLUMN_MIN_WIDTH_PERCENT - newRightColumnWidth; + newRightColumnWidth = this.COLUMN_MIN_WIDTH_PERCENT; + } + + // possible improvement: only dispatch on mouse up, and use a different way + // to update the column widths while dragging. + // this prevents a lot of document updates + this.view.dispatch( + this.view.state.tr + .setNodeAttribute( + pluginState.leftColumn.posBeforeNode, + "width", + newLeftColumnWidth + ) + .setNodeAttribute( + pluginState.rightColumn.posBeforeNode, + "width", + newRightColumnWidth + ) + .setMeta("addToHistory", false) + ); + }; + + // If the plugin is in a resize state, we want to revert it to a default + // or hover, depending on where the mouse cursor is, when the user + // releases the mouse button. + mouseUpHandler = (event: MouseEvent) => { + const pluginState = columnResizePluginKey.getState(this.view.state); + if (!pluginState || pluginState.type !== "resize") { + return; + } + + const newState = this.getColumnHoverOrDefaultState(event); + + // Revert plugin state to default or hover, depending on where the mouse + // cursor is. + this.view.dispatch( + this.view.state.tr.setMeta(columnResizePluginKey, newState) + ); + + this.editor.sideMenu.unfreezeMenu(); + }; + + // This is a required method for PluginView, so we get a type error if we + // don't implement it. + update: undefined; +} + +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", + } + ), + Decoration.node( + pluginState.rightColumn.posBeforeNode, + pluginState.rightColumn.posBeforeNode + + pluginState.rightColumn.node.nodeSize, + { + style: "cursor: col-resize", + } + ), + ]); + }, + }, + state: { + init: () => ({ type: "default" } as ColumnState), + apply: (tr, oldPluginState) => { + const newPluginState = tr.getMeta(columnResizePluginKey) as + | ColumnState + | undefined; + + return newPluginState === undefined ? oldPluginState : newPluginState; + }, + }, + view: (view) => new ColumnResizePluginView(editor, view), + }); + +export const createColumnResizeExtension = ( + editor: BlockNoteEditor +) => + Extension.create({ + name: "columnResize", + addProseMirrorPlugins() { + return [createColumnResizePlugin(editor)]; + }, + }); diff --git a/packages/multi-column/src/extensions/DropCursor/MultiColumnDropCursorPlugin.ts b/packages/multi-column/src/extensions/DropCursor/MultiColumnDropCursorPlugin.ts index 6996dcf0ce..c586a0f942 100644 --- a/packages/multi-column/src/extensions/DropCursor/MultiColumnDropCursorPlugin.ts +++ b/packages/multi-column/src/extensions/DropCursor/MultiColumnDropCursorPlugin.ts @@ -100,6 +100,32 @@ export function multiColumnDropCursor( editor.schema.styleSchema ); + // In a `columnList`, we expect that the average width of each column + // is 1. However, there are cases in which this stops being true. For + // example, having one wider column and then removing it will cause + // the average width to go down. This isn't really an issue until the + // user tries to add a new column, which will, in this case, be wider + // than expected. Therefore, we normalize the column widths to an + // average of 1 here to avoid this issue. + let sumColumnWidthPercent = 0; + columnList.children.forEach((column) => { + sumColumnWidthPercent += column.props.width as number; + }); + const avgColumnWidthPercent = + sumColumnWidthPercent / columnList.children.length; + + // If the average column width is not 1, normalize it. We're dealing + // with floats so we need a small margin to account for precision + // errors. + if (avgColumnWidthPercent < 0.99 || avgColumnWidthPercent > 1.01) { + const scalingFactor = 1 / avgColumnWidthPercent; + + columnList.children.forEach((column) => { + column.props.width = + (column.props.width as number) * scalingFactor; + }); + } + const index = columnList.children.findIndex( (b) => b.id === blockInfo.bnBlock.node.attrs.id ); diff --git a/packages/multi-column/src/pm-nodes/Column.ts b/packages/multi-column/src/pm-nodes/Column.ts index 4f1e3d5a51..8babf6c40e 100644 --- a/packages/multi-column/src/pm-nodes/Column.ts +++ b/packages/multi-column/src/pm-nodes/Column.ts @@ -1,16 +1,6 @@ -import { - createStronglyTypedTiptapNode, - mergeCSSClasses, -} from "@blocknote/core"; +import { createStronglyTypedTiptapNode } from "@blocknote/core"; -// TODO: necessary? -const BlockAttributes: Record = { - blockColor: "data-block-color", - blockStyle: "data-block-style", - id: "data-id", - depth: "data-depth", - depthChange: "data-depth-change", -}; +import { createColumnResizeExtension } from "../extensions/ColumnResize/ColumnResizeExtension.js"; export const Column = createStronglyTypedTiptapNode({ name: "column", @@ -20,7 +10,45 @@ export const Column = createStronglyTypedTiptapNode({ priority: 40, // defining: true, // TODO - // TODO + addAttributes() { + return { + width: { + // Why does each column have a default width of 1, i.e. 100%? Because + // when creating a new column, we want to make sure that existing + // column widths are preserved, while the new one also has a sensible + // width. If we'd set it so all column widths must add up to 100% + // instead, then each time a new column is created, we'd have to assign + // it a width depending on the total number of columns and also adjust + // the widths of the other columns. The same can be said for using px + // instead of percent widths and making them add to the editor width. So + // using this method is both simpler and computationally cheaper. This + // is possible because we can set the `flex-grow` property to the width + // value, which handles all the resizing for us, instead of manually + // having to set the `width` property of each column. + default: 1, + parseHTML: (element) => { + const attr = element.getAttribute("data-width"); + if (attr === null) { + return null; + } + + const parsed = parseFloat(attr); + if (isFinite(parsed)) { + return parsed; + } + + return null; + }, + renderHTML: (attributes) => { + return { + "data-width": (attributes.width as number).toString(), + style: `flex-grow: ${attributes.width as number};`, + }; + }, + }, + }; + }, + parseHTML() { return [ { @@ -30,15 +58,8 @@ export const Column = createStronglyTypedTiptapNode({ return false; } - const attrs: Record = {}; - for (const [nodeAttr, HTMLAttr] of Object.entries(BlockAttributes)) { - if (element.getAttribute(HTMLAttr)) { - attrs[nodeAttr] = element.getAttribute(HTMLAttr)!; - } - } - if (element.getAttribute("data-node-type") === this.name) { - return attrs; + return {}; } return false; @@ -47,35 +68,23 @@ export const Column = createStronglyTypedTiptapNode({ ]; }, - // TODO, needed? + type of attributes renderHTML({ HTMLAttributes }) { - const blockOuter = document.createElement("div"); - blockOuter.className = "bn-block-outer"; - blockOuter.setAttribute("data-node-type", "blockOuter"); + const column = document.createElement("div"); + column.className = "bn-block-column"; + column.setAttribute("data-node-type", this.name); for (const [attribute, value] of Object.entries(HTMLAttributes)) { if (attribute !== "class") { - blockOuter.setAttribute(attribute, value); - } - } - - const blockHTMLAttributes = { - ...(this.options.domAttributes?.block || {}), - ...HTMLAttributes, - }; - const block = document.createElement("div"); - block.className = mergeCSSClasses("bn-block", blockHTMLAttributes.class); - block.setAttribute("data-node-type", this.name); - for (const [attribute, value] of Object.entries(blockHTMLAttributes)) { - if (attribute !== "class") { - block.setAttribute(attribute, value as any); // TODO as any + column.setAttribute(attribute, value as any); // TODO as any } } - blockOuter.appendChild(block); - return { - dom: blockOuter, - contentDOM: block, + dom: column, + contentDOM: column, }; }, + + addExtensions() { + return [createColumnResizeExtension(this.options.editor)]; + }, }); diff --git a/playground/vite.config.ts b/playground/vite.config.ts index ead9639a7a..e380406d15 100644 --- a/playground/vite.config.ts +++ b/playground/vite.config.ts @@ -50,6 +50,10 @@ export default defineConfig((conf) => ({ __dirname, "../packages/shadcn/src/" ), + "@blocknote/multi-column": path.resolve( + __dirname, + "../packages/multi-column/src/" + ), }, }, })); diff --git a/tests/src/end-to-end/ariakit/ariakit.test.ts-snapshots/ariakit-emoji-picker-chromium-linux.png b/tests/src/end-to-end/ariakit/ariakit.test.ts-snapshots/ariakit-emoji-picker-chromium-linux.png index 7f5d6c4c9b..025144361e 100644 Binary files a/tests/src/end-to-end/ariakit/ariakit.test.ts-snapshots/ariakit-emoji-picker-chromium-linux.png and b/tests/src/end-to-end/ariakit/ariakit.test.ts-snapshots/ariakit-emoji-picker-chromium-linux.png differ diff --git a/tests/src/end-to-end/ariakit/ariakit.test.ts-snapshots/ariakit-emoji-picker-webkit-linux.png b/tests/src/end-to-end/ariakit/ariakit.test.ts-snapshots/ariakit-emoji-picker-webkit-linux.png index 2a31a61200..12b21109da 100644 Binary files a/tests/src/end-to-end/ariakit/ariakit.test.ts-snapshots/ariakit-emoji-picker-webkit-linux.png and b/tests/src/end-to-end/ariakit/ariakit.test.ts-snapshots/ariakit-emoji-picker-webkit-linux.png differ diff --git a/tests/src/end-to-end/shadcn/shadcn.test.ts-snapshots/shadcn-emoji-picker-chromium-linux.png b/tests/src/end-to-end/shadcn/shadcn.test.ts-snapshots/shadcn-emoji-picker-chromium-linux.png index a891246a12..b31771551d 100644 Binary files a/tests/src/end-to-end/shadcn/shadcn.test.ts-snapshots/shadcn-emoji-picker-chromium-linux.png and b/tests/src/end-to-end/shadcn/shadcn.test.ts-snapshots/shadcn-emoji-picker-chromium-linux.png differ diff --git a/tests/src/end-to-end/shadcn/shadcn.test.ts-snapshots/shadcn-emoji-picker-webkit-linux.png b/tests/src/end-to-end/shadcn/shadcn.test.ts-snapshots/shadcn-emoji-picker-webkit-linux.png index b112870f11..3c6df41907 100644 Binary files a/tests/src/end-to-end/shadcn/shadcn.test.ts-snapshots/shadcn-emoji-picker-webkit-linux.png and b/tests/src/end-to-end/shadcn/shadcn.test.ts-snapshots/shadcn-emoji-picker-webkit-linux.png differ diff --git a/tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-emoji-picker-chromium-linux.png b/tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-emoji-picker-chromium-linux.png index 50b29b5367..29be7bae5d 100644 Binary files a/tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-emoji-picker-chromium-linux.png and b/tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-emoji-picker-chromium-linux.png differ diff --git a/tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-emoji-picker-webkit-linux.png b/tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-emoji-picker-webkit-linux.png index 56531fe120..83133efd93 100644 Binary files a/tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-emoji-picker-webkit-linux.png and b/tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-emoji-picker-webkit-linux.png differ