@blocknote/core
Version:
A "Notion-style" block-based extensible text editor built on top of Prosemirror and Tiptap.
339 lines (305 loc) • 10.9 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 { 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<any, any, any>,
): 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 any `columnList` blocks with the children of their columns. This is
* done here instead of in `getSelection` as we still need to remove the entire
* `columnList` node but only insert the `blockContainer` nodes inside it.
* @param blocks The blocks to flatten.
*/
function flattenColumns(
blocks: Block<any, any, any>[],
): Block<any, any, any>[] {
return blocks
.map((block) => {
if (block.type === "columnList") {
return block.children
.map((column) => flattenColumns(column.children))
.flat();
}
return {
...block,
children: flattenColumns(block.children),
};
})
.flat();
}
/**
* 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);
editor.removeBlocks(blocks);
editor.insertBlocks(flattenColumns(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>) {
editor.transact(() => {
const selection = editor.getSelection();
const block = selection?.blocks[0] || editor.getTextCursorPosition().block;
const moveUpPlacement = getMoveUpPlacement(
editor,
editor.getPrevBlock(block),
editor.getParentBlock(block),
);
if (!moveUpPlacement) {
return;
}
moveSelectedBlocksAndSelection(
editor,
moveUpPlacement.referenceBlock,
moveUpPlacement.placement,
);
});
}
export function moveBlocksDown(editor: BlockNoteEditor<any, any, any>) {
editor.transact(() => {
const selection = editor.getSelection();
const block =
selection?.blocks[selection?.blocks.length - 1] ||
editor.getTextCursorPosition().block;
const moveDownPlacement = getMoveDownPlacement(
editor,
editor.getNextBlock(block),
editor.getParentBlock(block),
);
if (!moveDownPlacement) {
return;
}
moveSelectedBlocksAndSelection(
editor,
moveDownPlacement.referenceBlock,
moveDownPlacement.placement,
);
});
}