@blocknote/core
Version:
A "Notion-style" block-based extensible text editor built on top of Prosemirror and Tiptap.
410 lines (375 loc) • 13.1 kB
text/typescript
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 {
getBlockInfoAtNearest,
getNodeId,
} from "../../../getBlockInfoFromPos.js";
import { getNodeById } from "../../../nodeUtil.js";
import { insertBlocks } from "../insertBlocks/insertBlocks.js";
import { removeAndInsertBlocks } from "../replaceBlocks/replaceBlocks.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<any, any, any>,
): BlockSelectionData {
return editor.transact((tr) => {
const anchorBlockPosInfo = getBlockInfoAtNearest(tr, tr.selection.anchor);
const anchorBlockId = getNodeId(anchorBlockPosInfo.bnBlock.node, tr.doc);
if (tr.selection instanceof CellSelection) {
return {
type: "cell" as const,
anchorBlockId,
anchorCellOffset:
tr.selection.$anchorCell.pos - anchorBlockPosInfo.bnBlock.beforePos,
headCellOffset:
tr.selection.$headCell.pos - anchorBlockPosInfo.bnBlock.beforePos,
};
} else if (tr.selection instanceof NodeSelection) {
return {
type: "node" as const,
anchorBlockId,
};
} else {
const headBlockPosInfo = getBlockInfoAtNearest(tr, tr.selection.head);
return {
type: "text" as const,
anchorBlockId,
headBlockId: getNodeId(headBlockPosInfo.bnBlock.node, tr.doc),
anchorOffset:
tr.selection.anchor - anchorBlockPosInfo.bnBlock.beforePos,
headOffset: tr.selection.head - headBlockPosInfo.bnBlock.beforePos,
};
}
});
}
/**
* `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<any, any, any>[],
): Block<any, any, any>[] {
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<any, any, any>,
blocks: Block<any, any, any>[],
referenceBlock: BlockIdentifier,
placement: "before" | "after",
) {
editor.transact((tr) => {
// Don't fix columns/columnLists in the removal step. Since a move is a
// rearrangement rather than a deletion, columns that it empties out are
// deliberately left as-is instead of being collapsed - this keeps moves
// free of side effects (and reversible by moving back), and matches
// dragging a block out of a column, which doesn't collapse it either.
// Fixing them mid-move also broke the following case:
// <column>
// <paragraph></paragraph>
// <paragraph>Paragraph</paragraph>
// </column>
// When the non-empty block is moved up, the column is seen as empty and
// collapsed in the removal step, so the following insertion fails.
removeAndInsertBlocks(tr, blocks, [], { fixColumns: false });
insertBlocks<any, any, any>(
tr,
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<any, any, any>,
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<any, any, any>): 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<any, any, any>,
prevBlock?: Block<any, any, any>,
parentBlock?: Block<any, any, any>,
):
| { referenceBlock: BlockIdentifier; placement: "before" | "after" }
| undefined {
let referenceBlock: Block<any, any, any> | 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<any, any, any>,
nextBlock?: Block<any, any, any>,
parentBlock?: Block<any, any, any>,
):
| { referenceBlock: BlockIdentifier; placement: "before" | "after" }
| undefined {
let referenceBlock: Block<any, any, any> | 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<any, any, any>,
blockIdentifier?: BlockIdentifier,
) {
editor.transact(() => {
let sourceBlock: Block<any, any, any> | 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<any, any, any>,
blockIdentifier?: BlockIdentifier,
) {
editor.transact(() => {
let sourceBlock: Block<any, any, any> | 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,
);
}
});
}