@blocknote/core
Version:
A "Notion-style" block-based extensible text editor built on top of Prosemirror and Tiptap.
283 lines (257 loc) • 8.15 kB
text/typescript
import {
Attribute,
Attributes,
Editor,
Extension,
Node,
NodeConfig,
} from "@tiptap/core";
import { defaultBlockToHTML } from "../../blocks/defaultBlockHelpers.js";
import { inheritedProps } from "../../blocks/defaultProps.js";
import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
import { mergeCSSClasses } from "../../util/browser.js";
import { camelToDataKebab } from "../../util/string.js";
import { InlineContentSchema } from "../inlineContent/types.js";
import { PropSchema, Props } from "../propTypes.js";
import { StyleSchema } from "../styles/types.js";
import {
BlockConfig,
BlockSchemaFromSpecs,
BlockSchemaWithBlock,
BlockSpec,
BlockSpecs,
SpecificBlock,
TiptapBlockImplementation,
} from "./types.js";
// Function that uses the 'propSchema' of a blockConfig to create a TipTap
// node's `addAttributes` property.
// TODO: extract function
export function propsToAttributes(propSchema: PropSchema): Attributes {
const tiptapAttributes: Record<string, Attribute> = {};
Object.entries(propSchema)
.filter(([name, _spec]) => !inheritedProps.includes(name))
.forEach(([name, spec]) => {
tiptapAttributes[name] = {
default: spec.default,
keepOnSplit: true,
// Props are displayed in kebab-case as HTML attributes. If a prop's
// value is the same as its default, we don't display an HTML
// attribute for it.
parseHTML: (element) => {
const value = element.getAttribute(camelToDataKebab(name));
if (value === null) {
return null;
}
if (
(spec.default === undefined && spec.type === "boolean") ||
(spec.default !== undefined && typeof spec.default === "boolean")
) {
if (value === "true") {
return true;
}
if (value === "false") {
return false;
}
return null;
}
if (
(spec.default === undefined && spec.type === "number") ||
(spec.default !== undefined && typeof spec.default === "number")
) {
const asNumber = parseFloat(value);
const isNumeric =
!Number.isNaN(asNumber) && Number.isFinite(asNumber);
if (isNumeric) {
return asNumber;
}
return null;
}
return value;
},
renderHTML: (attributes) => {
// don't render to html if the value is the same as the default
return attributes[name] !== spec.default
? {
[camelToDataKebab(name)]: attributes[name],
}
: {};
},
};
});
return tiptapAttributes;
}
// Used to figure out which block should be rendered. This block is then used to
// create the node view.
export function getBlockFromPos<
BType extends string,
Config extends BlockConfig,
BSchema extends BlockSchemaWithBlock<BType, Config>,
I extends InlineContentSchema,
S extends StyleSchema,
>(
getPos: (() => number) | boolean,
editor: BlockNoteEditor<BSchema, I, S>,
tipTapEditor: Editor,
type: BType,
) {
// Gets position of the node
if (typeof getPos === "boolean") {
throw new Error(
"Cannot find node position as getPos is a boolean, not a function.",
);
}
const pos = getPos();
// Gets parent blockContainer node
const blockContainer = tipTapEditor.state.doc.resolve(pos!).node();
// Gets block identifier
const blockIdentifier = blockContainer.attrs.id;
if (!blockIdentifier) {
throw new Error("Block doesn't have id");
}
// Gets the block
const block = editor.getBlock(blockIdentifier)! as SpecificBlock<
BSchema,
BType,
I,
S
>;
if (block.type !== type) {
throw new Error("Block type does not match");
}
return block;
}
// Function that wraps the `dom` element returned from 'blockConfig.render' in a
// `blockContent` div, which contains the block type and props as HTML
// attributes. If `blockConfig.render` also returns a `contentDOM`, it also adds
// an `inlineContent` class to it.
export function wrapInBlockStructure<
BType extends string,
PSchema extends PropSchema,
>(
element: {
dom: HTMLElement;
contentDOM?: HTMLElement;
destroy?: () => void;
},
blockType: BType,
blockProps: Props<PSchema>,
propSchema: PSchema,
isFileBlock = false,
domAttributes?: Record<string, string>,
): {
dom: HTMLElement;
contentDOM?: HTMLElement;
destroy?: () => void;
} {
// Creates `blockContent` element
const blockContent = document.createElement("div");
// Adds custom HTML attributes
if (domAttributes !== undefined) {
for (const [attr, value] of Object.entries(domAttributes)) {
if (attr !== "class") {
blockContent.setAttribute(attr, value);
}
}
}
// Sets blockContent class
blockContent.className = mergeCSSClasses(
"bn-block-content",
domAttributes?.class || "",
);
// Sets content type attribute
blockContent.setAttribute("data-content-type", blockType);
// Adds props as HTML attributes in kebab-case with "data-" prefix. Skips props
// which are already added as HTML attributes to the parent `blockContent`
// element (inheritedProps) and props set to their default values.
for (const [prop, value] of Object.entries(blockProps)) {
const spec = propSchema[prop];
const defaultValue = spec.default;
if (!inheritedProps.includes(prop) && value !== defaultValue) {
blockContent.setAttribute(camelToDataKebab(prop), value);
}
}
// Adds file block attribute
if (isFileBlock) {
blockContent.setAttribute("data-file-block", "");
}
blockContent.appendChild(element.dom);
if (element.contentDOM !== undefined) {
element.contentDOM.className = mergeCSSClasses(
"bn-inline-content",
element.contentDOM.className,
);
}
return {
...element,
dom: blockContent,
};
}
// Helper type to keep track of the `name` and `content` properties after calling Node.create.
type StronglyTypedTipTapNode<
Name extends string,
Content extends
| "inline*"
| "tableRow+"
| "blockContainer+"
| "column column+"
| "",
> = Node & { name: Name; config: { content: Content } };
export function createStronglyTypedTiptapNode<
Name extends string,
Content extends
| "inline*"
| "tableRow+"
| "blockContainer+"
| "column column+"
| "",
>(config: NodeConfig & { name: Name; content: Content }) {
return Node.create(config) as StronglyTypedTipTapNode<Name, Content>; // force re-typing (should be safe as it's type-checked from the config)
}
// This helper function helps to instantiate a blockspec with a
// config and implementation that conform to the type of Config
export function createInternalBlockSpec<T extends BlockConfig>(
config: T,
implementation: TiptapBlockImplementation<
T,
any,
InlineContentSchema,
StyleSchema
>,
) {
return {
config,
implementation,
} satisfies BlockSpec<T, any, InlineContentSchema, StyleSchema>;
}
export function createBlockSpecFromStronglyTypedTiptapNode<
T extends Node,
P extends PropSchema,
>(node: T, propSchema: P, requiredExtensions?: Array<Extension | Node>) {
return createInternalBlockSpec(
{
type: node.name as T["name"],
content: (node.config.content === "inline*"
? "inline"
: node.config.content === "tableRow+"
? "table"
: "none") as T["config"]["content"] extends "inline*"
? "inline"
: T["config"]["content"] extends "tableRow+"
? "table"
: "none",
propSchema,
},
{
node,
requiredExtensions,
toInternalHTML: defaultBlockToHTML,
toExternalHTML: defaultBlockToHTML,
// parse: () => undefined, // parse rules are in node already
},
);
}
export function getBlockSchemaFromSpecs<T extends BlockSpecs>(specs: T) {
return Object.fromEntries(
Object.entries(specs).map(([key, value]) => [key, value.config]),
) as BlockSchemaFromSpecs<T>;
}