UNPKG

@blocknote/xl-multi-column

Version:

A "Notion-style" block-based extensible text editor built on top of Prosemirror and Tiptap.

364 lines (309 loc) 11.7 kB
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<ColumnState>("ColumnResizePlugin"); class ColumnResizePluginView implements PluginView { editor: BlockNoteEditor<any, any, any>; view: EditorView; readonly RESIZE_MARGIN_WIDTH_PX = 20; readonly COLUMN_MIN_WIDTH_PERCENT = 0.5; constructor(editor: BlockNoteEditor<any, any, any>, 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 => { if (!this.editor.isEditable) { return { type: "default" }; } 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(); }; destroy() { this.view.dom.removeEventListener("mousedown", this.mouseDownHandler); document.body.removeEventListener("mousemove", this.mouseMoveHandler); document.body.removeEventListener("mouseup", this.mouseUpHandler); } } const createColumnResizePlugin = (editor: BlockNoteEditor<any, any, any>) => 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<any, any, any>, ) => Extension.create({ name: "columnResize", addProseMirrorPlugins() { return [createColumnResizePlugin(editor)]; }, });