@blocknote/core
Version:
A "Notion-style" block-based extensible text editor built on top of Prosemirror and Tiptap.
293 lines (270 loc) • 7.55 kB
text/typescript
import { combineTransactionSteps } from "@tiptap/core";
import type { Node } from "prosemirror-model";
import type { Transaction } from "prosemirror-state";
import {
Block,
DefaultBlockSchema,
DefaultInlineContentSchema,
DefaultStyleSchema,
} from "../blocks/defaultBlocks.js";
import type { BlockSchema } from "../schema/index.js";
import type { InlineContentSchema } from "../schema/inlineContent/types.js";
import type { StyleSchema } from "../schema/styles/types.js";
import { nodeToBlock } from "./nodeConversions/nodeToBlock.js";
import { getPmSchema } from "./pmUtil.js";
/**
* Gets the parent block of a node, if it has one.
*/
function getParentBlockId(doc: Node, pos: number): string | undefined {
if (pos === 0) {
return undefined;
}
const resolvedPos = doc.resolve(pos);
for (let i = resolvedPos.depth; i > 0; i--) {
const parent = resolvedPos.node(i);
if (isNodeBlock(parent)) {
return parent.attrs.id;
}
}
return undefined;
}
/**
* Get a TipTap node by id
*/
export function getNodeById(
id: string,
doc: Node,
): { node: Node; posBeforeNode: number } | undefined {
let targetNode: Node | undefined = undefined;
let posBeforeNode: number | undefined = undefined;
doc.firstChild!.descendants((node, pos) => {
// Skips traversing nodes after node with target ID has been found.
if (targetNode) {
return false;
}
// Keeps traversing nodes if block with target ID has not been found.
if (!isNodeBlock(node) || node.attrs.id !== id) {
return true;
}
targetNode = node;
posBeforeNode = pos + 1;
return false;
});
if (targetNode === undefined || posBeforeNode === undefined) {
return undefined;
}
return {
node: targetNode,
posBeforeNode: posBeforeNode,
};
}
export function isNodeBlock(node: Node): boolean {
return node.type.isInGroup("bnBlock");
}
/**
* This attributes the changes to a specific source.
*/
export type BlockChangeSource =
| { type: "local" }
| { type: "paste" }
| { type: "drop" }
| { type: "undo" | "redo" | "undo-redo" }
| { type: "yjs-remote" };
export type BlocksChanged<
BSchema extends BlockSchema = DefaultBlockSchema,
ISchema extends InlineContentSchema = DefaultInlineContentSchema,
SSchema extends StyleSchema = DefaultStyleSchema,
> = Array<
{
/**
* The affected block.
*/
block: Block<BSchema, ISchema, SSchema>;
/**
* The source of the change.
*/
source: BlockChangeSource;
} & (
| {
type: "insert" | "delete";
/**
* Insert and delete changes don't have a previous block.
*/
prevBlock: undefined;
}
| {
type: "update";
/**
* The previous block.
*/
prevBlock: Block<BSchema, ISchema, SSchema>;
}
| {
type: "move";
/**
* The affected block.
*/
block: Block<BSchema, ISchema, SSchema>;
/**
* The block before the move.
*/
prevBlock: Block<BSchema, ISchema, SSchema>;
/**
* The previous parent block (if it existed).
*/
prevParent?: Block<BSchema, ISchema, SSchema>;
/**
* The current parent block (if it exists).
*/
currentParent?: Block<BSchema, ISchema, SSchema>;
}
)
>;
/**
* Compares two blocks, ignoring their children.
* Returns true if the blocks are different (excluding children).
*/
function areBlocksDifferentExcludingChildren<
BSchema extends BlockSchema,
ISchema extends InlineContentSchema,
SSchema extends StyleSchema,
>(
block1: Block<BSchema, ISchema, SSchema>,
block2: Block<BSchema, ISchema, SSchema>,
): boolean {
return (
block1.id !== block2.id ||
block1.type !== block2.type ||
JSON.stringify(block1.props) !== JSON.stringify(block2.props) ||
JSON.stringify(block1.content) !== JSON.stringify(block2.content)
);
}
function determineChangeSource(transaction: Transaction): BlockChangeSource {
if (transaction.getMeta("paste")) {
return { type: "paste" };
}
if (transaction.getMeta("uiEvent") === "drop") {
return { type: "drop" };
}
if (transaction.getMeta("history$")) {
return {
type: transaction.getMeta("history$").redo ? "redo" : "undo",
};
}
if (transaction.getMeta("y-sync$")) {
if (transaction.getMeta("y-sync$").isUndoRedoOperation) {
return { type: "undo-redo" };
}
return { type: "yjs-remote" };
}
return { type: "local" };
}
function collectAllBlocks<
BSchema extends BlockSchema,
ISchema extends InlineContentSchema,
SSchema extends StyleSchema,
>(
doc: Node,
): Record<
string,
{
block: Block<BSchema, ISchema, SSchema>;
parentId: string | undefined;
}
> {
const blocks: Record<
string,
{
block: Block<BSchema, ISchema, SSchema>;
parentId: string | undefined;
}
> = {};
const pmSchema = getPmSchema(doc);
doc.descendants((node, pos) => {
if (isNodeBlock(node)) {
const parentId = getParentBlockId(doc, pos);
blocks[node.attrs.id] = {
block: nodeToBlock(node, pmSchema),
parentId,
};
}
return true;
});
return blocks;
}
/**
* Get the blocks that were changed by a transaction.
*/
export function getBlocksChangedByTransaction<
BSchema extends BlockSchema = DefaultBlockSchema,
ISchema extends InlineContentSchema = DefaultInlineContentSchema,
SSchema extends StyleSchema = DefaultStyleSchema,
>(
transaction: Transaction,
appendedTransactions: Transaction[] = [],
): BlocksChanged<BSchema, ISchema, SSchema> {
const source = determineChangeSource(transaction);
const combinedTransaction = combineTransactionSteps(transaction.before, [
transaction,
...appendedTransactions,
]);
const prevBlocks = collectAllBlocks<BSchema, ISchema, SSchema>(
combinedTransaction.before,
);
const nextBlocks = collectAllBlocks<BSchema, ISchema, SSchema>(
combinedTransaction.doc,
);
const changes: BlocksChanged<BSchema, ISchema, SSchema> = [];
// Handle inserted blocks
Object.keys(nextBlocks)
.filter((id) => !(id in prevBlocks))
.forEach((id) => {
changes.push({
type: "insert",
block: nextBlocks[id].block,
source,
prevBlock: undefined,
});
});
// Handle deleted blocks
Object.keys(prevBlocks)
.filter((id) => !(id in nextBlocks))
.forEach((id) => {
changes.push({
type: "delete",
block: prevBlocks[id].block,
source,
prevBlock: undefined,
});
});
// Handle updated, moved, indented, outdented blocks
Object.keys(nextBlocks)
.filter((id) => id in prevBlocks)
.forEach((id) => {
const prev = prevBlocks[id];
const next = nextBlocks[id];
const isParentDifferent = prev.parentId !== next.parentId;
if (isParentDifferent) {
changes.push({
type: "move",
block: next.block,
prevBlock: prev.block,
source,
prevParent: prev.parentId
? prevBlocks[prev.parentId]?.block
: undefined,
currentParent: next.parentId
? nextBlocks[next.parentId]?.block
: undefined,
});
} else if (areBlocksDifferentExcludingChildren(prev.block, next.block)) {
changes.push({
type: "update",
block: next.block,
prevBlock: prev.block,
source,
});
}
});
return changes;
}