@blocknote/core
Version:
A "Notion-style" block-based extensible text editor built on top of Prosemirror and Tiptap.
442 lines (408 loc) • 14.5 kB
text/typescript
import { Editor, Node } from "@tiptap/core";
import { DOMParser, Fragment, TagParseRule } from "@tiptap/pm/model";
import { NodeView } from "@tiptap/pm/view";
import { mergeParagraphs } from "../../blocks/defaultBlockHelpers.js";
import {
Extension,
ExtensionFactoryInstance,
} from "../../editor/BlockNoteExtension.js";
import { PropSchema } from "../propTypes.js";
import {
getBlockFromPos,
propsToAttributes,
wrapInBlockStructure,
} from "./internal.js";
import {
BlockConfig,
BlockImplementation,
BlockSpec,
LooseBlockSpec,
} from "./types.js";
// Function that causes events within non-selectable blocks to be handled by the
// browser instead of the editor.
export function applyNonSelectableBlockFix(nodeView: NodeView, editor: Editor) {
nodeView.stopEvent = (event) => {
// Blurs the editor on mouse down as the block is non-selectable. This is
// mainly done to prevent UI elements like the formatting toolbar from being
// visible while content within a non-selectable block is selected.
if (event.type === "mousedown") {
setTimeout(() => {
editor.view.dom.blur();
}, 10);
}
return true;
};
}
// Function that uses the 'parse' function of a blockConfig to create a
// TipTap node's `parseHTML` property. This is only used for parsing content
// from the clipboard.
export function getParseRules<
TName extends string,
TProps extends PropSchema,
TContent extends "inline" | "none" | "table",
>(
config: BlockConfig<TName, TProps, TContent>,
implementation: BlockImplementation<TName, TProps, TContent>,
) {
const rules: TagParseRule[] = [
{
tag: "[data-content-type=" + config.type + "]",
contentElement: ".bn-inline-content",
},
];
if (implementation.parse) {
rules.push({
tag: "*",
getAttrs(node: string | HTMLElement) {
if (typeof node === "string") {
return false;
}
const props = implementation.parse?.(node);
if (props === undefined) {
return false;
}
return props;
},
// Because we do the parsing ourselves, we want to preserve whitespace for content we've parsed
preserveWhitespace: true,
getContent:
config.content === "inline" || config.content === "none"
? (node, schema) => {
if (implementation.parseContent) {
return implementation.parseContent({
el: node as HTMLElement,
schema,
});
}
if (config.content === "inline") {
// Parse the inline content if it exists
const element = node as HTMLElement;
// Clone to avoid modifying the original
const clone = element.cloneNode(true) as HTMLElement;
// Merge multiple paragraphs into one with line breaks
mergeParagraphs(
clone,
implementation.meta?.code ? "\n" : "<br>",
);
// Parse the content directly as a paragraph to extract inline content
const parser = DOMParser.fromSchema(schema);
const parsed = parser.parse(clone, {
topNode: schema.nodes.paragraph.create(),
preserveWhitespace: true,
});
return parsed.content;
}
return Fragment.empty;
}
: undefined,
});
}
// getContent(node, schema) {
// const block = blockConfig.parse?.(node as HTMLElement);
//
// if (block !== undefined && block.content !== undefined) {
// return Fragment.from(
// typeof block.content === "string"
// ? schema.text(block.content)
// : inlineContentToNodes(block.content, schema)
// );
// }
//
// return Fragment.empty;
// },
// });
// }
return rules;
}
// A function to create custom block for API consumers
// we want to hide the tiptap node from API consumers and provide a simpler API surface instead
export function addNodeAndExtensionsToSpec<
TName extends string,
TProps extends PropSchema,
TContent extends "inline" | "none" | "table",
>(
blockConfig: BlockConfig<TName, TProps, TContent>,
blockImplementation: BlockImplementation<TName, TProps, TContent>,
extensions?: (ExtensionFactoryInstance | Extension)[],
priority?: number,
): LooseBlockSpec<TName, TProps, TContent> {
const node =
((blockImplementation as any).node as Node) ||
Node.create({
name: blockConfig.type,
content: (blockConfig.content === "inline"
? "inline*"
: blockConfig.content === "none"
? ""
: blockConfig.content) as TContent extends "inline" ? "inline*" : "",
group: "blockContent",
selectable: blockImplementation.meta?.selectable ?? true,
isolating: blockImplementation.meta?.isolating ?? true,
code: blockImplementation.meta?.code ?? false,
defining: blockImplementation.meta?.defining ?? true,
priority,
addAttributes() {
return propsToAttributes(blockConfig.propSchema);
},
parseHTML() {
return getParseRules(blockConfig, blockImplementation);
},
renderHTML({ HTMLAttributes }) {
// renderHTML is used for copy/pasting content from the editor back into
// the editor, so we need to make sure the `blockContent` element is
// structured correctly as this is what's used for parsing blocks. We
// just render a placeholder div inside as the `blockContent` element
// already has all the information needed for proper parsing.
const div = document.createElement("div");
return wrapInBlockStructure(
{
dom: div,
contentDOM: blockConfig.content === "inline" ? div : undefined,
},
blockConfig.type,
{},
blockConfig.propSchema,
blockImplementation.meta?.fileBlockAccept !== undefined,
HTMLAttributes,
);
},
addNodeView() {
return (props) => {
// Gets the BlockNote editor instance
const editor = this.options.editor;
// Gets the block
const block = getBlockFromPos(
props.getPos,
editor,
this.editor,
blockConfig.type,
);
// Gets the custom HTML attributes for `blockContent` nodes
const blockContentDOMAttributes =
this.options.domAttributes?.blockContent || {};
const nodeView = blockImplementation.render.call(
{ blockContentDOMAttributes, props, renderType: "nodeView" },
block as any,
editor as any,
);
if (blockImplementation.meta?.selectable === false) {
applyNonSelectableBlockFix(nodeView, this.editor);
}
// See explanation for why `update` is not implemented for NodeViews
// https://github.com/TypeCellOS/BlockNote/pull/1904#discussion_r2313461464
return nodeView;
};
},
});
if (node.name !== blockConfig.type) {
throw new Error(
"Node name does not match block type. This is a bug in BlockNote.",
);
}
return {
config: blockConfig,
implementation: {
...blockImplementation,
node,
render(block, editor) {
const blockContentDOMAttributes =
node.options.domAttributes?.blockContent || {};
return blockImplementation.render.call(
{
blockContentDOMAttributes,
props: undefined,
renderType: "dom",
},
block as any,
editor as any,
);
},
// TODO: this should not have wrapInBlockStructure and generally be a lot simpler
// post-processing in externalHTMLExporter should not be necessary
toExternalHTML: (block, editor) => {
const blockContentDOMAttributes =
node.options.domAttributes?.blockContent || {};
return (
blockImplementation.toExternalHTML?.call(
{ blockContentDOMAttributes },
block as any,
editor as any,
) ??
blockImplementation.render.call(
{ blockContentDOMAttributes, renderType: "dom", props: undefined },
block as any,
editor as any,
)
);
},
},
extensions,
};
}
/**
* Helper function to create a block config.
*/
export function createBlockConfig<
TCallback extends (
options: Partial<Record<string, any>>,
) => BlockConfig<any, any, any>,
TOptions extends Parameters<TCallback>[0],
TName extends ReturnType<TCallback>["type"],
TProps extends ReturnType<TCallback>["propSchema"],
TContent extends ReturnType<TCallback>["content"],
>(
callback: TCallback,
): TOptions extends undefined
? () => BlockConfig<TName, TProps, TContent>
: (options: TOptions) => BlockConfig<TName, TProps, TContent> {
return callback as any;
}
/**
* Helper function to create a block definition.
* Can accept either functions that return the required objects, or the objects directly.
*/
export function createBlockSpec<
const TName extends string,
const TProps extends PropSchema,
const TContent extends "inline" | "none",
const TOptions extends Partial<Record<string, any>> | undefined = undefined,
>(
blockConfigOrCreator: BlockConfig<TName, TProps, TContent>,
blockImplementationOrCreator:
| BlockImplementation<TName, TProps, TContent>
| (TOptions extends undefined
? () => BlockImplementation<TName, TProps, TContent>
: (
options: Partial<TOptions>,
) => BlockImplementation<TName, TProps, TContent>),
extensionsOrCreator?:
| ExtensionFactoryInstance[]
| (TOptions extends undefined
? () => ExtensionFactoryInstance[]
: (options: Partial<TOptions>) => ExtensionFactoryInstance[]),
): (options?: Partial<TOptions>) => BlockSpec<TName, TProps, TContent>;
export function createBlockSpec<
const TName extends string,
const TProps extends PropSchema,
const TContent extends "inline" | "none",
const BlockConf extends BlockConfig<TName, TProps, TContent>,
const TOptions extends Partial<Record<string, any>>,
>(
blockCreator: (options: Partial<TOptions>) => BlockConf,
blockImplementationOrCreator:
| BlockImplementation<
BlockConf["type"],
BlockConf["propSchema"],
BlockConf["content"]
>
| (TOptions extends undefined
? () => BlockImplementation<
BlockConf["type"],
BlockConf["propSchema"],
BlockConf["content"]
>
: (
options: Partial<TOptions>,
) => BlockImplementation<
BlockConf["type"],
BlockConf["propSchema"],
BlockConf["content"]
>),
extensionsOrCreator?:
| ExtensionFactoryInstance[]
| (TOptions extends undefined
? () => ExtensionFactoryInstance[]
: (options: Partial<TOptions>) => ExtensionFactoryInstance[]),
): (
options?: Partial<TOptions>,
) => BlockSpec<
BlockConf["type"],
BlockConf["propSchema"],
BlockConf["content"]
>;
export function createBlockSpec<
const TName extends string,
const TProps extends PropSchema,
const TContent extends "inline" | "none",
const TOptions extends Partial<Record<string, any>> | undefined = undefined,
>(
blockConfigOrCreator:
| BlockConfig<TName, TProps, TContent>
| (TOptions extends undefined
? () => BlockConfig<TName, TProps, TContent>
: (options: Partial<TOptions>) => BlockConfig<TName, TProps, TContent>),
blockImplementationOrCreator:
| BlockImplementation<TName, TProps, TContent>
| (TOptions extends undefined
? () => BlockImplementation<TName, TProps, TContent>
: (
options: Partial<TOptions>,
) => BlockImplementation<TName, TProps, TContent>),
extensionsOrCreator?:
| ExtensionFactoryInstance[]
| (TOptions extends undefined
? () => ExtensionFactoryInstance[]
: (options: Partial<TOptions>) => ExtensionFactoryInstance[]),
): (options?: Partial<TOptions>) => BlockSpec<TName, TProps, TContent> {
return (options = {} as TOptions) => {
const blockConfig =
typeof blockConfigOrCreator === "function"
? blockConfigOrCreator(options as any)
: blockConfigOrCreator;
const blockImplementation =
typeof blockImplementationOrCreator === "function"
? blockImplementationOrCreator(options as any)
: blockImplementationOrCreator;
const extensions = extensionsOrCreator
? typeof extensionsOrCreator === "function"
? extensionsOrCreator(options as any)
: extensionsOrCreator
: undefined;
return {
config: blockConfig,
implementation: {
...blockImplementation,
// TODO: this should not have wrapInBlockStructure and generally be a lot simpler
// post-processing in externalHTMLExporter should not be necessary
toExternalHTML(block, editor) {
const output = blockImplementation.toExternalHTML?.call(
{ blockContentDOMAttributes: this.blockContentDOMAttributes },
block as any,
editor as any,
);
if (output === undefined) {
return undefined;
}
return wrapInBlockStructure(
output,
block.type,
block.props,
blockConfig.propSchema,
blockImplementation.meta?.fileBlockAccept !== undefined,
);
},
render(block, editor) {
const output = blockImplementation.render.call(
{
blockContentDOMAttributes: this.blockContentDOMAttributes,
renderType: this.renderType,
props: this.props as any,
},
block as any,
editor as any,
);
const nodeView = wrapInBlockStructure(
output,
block.type,
block.props,
blockConfig.propSchema,
blockImplementation.meta?.fileBlockAccept !== undefined,
this.blockContentDOMAttributes,
) satisfies NodeView;
return nodeView;
},
},
extensions: extensions,
};
};
}