import { NodeSelection, Selection, TextSelection, Transaction, } from "prosemirror-state"; import { CellSelection } from "prosemirror-tables"; import { Block } from "../../../../blocks/defaultBlocks.js"; import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor"; import { BlockIdentifier } from "../../../../schema/index.js"; import { getNearestBlockPos } from "../../../getBlockInfoFromPos.js"; import { getNodeById } from "../../../nodeUtil.js"; type BlockSelectionData = ( | { type: "text"; headBlockId: string; anchorOffset: number; headOffset: number; } | { type: "node"; } | { type: "cell"; anchorCellOffset: number; headCellOffset: number; } ) & { anchorBlockId: string; }; /** * `getBlockSelectionData` and `updateBlockSelectionFromData` are used to save * and restore the selection within a block, when the block is moved. This is * done by first saving the offsets of the anchor and head from the before * positions of their surrounding blocks, as well as the IDs of those blocks. We * can then recreate the selection by finding the blocks with those IDs, getting * their before positions, and adding the offsets to those positions. * @param editor The BlockNote editor instance to get the selection data from. */ function getBlockSelectionData( editor: BlockNoteEditor, ): BlockSelectionData { return editor.transact((tr) => { const anchorBlockPosInfo = getNearestBlockPos(tr.doc, tr.selection.anchor); if (tr.selection instanceof CellSelection) { return { type: "cell" as const, anchorBlockId: anchorBlockPosInfo.node.attrs.id, anchorCellOffset: tr.selection.$anchorCell.pos - anchorBlockPosInfo.posBeforeNode, headCellOffset: tr.selection.$headCell.pos - anchorBlockPosInfo.posBeforeNode, }; } else if (tr.selection instanceof NodeSelection) { return { type: "node" as const, anchorBlockId: anchorBlockPosInfo.node.attrs.id, }; } else { const headBlockPosInfo = getNearestBlockPos(tr.doc, tr.selection.head); return { type: "text" as const, anchorBlockId: anchorBlockPosInfo.node.attrs.id, headBlockId: headBlockPosInfo.node.attrs.id, anchorOffset: tr.selection.anchor - anchorBlockPosInfo.posBeforeNode, headOffset: tr.selection.head - headBlockPosInfo.posBeforeNode, }; } }); } /** * `getBlockSelectionData` and `updateBlockSelectionFromData` are used to save * and restore the selection within a block, when the block is moved. This is * done by first saving the offsets of the anchor and head from the before * positions of their surrounding blocks, as well as the IDs of those blocks. We * can then recreate the selection by finding the blocks with those IDs, getting * their before positions, and adding the offsets to those positions. * @param tr The transaction to update the selection in. * @param data The selection data to update the selection with (generated by * `getBlockSelectionData`). */ function updateBlockSelectionFromData( tr: Transaction, data: BlockSelectionData, ) { const anchorBlockPos = getNodeById(data.anchorBlockId, tr.doc)?.posBeforeNode; if (anchorBlockPos === undefined) { throw new Error( `Could not find block with ID ${data.anchorBlockId} to update selection`, ); } let selection: Selection; if (data.type === "cell") { selection = CellSelection.create( tr.doc, anchorBlockPos + data.anchorCellOffset, anchorBlockPos + data.headCellOffset, ); } else if (data.type === "node") { selection = NodeSelection.create(tr.doc, anchorBlockPos + 1); } else { const headBlockPos = getNodeById(data.headBlockId, tr.doc)?.posBeforeNode; if (headBlockPos === undefined) { throw new Error( `Could not find block with ID ${data.headBlockId} to update selection`, ); } selection = TextSelection.create( tr.doc, anchorBlockPos + data.anchorOffset, headBlockPos + data.headOffset, ); } tr.setSelection(selection); } // Replaces top-level `column` blocks with their children, as a `column` is not // a valid block outside a `columnList`. Other blocks are returned as-is. function flattenColumns( blocks: Block[], ): Block[] { return blocks.flatMap((block) => block.type === "column" ? block.children : [block], ); } /** * Removes the given blocks from the editor, then inserts them before/after a * reference block. * @param editor The BlockNote editor instance to move the blocks in. * @param blocks The blocks to move. * @param referenceBlock The reference block to insert the blocks before/after. * @param placement Whether to insert the blocks before or after the reference * block. */ export function moveBlocks( editor: BlockNoteEditor, blocks: Block[], referenceBlock: BlockIdentifier, placement: "before" | "after", ) { editor.transact(() => { // A `columnList` reference can be dissolved by `fixColumnList` when its // `column`s are removed, leaving its ID invalid for re-insertion. Anchor // to an adjacent block instead, which is unaffected by the removal. const refBlock = editor.getBlock(referenceBlock); if (refBlock?.type === "columnList") { const adjacent = placement === "after" ? editor.getNextBlock(refBlock) : editor.getPrevBlock(refBlock); if (adjacent) { referenceBlock = adjacent; placement = placement === "after" ? "before" : "after"; } } editor.removeBlocks(blocks); editor.insertBlocks(flattenColumns(blocks), referenceBlock, placement); }); } /** * Removes the selected blocks from the editor, then inserts them before/after a * reference block. Also updates the selection to match the original selection * using `getBlockSelectionData` and `updateBlockSelectionFromData`. * @param editor The BlockNote editor instance to move the blocks in. * @param referenceBlock The reference block to insert the selected blocks * before/after. * @param placement Whether to insert the selected blocks before or after the * reference block. */ export function moveSelectedBlocksAndSelection( editor: BlockNoteEditor, referenceBlock: BlockIdentifier, placement: "before" | "after", ) { // We want this to be a single step in the undo history editor.transact((tr) => { const blocks = editor.getSelection()?.blocks || [ editor.getTextCursorPosition().block, ]; const selectionData = getBlockSelectionData(editor); moveBlocks(editor, blocks, referenceBlock, placement); updateBlockSelectionFromData(tr, selectionData); }); } // Checks if a block is in a valid place after being moved. This check is // primitive at the moment and only returns false if the block's parent is a // `columnList` block. This is because regular blocks cannot be direct children // of `columnList` blocks. function checkPlacementIsValid(parentBlock?: Block): boolean { return !parentBlock || parentBlock.type !== "columnList"; } // Gets the placement for moving a block up. This has 3 cases: // 1. If the block has a previous sibling without children, the placement is // before it. // 2. If the block has a previous sibling with children, the placement is after // the last child. // 3. If the block has no previous sibling, but is nested, the placement is // before its parent. // If the placement is invalid, the function is called recursively until a valid // placement is found. Returns undefined if no valid placement is found, meaning // the block is already at the top of the document. function getMoveUpPlacement( editor: BlockNoteEditor, prevBlock?: Block, parentBlock?: Block, ): | { referenceBlock: BlockIdentifier; placement: "before" | "after" } | undefined { let referenceBlock: Block | undefined; let placement: "before" | "after" | undefined; if (!prevBlock) { if (parentBlock) { referenceBlock = parentBlock; placement = "before"; } } else if (prevBlock.children.length > 0) { referenceBlock = prevBlock.children[prevBlock.children.length - 1]; placement = "after"; } else { referenceBlock = prevBlock; placement = "before"; } // Case when the block is already at the top of the document. if (!referenceBlock || !placement) { return undefined; } const referenceBlockParent = editor.getParentBlock(referenceBlock); if (!checkPlacementIsValid(referenceBlockParent)) { return getMoveUpPlacement( editor, placement === "after" ? referenceBlock : editor.getPrevBlock(referenceBlock), referenceBlockParent, ); } return { referenceBlock, placement }; } // Gets the placement for moving a block down. This has 3 cases: // 1. If the block has a next sibling without children, the placement is after // it. // 2. If the block has a next sibling with children, the placement is before the // first child. // 3. If the block has no next sibling, but is nested, the placement is // after its parent. // If the placement is invalid, the function is called recursively until a valid // placement is found. Returns undefined if no valid placement is found, meaning // the block is already at the bottom of the document. function getMoveDownPlacement( editor: BlockNoteEditor, nextBlock?: Block, parentBlock?: Block, ): | { referenceBlock: BlockIdentifier; placement: "before" | "after" } | undefined { let referenceBlock: Block | undefined; let placement: "before" | "after" | undefined; if (!nextBlock) { if (parentBlock) { referenceBlock = parentBlock; placement = "after"; } } else if (nextBlock.children.length > 0) { referenceBlock = nextBlock.children[0]; placement = "before"; } else { referenceBlock = nextBlock; placement = "after"; } // Case when the block is already at the bottom of the document. if (!referenceBlock || !placement) { return undefined; } const referenceBlockParent = editor.getParentBlock(referenceBlock); if (!checkPlacementIsValid(referenceBlockParent)) { return getMoveDownPlacement( editor, placement === "before" ? referenceBlock : editor.getNextBlock(referenceBlock), referenceBlockParent, ); } return { referenceBlock, placement }; } export function moveBlocksUp( editor: BlockNoteEditor, blockIdentifier?: BlockIdentifier, ) { editor.transact(() => { let sourceBlock: Block | undefined; if (blockIdentifier) { sourceBlock = editor.getBlock(blockIdentifier); if (!sourceBlock) { return; } } else { const selection = editor.getSelection(); sourceBlock = selection?.blocks[0] || editor.getTextCursorPosition().block; } const moveUpPlacement = getMoveUpPlacement( editor, editor.getPrevBlock(sourceBlock), editor.getParentBlock(sourceBlock), ); if (!moveUpPlacement) { return; } if (blockIdentifier) { moveBlocks( editor, [sourceBlock], moveUpPlacement.referenceBlock, moveUpPlacement.placement, ); } else { moveSelectedBlocksAndSelection( editor, moveUpPlacement.referenceBlock, moveUpPlacement.placement, ); } }); } export function moveBlocksDown( editor: BlockNoteEditor, blockIdentifier?: BlockIdentifier, ) { editor.transact(() => { let sourceBlock: Block | undefined; if (blockIdentifier) { sourceBlock = editor.getBlock(blockIdentifier); if (!sourceBlock) { return; } } else { const selection = editor.getSelection(); sourceBlock = selection?.blocks[selection?.blocks.length - 1] || editor.getTextCursorPosition().block; } const moveDownPlacement = getMoveDownPlacement( editor, editor.getNextBlock(sourceBlock), editor.getParentBlock(sourceBlock), ); if (!moveDownPlacement) { return; } if (blockIdentifier) { moveBlocks( editor, [sourceBlock], moveDownPlacement.referenceBlock, moveDownPlacement.placement, ); } else { moveSelectedBlocksAndSelection( editor, moveDownPlacement.referenceBlock, moveDownPlacement.placement, ); } }); }