@blocknote/core
Version:
A "Notion-style" block-based extensible text editor built on top of Prosemirror and Tiptap.
379 lines (340 loc) • 10.8 kB
text/typescript
import { Attrs, Fragment, Mark, Node, Schema } from "@tiptap/pm/model";
import UniqueID from "../../extensions/UniqueID/UniqueID.js";
import type {
InlineContentSchema,
PartialCustomInlineContentFromConfig,
PartialInlineContent,
PartialLink,
PartialTableContent,
StyleSchema,
StyledText,
} from "../../schema";
import type { PartialBlock } from "../../blocks/defaultBlocks";
import {
isPartialLinkInlineContent,
isStyledTextInlineContent,
} from "../../schema/inlineContent/types.js";
import { getColspan, isPartialTableCell } from "../../util/table.js";
import { UnreachableCaseError } from "../../util/typescript.js";
import { getAbsoluteTableCells } from "../blockManipulation/tables/tables.js";
import { getStyleSchema } from "../pmUtil.js";
/**
* Convert a StyledText inline element to a
* prosemirror text node with the appropriate marks
*/
function styledTextToNodes<T extends StyleSchema>(
styledText: StyledText<T>,
schema: Schema,
styleSchema: T,
blockType?: string,
): Node[] {
const marks: Mark[] = [];
for (const [style, value] of Object.entries(styledText.styles || {})) {
const config = styleSchema[style];
if (!config) {
throw new Error(`style ${style} not found in styleSchema`);
}
if (config.propSchema === "boolean") {
marks.push(schema.mark(style));
} else if (config.propSchema === "string") {
marks.push(schema.mark(style, { stringValue: value }));
} else {
throw new UnreachableCaseError(config.propSchema);
}
}
const parseHardBreaks = !blockType || !schema.nodes[blockType].spec.code;
if (!parseHardBreaks) {
return styledText.text.length > 0
? [schema.text(styledText.text, marks)]
: [];
}
return (
styledText.text
// Splits text & line breaks.
.split(/(\n)/g)
// If the content ends with a line break, an empty string is added to the
// end, which this removes.
.filter((text) => text.length > 0)
// Converts text & line breaks to nodes.
.map((text) => {
if (text === "\n") {
return schema.nodes["hardBreak"].createChecked();
} else {
return schema.text(text, marks);
}
})
);
}
/**
* Converts a Link inline content element to
* prosemirror text nodes with the appropriate marks
*/
function linkToNodes(
link: PartialLink<StyleSchema>,
schema: Schema,
styleSchema: StyleSchema,
): Node[] {
const linkMark = schema.marks.link.create({
href: link.href,
});
return styledTextArrayToNodes(link.content, schema, styleSchema).map(
(node) => {
if (node.type.name === "text") {
return node.mark([...node.marks, linkMark]);
}
if (node.type.name === "hardBreak") {
return node;
}
throw new Error("unexpected node type");
},
);
}
/**
* Converts an array of StyledText inline content elements to
* prosemirror text nodes with the appropriate marks
*/
function styledTextArrayToNodes<S extends StyleSchema>(
content: string | StyledText<S>[],
schema: Schema,
styleSchema: S,
blockType?: string,
): Node[] {
const nodes: Node[] = [];
if (typeof content === "string") {
nodes.push(
...styledTextToNodes(
{ type: "text", text: content, styles: {} },
schema,
styleSchema,
blockType,
),
);
return nodes;
}
for (const styledText of content) {
nodes.push(
...styledTextToNodes(styledText, schema, styleSchema, blockType),
);
}
return nodes;
}
/**
* converts an array of inline content elements to prosemirror nodes
*/
export function inlineContentToNodes<
I extends InlineContentSchema,
S extends StyleSchema,
>(
blockContent: PartialInlineContent<I, S>,
schema: Schema,
blockType?: string,
styleSchema: S = getStyleSchema(schema),
): Node[] {
const nodes: Node[] = [];
for (const content of blockContent) {
if (typeof content === "string") {
nodes.push(
...styledTextArrayToNodes(content, schema, styleSchema, blockType),
);
} else if (isPartialLinkInlineContent(content)) {
nodes.push(...linkToNodes(content, schema, styleSchema));
} else if (isStyledTextInlineContent(content)) {
nodes.push(
...styledTextArrayToNodes([content], schema, styleSchema, blockType),
);
} else {
nodes.push(
blockOrInlineContentToContentNode(content, schema, styleSchema),
);
}
}
return nodes;
}
/**
* converts an array of inline content elements to prosemirror nodes
*/
export function tableContentToNodes<
I extends InlineContentSchema,
S extends StyleSchema,
>(
tableContent: PartialTableContent<I, S>,
schema: Schema,
styleSchema: StyleSchema = getStyleSchema(schema),
): Node[] {
const rowNodes: Node[] = [];
// Header rows and columns are used to determine the type of the cell
// If headerRows is 1, then the first row is a header row
const headerRows = new Array(tableContent.headerRows ?? 0).fill(true);
// If headerCols is 1, then the first column is a header column
const headerCols = new Array(tableContent.headerCols ?? 0).fill(true);
const columnWidths: (number | undefined)[] = tableContent.columnWidths ?? [];
for (let rowIndex = 0; rowIndex < tableContent.rows.length; rowIndex++) {
const row = tableContent.rows[rowIndex];
const columnNodes: Node[] = [];
const isHeaderRow = headerRows[rowIndex];
for (let cellIndex = 0; cellIndex < row.cells.length; cellIndex++) {
const cell = row.cells[cellIndex];
const isHeaderCol = headerCols[cellIndex];
/**
* The attributes of the cell to apply to the node
*/
const attrs: Attrs | undefined = undefined;
/**
* The content of the cell to apply to the node
*/
let content: Fragment | Node | readonly Node[] | null = null;
// Colwidths are absolutely referenced to the table, so we need to resolve the relative cell index to the absolute cell index
const absoluteCellIndex = getAbsoluteTableCells(
{
row: rowIndex,
col: cellIndex,
},
{ type: "table", content: tableContent } as any,
);
// Assume the column width is the width of the cell at the absolute cell index
let colwidth: (number | undefined)[] | null = columnWidths[
absoluteCellIndex.col
]
? [columnWidths[absoluteCellIndex.col]]
: null;
if (!cell) {
// No-op
} else if (typeof cell === "string") {
content = schema.text(cell);
} else if (isPartialTableCell(cell)) {
if (cell.content) {
content = inlineContentToNodes(
cell.content,
schema,
"tableParagraph",
styleSchema,
);
}
const colspan = getColspan(cell);
if (colspan > 1) {
// If the cell has a > 1 colspan, we need to get the column width for each cell in the span
colwidth = new Array(colspan).fill(false).map((_, i) => {
// Starting from the absolute column index, get the column width for each cell in the span
return columnWidths[absoluteCellIndex.col + i] ?? undefined;
});
}
} else {
content = inlineContentToNodes(
cell,
schema,
"tableParagraph",
styleSchema,
);
}
const cellNode = schema.nodes[
isHeaderCol || isHeaderRow ? "tableHeader" : "tableCell"
].createChecked(
{
...(isPartialTableCell(cell) ? cell.props : {}),
colwidth,
},
schema.nodes["tableParagraph"].createChecked(attrs, content),
);
columnNodes.push(cellNode);
}
const rowNode = schema.nodes["tableRow"].createChecked({}, columnNodes);
rowNodes.push(rowNode);
}
return rowNodes;
}
function blockOrInlineContentToContentNode(
block:
| PartialBlock<any, any, any>
| PartialCustomInlineContentFromConfig<any, any>,
schema: Schema,
styleSchema: StyleSchema,
) {
let contentNode: Node;
let type = block.type;
// TODO: needed? came from previous code
if (type === undefined) {
type = "paragraph";
}
if (!schema.nodes[type]) {
throw new Error(`node type ${type} not found in schema`);
}
if (!block.content) {
contentNode = schema.nodes[type].createChecked(block.props);
} else if (typeof block.content === "string") {
const nodes = inlineContentToNodes(
[block.content],
schema,
type,
styleSchema,
);
contentNode = schema.nodes[type].createChecked(block.props, nodes);
} else if (Array.isArray(block.content)) {
const nodes = inlineContentToNodes(
block.content,
schema,
type,
styleSchema,
);
contentNode = schema.nodes[type].createChecked(block.props, nodes);
} else if (block.content.type === "tableContent") {
const nodes = tableContentToNodes(block.content, schema, styleSchema);
contentNode = schema.nodes[type].createChecked(block.props, nodes);
} else {
throw new UnreachableCaseError(block.content.type);
}
return contentNode;
}
/**
* Converts a BlockNote block to a Prosemirror node.
*/
export function blockToNode(
block: PartialBlock<any, any, any>,
schema: Schema,
styleSchema: StyleSchema = getStyleSchema(schema),
) {
let id = block.id;
if (id === undefined) {
id = UniqueID.options.generateID();
}
const children: Node[] = [];
if (block.children) {
for (const child of block.children) {
children.push(blockToNode(child, schema, styleSchema));
}
}
const isBlockContent =
!block.type || // can happen if block.type is not defined (this should create the default node)
schema.nodes[block.type].isInGroup("blockContent");
if (isBlockContent) {
// Blocks with a type that matches "blockContent" group always need to be wrapped in a blockContainer
const contentNode = blockOrInlineContentToContentNode(
block,
schema,
styleSchema,
);
const groupNode =
children.length > 0
? schema.nodes["blockGroup"].createChecked({}, children)
: undefined;
return schema.nodes["blockContainer"].createChecked(
{
id: id,
...block.props,
},
groupNode ? [contentNode, groupNode] : contentNode,
);
} else if (schema.nodes[block.type].isInGroup("bnBlock")) {
// this is a bnBlock node like Column or ColumnList that directly translates to a prosemirror node
return schema.nodes[block.type].createChecked(
{
id: id,
...block.props,
},
children,
);
} else {
throw new Error(
`block type ${block.type} doesn't match blockContent or bnBlock group`,
);
}
}