@blocknote/core
Version:
A "Notion-style" block-based extensible text editor built on top of Prosemirror and Tiptap.
690 lines (629 loc) • 19.7 kB
text/typescript
import { Mark, Node, Schema, Slice } from "@tiptap/pm/model";
import type { Block } from "../../blocks/defaultBlocks.js";
import UniqueID from "../../extensions/UniqueID/UniqueID.js";
import type {
BlockSchema,
CustomInlineContentConfig,
CustomInlineContentFromConfig,
InlineContent,
InlineContentFromConfig,
InlineContentSchema,
StyleSchema,
Styles,
TableCell,
TableContent,
} from "../../schema/index.js";
import {
isLinkInlineContent,
isStyledTextInlineContent,
} from "../../schema/inlineContent/types.js";
import { UnreachableCaseError } from "../../util/typescript.js";
import { getBlockInfoWithManualOffset } from "../getBlockInfoFromPos.js";
import {
getBlockCache,
getBlockSchema,
getInlineContentSchema,
getStyleSchema,
} from "../pmUtil.js";
/**
* Converts an internal (prosemirror) table node contentto a BlockNote Tablecontent
*/
export function contentNodeToTableContent<
I extends InlineContentSchema,
S extends StyleSchema,
>(contentNode: Node, inlineContentSchema: I, styleSchema: S) {
const ret: TableContent<I, S> = {
type: "tableContent",
columnWidths: [],
headerRows: undefined,
headerCols: undefined,
rows: [],
};
/**
* A matrix of boolean values indicating whether a cell is a header.
* The first index is the row index, the second index is the cell index.
*/
const headerMatrix: boolean[][] = [];
contentNode.content.forEach((rowNode, _offset, rowIndex) => {
const row: TableContent<I, S>["rows"][0] = {
cells: [],
};
if (rowIndex === 0) {
rowNode.content.forEach((cellNode) => {
let colWidth = cellNode.attrs.colwidth as null | undefined | number[];
if (colWidth === undefined || colWidth === null) {
colWidth = new Array(cellNode.attrs.colspan ?? 1).fill(undefined);
}
ret.columnWidths.push(...colWidth);
});
}
row.cells = rowNode.content.content.map((cellNode, cellIndex) => {
if (!headerMatrix[rowIndex]) {
headerMatrix[rowIndex] = [];
}
// Mark the cell as a header if it is a tableHeader node.
headerMatrix[rowIndex][cellIndex] = cellNode.type.name === "tableHeader";
// Convert cell content to inline content and merge adjacent styled text nodes
const content = cellNode.content.content
.map((child) =>
contentNodeToInlineContent(child, inlineContentSchema, styleSchema),
)
// The reason that we merge this content is that we allow table cells to contain multiple tableParagraph nodes
// So that we can leverage prosemirror-tables native merging
// If the schema only allowed a single tableParagraph node, then the merging would not work and cause prosemirror to fit the content into a new cell
.reduce(
(acc, contentPartial) => {
if (!acc.length) {
return contentPartial;
}
const last = acc[acc.length - 1];
const first = contentPartial[0];
// Only merge if the last and first content are both styled text nodes and have the same styles
if (
first &&
isStyledTextInlineContent(last) &&
isStyledTextInlineContent(first) &&
JSON.stringify(last.styles) === JSON.stringify(first.styles)
) {
// Join them together if they have the same styles
last.text += "\n" + first.text;
acc.push(...contentPartial.slice(1));
return acc;
}
acc.push(...contentPartial);
return acc;
},
[] as InlineContent<I, S>[],
);
return {
type: "tableCell",
content,
props: {
colspan: cellNode.attrs.colspan,
rowspan: cellNode.attrs.rowspan,
backgroundColor: cellNode.attrs.backgroundColor,
textColor: cellNode.attrs.textColor,
textAlignment: cellNode.attrs.textAlignment,
},
} satisfies TableCell<I, S>;
});
ret.rows.push(row);
});
for (let i = 0; i < headerMatrix.length; i++) {
if (headerMatrix[i]?.every((isHeader) => isHeader)) {
ret.headerRows = (ret.headerRows ?? 0) + 1;
}
}
for (let i = 0; i < headerMatrix[0]?.length; i++) {
if (headerMatrix?.every((row) => row[i])) {
ret.headerCols = (ret.headerCols ?? 0) + 1;
}
}
return ret;
}
/**
* Converts an internal (prosemirror) content node to a BlockNote InlineContent array.
*/
export function contentNodeToInlineContent<
I extends InlineContentSchema,
S extends StyleSchema,
>(contentNode: Node, inlineContentSchema: I, styleSchema: S) {
const content: InlineContent<any, S>[] = [];
let currentContent: InlineContent<any, S> | undefined = undefined;
// Most of the logic below is for handling links because in ProseMirror links are marks
// while in BlockNote links are a type of inline content
contentNode.content.forEach((node) => {
// hardBreak nodes do not have an InlineContent equivalent, instead we
// add a newline to the previous node.
if (node.type.name === "hardBreak") {
if (currentContent) {
// Current content exists.
if (isStyledTextInlineContent(currentContent)) {
// Current content is text.
currentContent.text += "\n";
} else if (isLinkInlineContent(currentContent)) {
// Current content is a link.
currentContent.content[currentContent.content.length - 1].text +=
"\n";
} else {
throw new Error("unexpected");
}
} else {
// Current content does not exist.
currentContent = {
type: "text",
text: "\n",
styles: {},
};
}
return;
}
if (node.type.name !== "link" && node.type.name !== "text") {
if (!inlineContentSchema[node.type.name]) {
// eslint-disable-next-line no-console
console.warn("unrecognized inline content type", node.type.name);
return;
}
if (currentContent) {
content.push(currentContent);
currentContent = undefined;
}
content.push(
nodeToCustomInlineContent(node, inlineContentSchema, styleSchema),
);
return;
}
const styles: Styles<S> = {};
let linkMark: Mark | undefined;
for (const mark of node.marks) {
if (mark.type.name === "link") {
linkMark = mark;
} else {
const config = styleSchema[mark.type.name];
if (!config) {
if (mark.type.spec.blocknoteIgnore) {
// at this point, we don't want to show certain marks (such as comments)
// in the BlockNote JSON output. These marks should be tagged with "blocknoteIgnore" in the spec
continue;
}
throw new Error(`style ${mark.type.name} not found in styleSchema`);
}
if (config.propSchema === "boolean") {
(styles as any)[config.type] = true;
} else if (config.propSchema === "string") {
(styles as any)[config.type] = mark.attrs.stringValue;
} else {
throw new UnreachableCaseError(config.propSchema);
}
}
}
// Parsing links and text.
// Current content exists.
if (currentContent) {
// Current content is text.
if (isStyledTextInlineContent(currentContent)) {
if (!linkMark) {
// Node is text (same type as current content).
if (
JSON.stringify(currentContent.styles) === JSON.stringify(styles)
) {
// Styles are the same.
currentContent.text += node.textContent;
} else {
// Styles are different.
content.push(currentContent);
currentContent = {
type: "text",
text: node.textContent,
styles,
};
}
} else {
// Node is a link (different type to current content).
content.push(currentContent);
currentContent = {
type: "link",
href: linkMark.attrs.href,
content: [
{
type: "text",
text: node.textContent,
styles,
},
],
};
}
} else if (isLinkInlineContent(currentContent)) {
// Current content is a link.
if (linkMark) {
// Node is a link (same type as current content).
// Link URLs are the same.
if (currentContent.href === linkMark.attrs.href) {
// Styles are the same.
if (
JSON.stringify(
currentContent.content[currentContent.content.length - 1]
.styles,
) === JSON.stringify(styles)
) {
currentContent.content[currentContent.content.length - 1].text +=
node.textContent;
} else {
// Styles are different.
currentContent.content.push({
type: "text",
text: node.textContent,
styles,
});
}
} else {
// Link URLs are different.
content.push(currentContent);
currentContent = {
type: "link",
href: linkMark.attrs.href,
content: [
{
type: "text",
text: node.textContent,
styles,
},
],
};
}
} else {
// Node is text (different type to current content).
content.push(currentContent);
currentContent = {
type: "text",
text: node.textContent,
styles,
};
}
} else {
// TODO
}
}
// Current content does not exist.
else {
// Node is text.
if (!linkMark) {
currentContent = {
type: "text",
text: node.textContent,
styles,
};
}
// Node is a link.
else {
currentContent = {
type: "link",
href: linkMark.attrs.href,
content: [
{
type: "text",
text: node.textContent,
styles,
},
],
};
}
}
});
if (currentContent) {
content.push(currentContent);
}
return content as InlineContent<I, S>[];
}
export function nodeToCustomInlineContent<
I extends InlineContentSchema,
S extends StyleSchema,
>(node: Node, inlineContentSchema: I, styleSchema: S): InlineContent<I, S> {
if (node.type.name === "text" || node.type.name === "link") {
throw new Error("unexpected");
}
const props: any = {};
const icConfig = inlineContentSchema[
node.type.name
] as CustomInlineContentConfig;
for (const [attr, value] of Object.entries(node.attrs)) {
if (!icConfig) {
throw Error("ic node is of an unrecognized type: " + node.type.name);
}
const propSchema = icConfig.propSchema;
if (attr in propSchema) {
props[attr] = value;
}
}
let content: CustomInlineContentFromConfig<any, any>["content"];
if (icConfig.content === "styled") {
content = contentNodeToInlineContent(
node,
inlineContentSchema,
styleSchema,
) as any; // TODO: is this safe? could we have Links here that are undesired?
} else {
content = undefined;
}
const ic = {
type: node.type.name,
props,
content,
} as InlineContentFromConfig<I[keyof I], S>;
return ic;
}
/**
* Convert a Prosemirror node to a BlockNote block.
*
* TODO: test changes
*/
export function nodeToBlock<
BSchema extends BlockSchema,
I extends InlineContentSchema,
S extends StyleSchema,
>(
node: Node,
schema: Schema,
blockSchema: BSchema = getBlockSchema(schema) as BSchema,
inlineContentSchema: I = getInlineContentSchema(schema) as I,
styleSchema: S = getStyleSchema(schema) as S,
blockCache = getBlockCache(schema),
): Block<BSchema, I, S> {
if (!node.type.isInGroup("bnBlock")) {
throw Error("Node should be a bnBlock, but is instead: " + node.type.name);
}
const cachedBlock = blockCache?.get(node);
if (cachedBlock) {
return cachedBlock;
}
const blockInfo = getBlockInfoWithManualOffset(node, 0);
let id = blockInfo.bnBlock.node.attrs.id;
// Only used for blocks converted from other formats.
if (id === null) {
id = UniqueID.options.generateID();
}
const blockSpec = blockSchema[blockInfo.blockNoteType];
if (!blockSpec) {
throw Error("Block is of an unrecognized type: " + blockInfo.blockNoteType);
}
const props: any = {};
for (const [attr, value] of Object.entries({
...node.attrs,
...(blockInfo.isBlockContainer ? blockInfo.blockContent.node.attrs : {}),
})) {
const propSchema = blockSpec.propSchema;
if (
attr in propSchema &&
!(propSchema[attr].default === undefined && value === undefined)
) {
props[attr] = value;
}
}
const blockConfig = blockSchema[blockInfo.blockNoteType];
const children: Block<BSchema, I, S>[] = [];
blockInfo.childContainer?.node.forEach((child) => {
children.push(
nodeToBlock(
child,
schema,
blockSchema,
inlineContentSchema,
styleSchema,
blockCache,
),
);
});
let content: Block<any, any, any>["content"];
if (blockConfig.content === "inline") {
if (!blockInfo.isBlockContainer) {
throw new Error("impossible");
}
content = contentNodeToInlineContent(
blockInfo.blockContent.node,
inlineContentSchema,
styleSchema,
);
} else if (blockConfig.content === "table") {
if (!blockInfo.isBlockContainer) {
throw new Error("impossible");
}
content = contentNodeToTableContent(
blockInfo.blockContent.node,
inlineContentSchema,
styleSchema,
);
} else if (blockConfig.content === "none") {
content = undefined;
} else {
throw new UnreachableCaseError(blockConfig.content);
}
const block = {
id,
type: blockConfig.type,
props,
content,
children,
} as Block<BSchema, I, S>;
blockCache?.set(node, block);
return block;
}
/**
* Convert a Prosemirror document to a BlockNote document (array of blocks)
*/
export function docToBlocks<
BSchema extends BlockSchema,
I extends InlineContentSchema,
S extends StyleSchema,
>(
doc: Node,
schema: Schema,
blockSchema: BSchema = getBlockSchema(schema) as BSchema,
inlineContentSchema: I = getInlineContentSchema(schema) as I,
styleSchema: S = getStyleSchema(schema) as S,
blockCache = getBlockCache(schema),
) {
const blocks: Block<BSchema, I, S>[] = [];
doc.firstChild!.descendants((node) => {
blocks.push(
nodeToBlock(
node,
schema,
blockSchema,
inlineContentSchema,
styleSchema,
blockCache,
),
);
return false;
});
return blocks;
}
/**
*
* Parse a Prosemirror Slice into a BlockNote selection. The prosemirror schema looks like this:
*
* <blockGroup>
* <blockContainer> (main content of block)
* <p, heading, etc.>
* <blockGroup> (only if blocks has children)
* <blockContainer> (child block)
* <p, heading, etc.>
* </blockContainer>
* <blockContainer> (child block 2)
* <p, heading, etc.>
* </blockContainer>
* </blockContainer>
* </blockGroup>
* </blockGroup>
*
*/
export function prosemirrorSliceToSlicedBlocks<
BSchema extends BlockSchema,
I extends InlineContentSchema,
S extends StyleSchema,
>(
slice: Slice,
schema: Schema,
blockSchema: BSchema = getBlockSchema(schema) as BSchema,
inlineContentSchema: I = getInlineContentSchema(schema) as I,
styleSchema: S = getStyleSchema(schema) as S,
blockCache: WeakMap<Node, Block<BSchema, I, S>> = getBlockCache(schema),
): {
/**
* The blocks that are included in the selection.
*/
blocks: Block<BSchema, I, S>[];
/**
* If a block was "cut" at the start of the selection, this will be the id of the block that was cut.
*/
blockCutAtStart: string | undefined;
/**
* If a block was "cut" at the end of the selection, this will be the id of the block that was cut.
*/
blockCutAtEnd: string | undefined;
} {
// console.log(JSON.stringify(slice.toJSON()));
function processNode(
node: Node,
openStart: number,
openEnd: number,
): {
blocks: Block<BSchema, I, S>[];
blockCutAtStart: string | undefined;
blockCutAtEnd: string | undefined;
} {
if (node.type.name !== "blockGroup") {
throw new Error("unexpected");
}
const blocks: Block<BSchema, I, S>[] = [];
let blockCutAtStart: string | undefined;
let blockCutAtEnd: string | undefined;
node.forEach((blockContainer, _offset, index) => {
if (blockContainer.type.name !== "blockContainer") {
throw new Error("unexpected");
}
if (blockContainer.childCount === 0) {
return;
}
if (blockContainer.childCount === 0 || blockContainer.childCount > 2) {
throw new Error(
"unexpected, blockContainer.childCount: " + blockContainer.childCount,
);
}
const isFirstBlock = index === 0;
const isLastBlock = index === node.childCount - 1;
if (blockContainer.firstChild!.type.name === "blockGroup") {
// this is the parent where a selection starts within one of its children,
// e.g.:
// A
// ├── B
// selection starts within B, then this blockContainer is A, but we don't care about A
// so let's descend into B and continue processing
if (!isFirstBlock) {
throw new Error("unexpected");
}
const ret = processNode(
blockContainer.firstChild!,
Math.max(0, openStart - 1),
isLastBlock ? Math.max(0, openEnd - 1) : 0,
);
blockCutAtStart = ret.blockCutAtStart;
if (isLastBlock) {
blockCutAtEnd = ret.blockCutAtEnd;
}
blocks.push(...ret.blocks);
return;
}
const block = nodeToBlock(
blockContainer,
schema,
blockSchema,
inlineContentSchema,
styleSchema,
blockCache,
);
const childGroup =
blockContainer.childCount > 1 ? blockContainer.child(1) : undefined;
let childBlocks: Block<BSchema, I, S>[] = [];
if (childGroup) {
const ret = processNode(
childGroup,
0, // TODO: can this be anything other than 0?
isLastBlock ? Math.max(0, openEnd - 1) : 0,
);
childBlocks = ret.blocks;
if (isLastBlock) {
blockCutAtEnd = ret.blockCutAtEnd;
}
}
if (isLastBlock && !childGroup && openEnd > 1) {
blockCutAtEnd = block.id;
}
if (isFirstBlock && openStart > 1) {
blockCutAtStart = block.id;
}
blocks.push({
...(block as any),
children: childBlocks,
});
});
return { blocks, blockCutAtStart, blockCutAtEnd };
}
if (slice.content.childCount === 0) {
return {
blocks: [],
blockCutAtStart: undefined,
blockCutAtEnd: undefined,
};
}
if (slice.content.childCount !== 1) {
throw new Error(
"slice must be a single block, did you forget includeParents=true?",
);
}
return processNode(
slice.content.firstChild!,
Math.max(slice.openStart - 1, 0),
Math.max(slice.openEnd - 1, 0),
);
}