UNPKG

@blocknote/core

Version:

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

1,211 lines (1,060 loc) 37.4 kB
import { EditorState, Plugin, PluginKey, PluginView } from "prosemirror-state"; import { CellSelection, addColumnAfter, addColumnBefore, addRowAfter, addRowBefore, deleteColumn, deleteRow, mergeCells, splitCell, } from "prosemirror-tables"; import { Decoration, DecorationSet, EditorView } from "prosemirror-view"; import { RelativeCellIndices, addRowsOrColumns, areInSameColumn, canColumnBeDraggedInto, canRowBeDraggedInto, cropEmptyRowsOrColumns, getCellsAtColumnHandle, getCellsAtRowHandle, getDimensionsOfTable, moveColumn, moveRow, } from "../../api/blockManipulation/tables/tables.js"; import { nodeToBlock } from "../../api/nodeConversions/nodeToBlock.js"; import { getNodeById } from "../../api/nodeUtil.js"; import { checkBlockIsDefaultType, isTableCellSelection, } from "../../blocks/defaultBlockTypeGuards.js"; import { DefaultBlockSchema } from "../../blocks/defaultBlocks.js"; import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import { BlockNoteExtension } from "../../editor/BlockNoteExtension.js"; import { BlockFromConfigNoChildren, BlockSchemaWithBlock, InlineContentSchema, StyleSchema, } from "../../schema/index.js"; import { getDraggableBlockFromElement } from "../getDraggableBlockFromElement.js"; let dragImageElement: HTMLElement | undefined; // TODO consider switching this to jotai, it is a bit messy and noisy export type TableHandlesState< I extends InlineContentSchema, S extends StyleSchema, > = { show: boolean; showAddOrRemoveRowsButton: boolean; showAddOrRemoveColumnsButton: boolean; referencePosCell: DOMRect | undefined; referencePosTable: DOMRect; block: BlockFromConfigNoChildren<DefaultBlockSchema["table"], I, S>; colIndex: number | undefined; rowIndex: number | undefined; draggingState: | { draggedCellOrientation: "row" | "col"; originalIndex: number; mousePos: number; } | undefined; widgetContainer: HTMLElement | undefined; }; function setHiddenDragImage(rootEl: Document | ShadowRoot) { if (dragImageElement) { return; } dragImageElement = document.createElement("div"); dragImageElement.innerHTML = "_"; dragImageElement.style.opacity = "0"; dragImageElement.style.height = "1px"; dragImageElement.style.width = "1px"; if (rootEl instanceof Document) { rootEl.body.appendChild(dragImageElement); } else { rootEl.appendChild(dragImageElement); } } function unsetHiddenDragImage(rootEl: Document | ShadowRoot) { if (dragImageElement) { if (rootEl instanceof Document) { rootEl.body.removeChild(dragImageElement); } else { rootEl.removeChild(dragImageElement); } dragImageElement = undefined; } } function getChildIndex(node: Element) { return Array.prototype.indexOf.call(node.parentElement!.childNodes, node); } // Finds the DOM element corresponding to the table cell that the target element // is currently in. If the target element is not in a table cell, returns null. function domCellAround(target: Element) { let currentTarget: Element | undefined = target; while ( currentTarget && currentTarget.nodeName !== "TD" && currentTarget.nodeName !== "TH" && !currentTarget.classList.contains("tableWrapper") ) { if (currentTarget.classList.contains("ProseMirror")) { return undefined; } const parent: ParentNode | null = currentTarget.parentNode; if (!parent || !(parent instanceof Element)) { return undefined; } currentTarget = parent; } return currentTarget.nodeName === "TD" || currentTarget.nodeName === "TH" ? { type: "cell", domNode: currentTarget, tbodyNode: currentTarget.closest("tbody"), } : { type: "wrapper", domNode: currentTarget, tbodyNode: currentTarget.querySelector("tbody"), }; } // Hides elements in the DOMwith the provided class names. function hideElements(selector: string, rootEl: Document | ShadowRoot) { const elementsToHide = rootEl.querySelectorAll(selector); for (let i = 0; i < elementsToHide.length; i++) { (elementsToHide[i] as HTMLElement).style.visibility = "hidden"; } } export class TableHandlesView< I extends InlineContentSchema, S extends StyleSchema, > implements PluginView { public state?: TableHandlesState<I, S>; public emitUpdate: () => void; public tableId: string | undefined; public tablePos: number | undefined; public tableElement: HTMLElement | undefined; public menuFrozen = false; public mouseState: "up" | "down" | "selecting" = "up"; public prevWasEditable: boolean | null = null; constructor( private readonly editor: BlockNoteEditor< BlockSchemaWithBlock<"table", DefaultBlockSchema["table"]>, I, S >, private readonly pmView: EditorView, emitUpdate: (state: TableHandlesState<I, S>) => void, ) { this.emitUpdate = () => { if (!this.state) { throw new Error("Attempting to update uninitialized image toolbar"); } emitUpdate(this.state); }; pmView.dom.addEventListener("mousemove", this.mouseMoveHandler); pmView.dom.addEventListener("mousedown", this.viewMousedownHandler); window.addEventListener("mouseup", this.mouseUpHandler); pmView.root.addEventListener( "dragover", this.dragOverHandler as EventListener, ); pmView.root.addEventListener( "drop", this.dropHandler as unknown as EventListener, ); } viewMousedownHandler = () => { this.mouseState = "down"; }; mouseUpHandler = (event: MouseEvent) => { this.mouseState = "up"; this.mouseMoveHandler(event); }; mouseMoveHandler = (event: MouseEvent) => { if (this.menuFrozen) { return; } if (this.mouseState === "selecting") { return; } if ( !(event.target instanceof Element) || !this.pmView.dom.contains(event.target) ) { return; } const target = domCellAround(event.target); if ( target?.type === "cell" && this.mouseState === "down" && !this.state?.draggingState ) { // hide draghandles when selecting text as they could be in the way of the user this.mouseState = "selecting"; if (this.state?.show) { this.state.show = false; this.state.showAddOrRemoveRowsButton = false; this.state.showAddOrRemoveColumnsButton = false; this.emitUpdate(); } return; } if (!target || !this.editor.isEditable) { if (this.state?.show) { this.state.show = false; this.state.showAddOrRemoveRowsButton = false; this.state.showAddOrRemoveColumnsButton = false; this.emitUpdate(); } return; } if (!target.tbodyNode) { return; } const tableRect = target.tbodyNode.getBoundingClientRect(); const blockEl = getDraggableBlockFromElement(target.domNode, this.pmView); if (!blockEl) { return; } this.tableElement = blockEl.node; let tableBlock: | BlockFromConfigNoChildren<DefaultBlockSchema["table"], I, S> | undefined; const pmNodeInfo = this.editor.transact((tr) => getNodeById(blockEl.id, tr.doc), ); if (!pmNodeInfo) { throw new Error(`Block with ID ${blockEl.id} not found`); } const block = nodeToBlock( pmNodeInfo.node, this.editor.pmSchema, this.editor.schema.blockSchema, this.editor.schema.inlineContentSchema, this.editor.schema.styleSchema, ); if (checkBlockIsDefaultType("table", block, this.editor)) { this.tablePos = pmNodeInfo.posBeforeNode + 1; tableBlock = block; } if (!tableBlock) { return; } this.tableId = blockEl.id; const widgetContainer = target.domNode .closest(".tableWrapper") ?.querySelector(".table-widgets-container") as HTMLElement; if (target?.type === "wrapper") { // if we're just to the right or below the table, show the extend buttons // (this is a bit hacky. It would probably be cleaner to render the extend buttons in the Table NodeView instead) const belowTable = event.clientY >= tableRect.bottom - 1 && // -1 to account for fractions of pixels in "bottom" event.clientY < tableRect.bottom + 20; const toRightOfTable = event.clientX >= tableRect.right - 1 && event.clientX < tableRect.right + 20; // without this check, we'd also hide draghandles when hovering over them const hideHandles = event.clientX > tableRect.right || event.clientY > tableRect.bottom; this.state = { ...this.state!, show: true, showAddOrRemoveRowsButton: belowTable, showAddOrRemoveColumnsButton: toRightOfTable, referencePosTable: tableRect, block: tableBlock, widgetContainer, colIndex: hideHandles ? undefined : this.state?.colIndex, rowIndex: hideHandles ? undefined : this.state?.rowIndex, referencePosCell: hideHandles ? undefined : this.state?.referencePosCell, }; } else { const colIndex = getChildIndex(target.domNode); const rowIndex = getChildIndex(target.domNode.parentElement!); const cellRect = target.domNode.getBoundingClientRect(); if ( this.state !== undefined && this.state.show && this.tableId === blockEl.id && this.state.rowIndex === rowIndex && this.state.colIndex === colIndex ) { // no update needed return; } this.state = { show: true, showAddOrRemoveColumnsButton: colIndex === tableBlock.content.rows[0].cells.length - 1, showAddOrRemoveRowsButton: rowIndex === tableBlock.content.rows.length - 1, referencePosTable: tableRect, block: tableBlock, draggingState: undefined, referencePosCell: cellRect, colIndex: colIndex, rowIndex: rowIndex, widgetContainer, }; } this.emitUpdate(); return false; }; dragOverHandler = (event: DragEvent) => { if (this.state?.draggingState === undefined) { return; } event.preventDefault(); event.dataTransfer!.dropEffect = "move"; hideElements( ".prosemirror-dropcursor-block, .prosemirror-dropcursor-inline", this.pmView.root, ); // The mouse cursor coordinates, bounded to the table's bounding box. The // bounding box is shrunk by 1px on each side to ensure that the bounded // coordinates are always inside a table cell. const boundedMouseCoords = { left: Math.min( Math.max(event.clientX, this.state.referencePosTable.left + 1), this.state.referencePosTable.right - 1, ), top: Math.min( Math.max(event.clientY, this.state.referencePosTable.top + 1), this.state.referencePosTable.bottom - 1, ), }; // Gets the table cell element that the bounded mouse cursor coordinates lie // in. const tableCellElements = this.pmView.root .elementsFromPoint(boundedMouseCoords.left, boundedMouseCoords.top) .filter( (element) => element.tagName === "TD" || element.tagName === "TH", ); if (tableCellElements.length === 0) { return; } const tableCellElement = tableCellElements[0]; let emitStateUpdate = false; // Gets current row and column index. const rowIndex = getChildIndex(tableCellElement.parentElement!); const colIndex = getChildIndex(tableCellElement); // Checks if the drop cursor needs to be updated. This affects decorations // only so it doesn't trigger a state update. const oldIndex = this.state.draggingState.draggedCellOrientation === "row" ? this.state.rowIndex : this.state.colIndex; const newIndex = this.state.draggingState.draggedCellOrientation === "row" ? rowIndex : colIndex; const dispatchDecorationsTransaction = newIndex !== oldIndex; // Checks if either the hovered cell has changed and updates the row and // column index. Also updates the reference DOMRect. if (this.state.rowIndex !== rowIndex || this.state.colIndex !== colIndex) { this.state.rowIndex = rowIndex; this.state.colIndex = colIndex; this.state.referencePosCell = tableCellElement.getBoundingClientRect(); emitStateUpdate = true; } // Checks if the mouse cursor position along the axis that the user is // dragging on has changed and updates it. const mousePos = this.state.draggingState.draggedCellOrientation === "row" ? boundedMouseCoords.top : boundedMouseCoords.left; if (this.state.draggingState.mousePos !== mousePos) { this.state.draggingState.mousePos = mousePos; emitStateUpdate = true; } // Emits a state update if any of the fields have changed. if (emitStateUpdate) { this.emitUpdate(); } // Dispatches a dummy transaction to force a decorations update if // necessary. if (dispatchDecorationsTransaction) { this.editor.transact((tr) => tr.setMeta(tableHandlesPluginKey, true)); } }; dropHandler = (event: DragEvent) => { this.mouseState = "up"; if (this.state === undefined || this.state.draggingState === undefined) { return false; } if ( this.state.rowIndex === undefined || this.state.colIndex === undefined ) { throw new Error( "Attempted to drop table row or column, but no table block was hovered prior.", ); } event.preventDefault(); const { draggingState, colIndex, rowIndex } = this.state; const columnWidths = this.state.block.content.columnWidths; if (draggingState.draggedCellOrientation === "row") { if ( !canRowBeDraggedInto( this.state.block, draggingState.originalIndex, rowIndex, ) ) { // If the target row is invalid, don't move the row return false; } const newTable = moveRow( this.state.block, draggingState.originalIndex, rowIndex, ); this.editor.updateBlock(this.state.block, { type: "table", content: { ...this.state.block.content, rows: newTable as any, }, }); } else { if ( !canColumnBeDraggedInto( this.state.block, draggingState.originalIndex, colIndex, ) ) { // If the target column is invalid, don't move the column return false; } const newTable = moveColumn( this.state.block, draggingState.originalIndex, colIndex, ); const [columnWidth] = columnWidths.splice(draggingState.originalIndex, 1); columnWidths.splice(colIndex, 0, columnWidth); this.editor.updateBlock(this.state.block, { type: "table", content: { ...this.state.block.content, columnWidths, rows: newTable as any, }, }); } // Have to reset text cursor position to the block as `updateBlock` moves // the existing selection out of the block. this.editor.setTextCursorPosition(this.state.block.id); return true; }; // Updates drag handles when the table is modified or removed. update() { if (!this.state || !this.state.show) { return; } // Hide handles if the table block has been removed. this.state.block = this.editor.getBlock(this.state.block.id)!; if ( !this.state.block || this.state.block.type !== "table" || // when collaborating, the table element might be replaced and out of date // because yjs replaces the element when for example you change the color via the side menu !this.tableElement?.isConnected ) { this.state.show = false; this.state.showAddOrRemoveRowsButton = false; this.state.showAddOrRemoveColumnsButton = false; this.emitUpdate(); return; } const { height: rowCount, width: colCount } = getDimensionsOfTable( this.state.block, ); if ( this.state.rowIndex !== undefined && this.state.colIndex !== undefined ) { // If rows or columns are deleted in the update, the hovered indices for // those may now be out of bounds. If this is the case, they are moved to // the new last row or column. if (this.state.rowIndex >= rowCount) { this.state.rowIndex = rowCount - 1; } if (this.state.colIndex >= colCount) { this.state.colIndex = colCount - 1; } } // Update bounding boxes. const tableBody = this.tableElement!.querySelector("tbody"); if (!tableBody) { throw new Error( "Table block does not contain a 'tbody' HTML element. This should never happen.", ); } if ( this.state.rowIndex !== undefined && this.state.colIndex !== undefined ) { const row = tableBody.children[this.state.rowIndex]; const cell = row.children[this.state.colIndex]; if (cell) { this.state.referencePosCell = cell.getBoundingClientRect(); } else { this.state.rowIndex = undefined; this.state.colIndex = undefined; } } this.state.referencePosTable = tableBody.getBoundingClientRect(); this.emitUpdate(); } destroy() { this.pmView.dom.removeEventListener("mousemove", this.mouseMoveHandler); window.removeEventListener("mouseup", this.mouseUpHandler); this.pmView.dom.removeEventListener("mousedown", this.viewMousedownHandler); this.pmView.root.removeEventListener( "dragover", this.dragOverHandler as EventListener, ); this.pmView.root.removeEventListener( "drop", this.dropHandler as unknown as EventListener, ); } } export const tableHandlesPluginKey = new PluginKey("TableHandlesPlugin"); export class TableHandlesProsemirrorPlugin< I extends InlineContentSchema, S extends StyleSchema, > extends BlockNoteExtension { public static key() { return "tableHandles"; } private view: TableHandlesView<I, S> | undefined; constructor( private readonly editor: BlockNoteEditor< BlockSchemaWithBlock<"table", DefaultBlockSchema["table"]>, I, S >, ) { super(); this.addProsemirrorPlugin( new Plugin({ key: tableHandlesPluginKey, view: (editorView) => { this.view = new TableHandlesView(editor, editorView, (state) => { this.emit("update", state); }); return this.view; }, // We use decorations to render the drop cursor when dragging a table row // or column. The decorations are updated in the `dragOverHandler` method. props: { decorations: (state) => { if ( this.view === undefined || this.view.state === undefined || this.view.state.draggingState === undefined || this.view.tablePos === undefined ) { return; } const newIndex = this.view.state.draggingState.draggedCellOrientation === "row" ? this.view.state.rowIndex : this.view.state.colIndex; if (newIndex === undefined) { return; } const decorations: Decoration[] = []; const { block, draggingState } = this.view.state; const { originalIndex, draggedCellOrientation } = draggingState; // Return empty decorations if: // - Dragging to same position // - No block exists // - Row drag not allowed // - Column drag not allowed if ( newIndex === originalIndex || !block || (draggedCellOrientation === "row" && !canRowBeDraggedInto(block, originalIndex, newIndex)) || (draggedCellOrientation === "col" && !canColumnBeDraggedInto(block, originalIndex, newIndex)) ) { return DecorationSet.create(state.doc, decorations); } // Gets the table to show the drop cursor in. const tableResolvedPos = state.doc.resolve(this.view.tablePos + 1); if ( this.view.state.draggingState.draggedCellOrientation === "row" ) { const cellsInRow = getCellsAtRowHandle( this.view.state.block, newIndex, ); cellsInRow.forEach(({ row, col }) => { // Gets each row in the table. const rowResolvedPos = state.doc.resolve( tableResolvedPos.posAtIndex(row) + 1, ); // Gets the cell within the row. const cellResolvedPos = state.doc.resolve( rowResolvedPos.posAtIndex(col) + 1, ); const cellNode = cellResolvedPos.node(); // Creates a decoration at the start or end of each cell, // depending on whether the new index is before or after the // original index. const decorationPos = cellResolvedPos.pos + (newIndex > originalIndex ? cellNode.nodeSize - 2 : 0); decorations.push( // The widget is a small bar which spans the width of the cell. Decoration.widget(decorationPos, () => { const widget = document.createElement("div"); widget.className = "bn-table-drop-cursor"; widget.style.left = "0"; widget.style.right = "0"; // This is only necessary because the drop indicator's height // is an even number of pixels, whereas the border between // table cells is an odd number of pixels. So this makes the // positioning slightly more consistent regardless of where // the row is being dropped. if (newIndex > originalIndex) { widget.style.bottom = "-2px"; } else { widget.style.top = "-3px"; } widget.style.height = "4px"; return widget; }), ); }); } else { const cellsInColumn = getCellsAtColumnHandle( this.view.state.block, newIndex, ); cellsInColumn.forEach(({ row, col }) => { // Gets each row in the table. const rowResolvedPos = state.doc.resolve( tableResolvedPos.posAtIndex(row) + 1, ); // Gets the cell within the row. const cellResolvedPos = state.doc.resolve( rowResolvedPos.posAtIndex(col) + 1, ); const cellNode = cellResolvedPos.node(); // Creates a decoration at the start or end of each cell, // depending on whether the new index is before or after the // original index. const decorationPos = cellResolvedPos.pos + (newIndex > originalIndex ? cellNode.nodeSize - 2 : 0); decorations.push( // The widget is a small bar which spans the height of the cell. Decoration.widget(decorationPos, () => { const widget = document.createElement("div"); widget.className = "bn-table-drop-cursor"; widget.style.top = "0"; widget.style.bottom = "0"; // This is only necessary because the drop indicator's width // is an even number of pixels, whereas the border between // table cells is an odd number of pixels. So this makes the // positioning slightly more consistent regardless of where // the column is being dropped. if (newIndex > originalIndex) { widget.style.right = "-2px"; } else { widget.style.left = "-3px"; } widget.style.width = "4px"; return widget; }), ); }); } return DecorationSet.create(state.doc, decorations); }, }, }), ); } public onUpdate(callback: (state: TableHandlesState<I, S>) => void) { return this.on("update", callback); } /** * Callback that should be set on the `dragStart` event for whichever element * is used as the column drag handle. */ colDragStart = (event: { dataTransfer: DataTransfer | null; clientX: number; }) => { if ( this.view!.state === undefined || this.view!.state.colIndex === undefined ) { throw new Error( "Attempted to drag table column, but no table block was hovered prior.", ); } this.view!.state.draggingState = { draggedCellOrientation: "col", originalIndex: this.view!.state.colIndex, mousePos: event.clientX, }; this.view!.emitUpdate(); this.editor.transact((tr) => tr.setMeta(tableHandlesPluginKey, { draggedCellOrientation: this.view!.state!.draggingState!.draggedCellOrientation, originalIndex: this.view!.state!.colIndex, newIndex: this.view!.state!.colIndex, tablePos: this.view!.tablePos, }), ); if (!this.editor.prosemirrorView) { throw new Error("Editor view not initialized."); } setHiddenDragImage(this.editor.prosemirrorView.root); event.dataTransfer!.setDragImage(dragImageElement!, 0, 0); event.dataTransfer!.effectAllowed = "move"; }; /** * Callback that should be set on the `dragStart` event for whichever element * is used as the row drag handle. */ rowDragStart = (event: { dataTransfer: DataTransfer | null; clientY: number; }) => { if ( this.view!.state === undefined || this.view!.state.rowIndex === undefined ) { throw new Error( "Attempted to drag table row, but no table block was hovered prior.", ); } this.view!.state.draggingState = { draggedCellOrientation: "row", originalIndex: this.view!.state.rowIndex, mousePos: event.clientY, }; this.view!.emitUpdate(); this.editor.transact((tr) => tr.setMeta(tableHandlesPluginKey, { draggedCellOrientation: this.view!.state!.draggingState!.draggedCellOrientation, originalIndex: this.view!.state!.rowIndex, newIndex: this.view!.state!.rowIndex, tablePos: this.view!.tablePos, }), ); if (!this.editor.prosemirrorView) { throw new Error("Editor view not initialized."); } setHiddenDragImage(this.editor.prosemirrorView.root); event.dataTransfer!.setDragImage(dragImageElement!, 0, 0); event.dataTransfer!.effectAllowed = "copyMove"; }; /** * Callback that should be set on the `dragEnd` event for both the element * used as the row drag handle, and the one used as the column drag handle. */ dragEnd = () => { if (this.view!.state === undefined) { throw new Error( "Attempted to drag table row, but no table block was hovered prior.", ); } this.view!.state.draggingState = undefined; this.view!.emitUpdate(); this.editor.transact((tr) => tr.setMeta(tableHandlesPluginKey, null)); if (!this.editor.prosemirrorView) { throw new Error("Editor view not initialized."); } unsetHiddenDragImage(this.editor.prosemirrorView.root); }; /** * Freezes the drag handles. When frozen, they will stay attached to the same * cell regardless of which cell is hovered by the mouse cursor. */ freezeHandles = () => { this.view!.menuFrozen = true; }; /** * Unfreezes the drag handles. When frozen, they will stay attached to the * same cell regardless of which cell is hovered by the mouse cursor. */ unfreezeHandles = () => { this.view!.menuFrozen = false; }; getCellsAtRowHandle = ( block: BlockFromConfigNoChildren<DefaultBlockSchema["table"], any, any>, relativeRowIndex: RelativeCellIndices["row"], ) => { return getCellsAtRowHandle(block, relativeRowIndex); }; /** * Get all the cells in a column of the table block. */ getCellsAtColumnHandle = ( block: BlockFromConfigNoChildren<DefaultBlockSchema["table"], any, any>, relativeColumnIndex: RelativeCellIndices["col"], ) => { return getCellsAtColumnHandle(block, relativeColumnIndex); }; /** * Sets the selection to the given cell or a range of cells. * @returns The new state after the selection has been set. */ private setCellSelection = ( state: EditorState, relativeStartCell: RelativeCellIndices, relativeEndCell: RelativeCellIndices = relativeStartCell, ) => { const view = this.view; if (!view) { throw new Error("Table handles view not initialized"); } const tableResolvedPos = state.doc.resolve(view.tablePos! + 1); const startRowResolvedPos = state.doc.resolve( tableResolvedPos.posAtIndex(relativeStartCell.row) + 1, ); const startCellResolvedPos = state.doc.resolve( // No need for +1, since CellSelection expects the position before the cell startRowResolvedPos.posAtIndex(relativeStartCell.col), ); const endRowResolvedPos = state.doc.resolve( tableResolvedPos.posAtIndex(relativeEndCell.row) + 1, ); const endCellResolvedPos = state.doc.resolve( // No need for +1, since CellSelection expects the position before the cell endRowResolvedPos.posAtIndex(relativeEndCell.col), ); // Begin a new transaction to set the selection const tr = state.tr; // Set the selection to the given cell or a range of cells tr.setSelection( new CellSelection(startCellResolvedPos, endCellResolvedPos), ); // Quickly apply the transaction to get the new state to update the selection before splitting the cell return state.apply(tr); }; /** * Adds a row or column to the table using prosemirror-table commands */ addRowOrColumn = ( index: RelativeCellIndices["row"] | RelativeCellIndices["col"], direction: | { orientation: "row"; side: "above" | "below" } | { orientation: "column"; side: "left" | "right" }, ) => { this.editor.exec((beforeState, dispatch) => { const state = this.setCellSelection( beforeState, direction.orientation === "row" ? { row: index, col: 0 } : { row: 0, col: index }, ); if (direction.orientation === "row") { if (direction.side === "above") { return addRowBefore(state, dispatch); } else { return addRowAfter(state, dispatch); } } else { if (direction.side === "left") { return addColumnBefore(state, dispatch); } else { return addColumnAfter(state, dispatch); } } }); }; /** * Removes a row or column from the table using prosemirror-table commands */ removeRowOrColumn = ( index: RelativeCellIndices["row"] | RelativeCellIndices["col"], direction: "row" | "column", ) => { if (direction === "row") { return this.editor.exec((beforeState, dispatch) => { const state = this.setCellSelection(beforeState, { row: index, col: 0, }); return deleteRow(state, dispatch); }); } else { return this.editor.exec((beforeState, dispatch) => { const state = this.setCellSelection(beforeState, { row: 0, col: index, }); return deleteColumn(state, dispatch); }); } }; /** * Merges the cells in the table block. */ mergeCells = (cellsToMerge?: { relativeStartCell: RelativeCellIndices; relativeEndCell: RelativeCellIndices; }) => { return this.editor.exec((beforeState, dispatch) => { const state = cellsToMerge ? this.setCellSelection( beforeState, cellsToMerge.relativeStartCell, cellsToMerge.relativeEndCell, ) : beforeState; return mergeCells(state, dispatch); }); }; /** * Splits the cell in the table block. * If no cell is provided, the current cell selected will be split. */ splitCell = (relativeCellToSplit?: RelativeCellIndices) => { return this.editor.exec((beforeState, dispatch) => { const state = relativeCellToSplit ? this.setCellSelection(beforeState, relativeCellToSplit) : beforeState; return splitCell(state, dispatch); }); }; /** * Gets the start and end cells of the current cell selection. * @returns The start and end cells of the current cell selection. */ getCellSelection = (): | undefined | { from: RelativeCellIndices; to: RelativeCellIndices; /** * All of the cells that are within the selected range. */ cells: RelativeCellIndices[]; } => { // Based on the current selection, find the table cells that are within the selected range return this.editor.transact((tr) => { const selection = tr.selection; let $fromCell = selection.$from; let $toCell = selection.$to; if (isTableCellSelection(selection)) { // When the selection is a table cell selection, we can find the // from and to cells by iterating over the ranges in the selection const { ranges } = selection; ranges.forEach((range) => { $fromCell = range.$from.min($fromCell ?? range.$from); $toCell = range.$to.max($toCell ?? range.$to); }); } else { // When the selection is a normal text selection // Assumes we are within a tableParagraph // And find the from and to cells by resolving the positions $fromCell = tr.doc.resolve( selection.$from.pos - selection.$from.parentOffset - 1, ); $toCell = tr.doc.resolve( selection.$to.pos - selection.$to.parentOffset - 1, ); // Opt-out when the selection is not pointing into cells if ($fromCell.pos === 0 || $toCell.pos === 0) { return undefined; } } // Find the row and table that the from and to cells are in const $fromRow = tr.doc.resolve( $fromCell.pos - $fromCell.parentOffset - 1, ); const $toRow = tr.doc.resolve($toCell.pos - $toCell.parentOffset - 1); // Find the table const $table = tr.doc.resolve($fromRow.pos - $fromRow.parentOffset - 1); // Find the column and row indices of the from and to cells const fromColIndex = $fromCell.index($fromRow.depth); const fromRowIndex = $fromRow.index($table.depth); const toColIndex = $toCell.index($toRow.depth); const toRowIndex = $toRow.index($table.depth); const cells: RelativeCellIndices[] = []; for (let row = fromRowIndex; row <= toRowIndex; row++) { for (let col = fromColIndex; col <= toColIndex; col++) { cells.push({ row, col }); } } return { from: { row: fromRowIndex, col: fromColIndex, }, to: { row: toRowIndex, col: toColIndex, }, cells, }; }); }; /** * Gets the direction of the merge based on the current cell selection. * * Returns undefined when there is no cell selection, or the selection is not within a table. */ getMergeDirection = ( block: | BlockFromConfigNoChildren<DefaultBlockSchema["table"], any, any> | undefined, ) => { return this.editor.transact((tr) => { const isSelectingTableCells = isTableCellSelection(tr.selection) ? tr.selection : undefined; if ( !isSelectingTableCells || !block || // Only offer the merge button if there is more than one cell selected. isSelectingTableCells.ranges.length <= 1 ) { return undefined; } const cellSelection = this.getCellSelection(); if (!cellSelection) { return undefined; } if (areInSameColumn(cellSelection.from, cellSelection.to, block)) { return "vertical"; } return "horizontal"; }); }; cropEmptyRowsOrColumns = ( block: BlockFromConfigNoChildren<DefaultBlockSchema["table"], any, any>, removeEmpty: "columns" | "rows", ) => { return cropEmptyRowsOrColumns(block, removeEmpty); }; addRowsOrColumns = ( block: BlockFromConfigNoChildren<DefaultBlockSchema["table"], any, any>, addType: "columns" | "rows", numToAdd: number, ) => { return addRowsOrColumns(block, addType, numToAdd); }; }