@blocknote/core
Version:
A "Notion-style" block-based extensible text editor built on top of Prosemirror and Tiptap.
332 lines (304 loc) • 10.8 kB
text/typescript
import {
Fragment,
type NodeType,
type Node as PMNode,
Slice,
} from "prosemirror-model";
import type { Transaction } from "prosemirror-state";
import { ReplaceStep, Transform } from "prosemirror-transform";
import type { Block, PartialBlock } from "../../../../blocks/defaultBlocks.js";
import type {
BlockIdentifier,
BlockSchema,
} from "../../../../schema/blocks/types.js";
import type { InlineContentSchema } from "../../../../schema/inlineContent/types.js";
import type { StyleSchema } from "../../../../schema/styles/types.js";
import { UnreachableCaseError } from "../../../../util/typescript.js";
import {
type BlockInfo,
getBlockInfoFromResolvedPos,
} from "../../../getBlockInfoFromPos.js";
import {
blockToNode,
inlineContentToNodes,
tableContentToNodes,
} from "../../../nodeConversions/blockToNode.js";
import { nodeToBlock } from "../../../nodeConversions/nodeToBlock.js";
import { getNodeById } from "../../../nodeUtil.js";
import { getPmSchema } from "../../../pmUtil.js";
// for compatibility with tiptap. TODO: remove as we want to remove dependency on tiptap command interface
export const updateBlockCommand = <
BSchema extends BlockSchema,
I extends InlineContentSchema,
S extends StyleSchema,
>(
posBeforeBlock: number,
block: PartialBlock<BSchema, I, S>,
) => {
return ({
tr,
dispatch,
}: {
tr: Transaction;
dispatch?: () => void;
}): boolean => {
if (dispatch) {
updateBlockTr(tr, posBeforeBlock, block);
}
return true;
};
};
export function updateBlockTr<
BSchema extends BlockSchema,
I extends InlineContentSchema,
S extends StyleSchema,
>(
tr: Transform,
posBeforeBlock: number,
block: PartialBlock<BSchema, I, S>,
replaceFromPos?: number,
replaceToPos?: number,
) {
const blockInfo = getBlockInfoFromResolvedPos(tr.doc.resolve(posBeforeBlock));
const pmSchema = getPmSchema(tr);
if (
replaceFromPos !== undefined &&
replaceToPos !== undefined &&
replaceFromPos > replaceToPos
) {
throw new Error("Invalid replaceFromPos or replaceToPos");
}
// Adds blockGroup node with child blocks if necessary.
const oldNodeType = pmSchema.nodes[blockInfo.blockNoteType];
const newNodeType = pmSchema.nodes[block.type || blockInfo.blockNoteType];
const newBnBlockNodeType = newNodeType.isInGroup("bnBlock")
? newNodeType
: pmSchema.nodes["blockContainer"];
if (blockInfo.isBlockContainer && newNodeType.isInGroup("blockContent")) {
const replaceFromOffset =
replaceFromPos !== undefined &&
replaceFromPos > blockInfo.blockContent.beforePos &&
replaceFromPos < blockInfo.blockContent.afterPos
? replaceFromPos - blockInfo.blockContent.beforePos - 1
: undefined;
const replaceToOffset =
replaceToPos !== undefined &&
replaceToPos > blockInfo.blockContent.beforePos &&
replaceToPos < blockInfo.blockContent.afterPos
? replaceToPos - blockInfo.blockContent.beforePos - 1
: undefined;
updateChildren(block, tr, blockInfo);
// The code below determines the new content of the block.
// or "keep" to keep as-is
updateBlockContentNode(
block,
tr,
oldNodeType,
newNodeType,
blockInfo,
replaceFromOffset,
replaceToOffset,
);
} else if (!blockInfo.isBlockContainer && newNodeType.isInGroup("bnBlock")) {
updateChildren(block, tr, blockInfo);
// old node was a bnBlock type (like column or columnList) and new block as well
// No op, we just update the bnBlock below (at end of function) and have already updated the children
} else {
// switching from blockContainer to non-blockContainer or v.v.
// currently breaking for column slash menu items converting empty block
// to column.
// currently, we calculate the new node and replace the entire node with the desired new node.
// for this, we do a nodeToBlock on the existing block to get the children.
// it would be cleaner to use a ReplaceAroundStep, but this is a bit simpler and it's quite an edge case
const existingBlock = nodeToBlock(blockInfo.bnBlock.node, pmSchema);
tr.replaceWith(
blockInfo.bnBlock.beforePos,
blockInfo.bnBlock.afterPos,
blockToNode(
{
children: existingBlock.children, // if no children are passed in, use existing children
...block,
},
pmSchema,
),
);
return;
}
// Adds all provided props as attributes to the parent blockContainer node too, and also preserves existing
// attributes.
tr.setNodeMarkup(blockInfo.bnBlock.beforePos, newBnBlockNodeType, {
...blockInfo.bnBlock.node.attrs,
...block.props,
});
}
function updateBlockContentNode<
BSchema extends BlockSchema,
I extends InlineContentSchema,
S extends StyleSchema,
>(
block: PartialBlock<BSchema, I, S>,
tr: Transform,
oldNodeType: NodeType,
newNodeType: NodeType,
blockInfo: {
childContainer?:
| { node: PMNode; beforePos: number; afterPos: number }
| undefined;
blockContent: { node: PMNode; beforePos: number; afterPos: number };
},
replaceFromOffset?: number,
replaceToOffset?: number,
) {
const pmSchema = getPmSchema(tr);
let content: PMNode[] | "keep" = "keep";
// Has there been any custom content provided?
if (block.content) {
if (typeof block.content === "string") {
// Adds a single text node with no marks to the content.
content = inlineContentToNodes(
[block.content],
pmSchema,
newNodeType.name,
);
} else if (Array.isArray(block.content)) {
// Adds a text node with the provided styles converted into marks to the content,
// for each InlineContent object.
content = inlineContentToNodes(block.content, pmSchema, newNodeType.name);
} else if (block.content.type === "tableContent") {
content = tableContentToNodes(block.content, pmSchema);
} else {
throw new UnreachableCaseError(block.content.type);
}
} else {
// no custom content has been provided, use existing content IF possible
// Since some block types contain inline content and others don't,
// we either need to call setNodeMarkup to just update type &
// attributes, or replaceWith to replace the whole blockContent.
if (oldNodeType.spec.content === "") {
// keep old content, because it's empty anyway and should be compatible with
// any newContentType
} else if (newNodeType.spec.content !== oldNodeType.spec.content) {
// the content type changed, replace the previous content
content = [];
} else {
// keep old content, because the content type is the same and should be compatible
}
}
// Now, changes the blockContent node type and adds the provided props
// as attributes. Also preserves all existing attributes that are
// compatible with the new type.
//
// Use either setNodeMarkup or replaceWith depending on whether the
// content is being replaced or not.
if (content === "keep") {
// use setNodeMarkup to only update the type and attributes
tr.setNodeMarkup(blockInfo.blockContent.beforePos, newNodeType, {
...blockInfo.blockContent.node.attrs,
...block.props,
});
} else if (replaceFromOffset !== undefined || replaceToOffset !== undefined) {
// first update markup of the containing node
tr.setNodeMarkup(blockInfo.blockContent.beforePos, newNodeType, {
...blockInfo.blockContent.node.attrs,
...block.props,
});
const start =
blockInfo.blockContent.beforePos + 1 + (replaceFromOffset ?? 0);
const end =
blockInfo.blockContent.beforePos +
1 +
(replaceToOffset ?? blockInfo.blockContent.node.content.size);
// for content like table cells (where the blockcontent has nested PM nodes),
// we need to figure out the correct openStart and openEnd for the slice when replacing
const contentDepth = tr.doc.resolve(blockInfo.blockContent.beforePos).depth;
const startDepth = tr.doc.resolve(start).depth;
const endDepth = tr.doc.resolve(end).depth;
tr.replace(
start,
end,
new Slice(
Fragment.from(content),
startDepth - contentDepth - 1,
endDepth - contentDepth - 1,
),
);
} else {
// use replaceWith to replace the content and the block itself
// also reset the selection since replacing the block content
// sets it to the next block.
tr.replaceWith(
blockInfo.blockContent.beforePos,
blockInfo.blockContent.afterPos,
newNodeType.createChecked(
{
...blockInfo.blockContent.node.attrs,
...block.props,
},
content,
),
);
}
}
function updateChildren<
BSchema extends BlockSchema,
I extends InlineContentSchema,
S extends StyleSchema,
>(block: PartialBlock<BSchema, I, S>, tr: Transform, blockInfo: BlockInfo) {
const pmSchema = getPmSchema(tr);
if (block.children !== undefined && block.children.length > 0) {
const childNodes = block.children.map((child) => {
return blockToNode(child, pmSchema);
});
// Checks if a blockGroup node already exists.
if (blockInfo.childContainer) {
// Replaces all child nodes in the existing blockGroup with the ones created earlier.
// use a replacestep to avoid the fitting algorithm
tr.step(
new ReplaceStep(
blockInfo.childContainer.beforePos + 1,
blockInfo.childContainer.afterPos - 1,
new Slice(Fragment.from(childNodes), 0, 0),
),
);
} else {
if (!blockInfo.isBlockContainer) {
throw new Error("impossible");
}
// Inserts a new blockGroup containing the child nodes created earlier.
tr.insert(
blockInfo.blockContent.afterPos,
pmSchema.nodes["blockGroup"].createChecked({}, childNodes),
);
}
}
}
export function updateBlock<
BSchema extends BlockSchema = any,
I extends InlineContentSchema = any,
S extends StyleSchema = any,
>(
tr: Transaction,
blockToUpdate: BlockIdentifier,
update: PartialBlock<BSchema, I, S>,
replaceFromPos?: number,
replaceToPos?: number,
): Block<BSchema, I, S> {
const id =
typeof blockToUpdate === "string" ? blockToUpdate : blockToUpdate.id;
const posInfo = getNodeById(id, tr.doc);
if (!posInfo) {
throw new Error(`Block with ID ${id} not found`);
}
updateBlockTr(
tr,
posInfo.posBeforeNode,
update,
replaceFromPos,
replaceToPos,
);
const blockContainerNode = tr.doc
.resolve(posInfo.posBeforeNode + 1) // TODO: clean?
.node();
const pmSchema = getPmSchema(tr);
return nodeToBlock(blockContainerNode, pmSchema);
}