@blocknote/core
Version:
A "Notion-style" block-based extensible text editor built on top of Prosemirror and Tiptap.
272 lines (248 loc) • 7.2 kB
text/typescript
import {
combineTransactionSteps,
findChildrenInRange,
getChangedRanges,
} 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";
/**
* 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 =
| {
/**
* When an event is triggered by the local user, the source is "local".
* This is the default source.
*/
type: "local";
}
| {
/**
* When an event is triggered by a paste operation, the source is "paste".
*/
type: "paste";
}
| {
/**
* When an event is triggered by a drop operation, the source is "drop".
*/
type: "drop";
}
| {
/**
* When an event is triggered by an undo or redo operation, the source is "undo" or "redo".
* @note Y.js undo/redo are not differentiated.
*/
type: "undo" | "redo" | "undo-redo";
}
| {
/**
* When an event is triggered by a remote user, the source is "remote".
*/
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 block before the update.
*/
prevBlock: 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 {
// TODO use an actual diff algorithm
// Compare all properties except children
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)
);
}
/**
* Get the blocks that were changed by a transaction.
* @param transaction The transaction to get the changes from.
* @param editor The editor to get the changes from.
* @returns The blocks that were changed by the transaction.
*/
export function getBlocksChangedByTransaction<
BSchema extends BlockSchema = DefaultBlockSchema,
ISchema extends InlineContentSchema = DefaultInlineContentSchema,
SSchema extends StyleSchema = DefaultStyleSchema,
>(
transaction: Transaction,
appendedTransactions: Transaction[] = [],
): BlocksChanged<BSchema, ISchema, SSchema> {
let source: BlockChangeSource = { type: "local" };
if (transaction.getMeta("paste")) {
source = { type: "paste" };
} else if (transaction.getMeta("uiEvent") === "drop") {
source = { type: "drop" };
} else if (transaction.getMeta("history$")) {
source = {
type: transaction.getMeta("history$").redo ? "redo" : "undo",
};
} else if (transaction.getMeta("y-sync$")) {
if (transaction.getMeta("y-sync$").isUndoRedoOperation) {
source = {
type: "undo-redo",
};
} else {
source = {
type: "yjs-remote",
};
}
}
// Get affected blocks before and after the change
const pmSchema = getPmSchema(transaction);
const combinedTransaction = combineTransactionSteps(transaction.before, [
transaction,
...appendedTransactions,
]);
const changedRanges = getChangedRanges(combinedTransaction);
const prevAffectedBlocks = changedRanges
.flatMap((range) => {
return findChildrenInRange(
combinedTransaction.before,
range.oldRange,
isNodeBlock,
);
})
.map(({ node }) => nodeToBlock(node, pmSchema));
const nextAffectedBlocks = changedRanges
.flatMap((range) => {
return findChildrenInRange(
combinedTransaction.doc,
range.newRange,
isNodeBlock,
);
})
.map(({ node }) => nodeToBlock(node, pmSchema));
const nextBlocks = new Map(
nextAffectedBlocks.map((block) => {
return [block.id, block];
}),
);
const prevBlocks = new Map(
prevAffectedBlocks.map((block) => {
return [block.id, block];
}),
);
const changes: BlocksChanged<BSchema, ISchema, SSchema> = [];
// Inserted blocks are blocks that were not in the previous state and are in the next state
for (const [id, block] of nextBlocks) {
if (!prevBlocks.has(id)) {
changes.push({
type: "insert",
block,
source,
prevBlock: undefined,
});
}
}
// Deleted blocks are blocks that were in the previous state but not in the next state
for (const [id, block] of prevBlocks) {
if (!nextBlocks.has(id)) {
changes.push({
type: "delete",
block,
source,
prevBlock: undefined,
});
}
}
// Updated blocks are blocks that were in the previous state and are in the next state
for (const [id, block] of nextBlocks) {
if (prevBlocks.has(id)) {
const prevBlock = prevBlocks.get(id)!;
// Only include the update if the block itself changed (excluding children)
if (areBlocksDifferentExcludingChildren(prevBlock, block)) {
changes.push({
type: "update",
block,
prevBlock,
source,
});
}
}
}
return changes;
}