UNPKG

@blocknote/core

Version:

A "Notion-style" block-based extensible text editor built on top of Prosemirror and Tiptap.

260 lines (235 loc) 7.96 kB
import { Editor } from "@tiptap/core"; import { TagParseRule } from "@tiptap/pm/model"; import { NodeView } from "@tiptap/pm/view"; import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import { InlineContentSchema } from "../inlineContent/types.js"; import { StyleSchema } from "../styles/types.js"; import { createInternalBlockSpec, createStronglyTypedTiptapNode, getBlockFromPos, propsToAttributes, wrapInBlockStructure, } from "./internal.js"; import { BlockConfig, BlockFromConfig, BlockSchemaWithBlock, PartialBlockFromConfig, } from "./types.js"; // restrict content to "inline" and "none" only export type CustomBlockConfig = BlockConfig & { content: "inline" | "none"; }; export type CustomBlockImplementation< T extends CustomBlockConfig, I extends InlineContentSchema, S extends StyleSchema, > = { render: ( /** * The custom block to render */ block: BlockFromConfig<T, I, S>, /** * The BlockNote editor instance * This is typed generically. If you want an editor with your custom schema, you need to * cast it manually, e.g.: `const e = editor as BlockNoteEditor<typeof mySchema>;` */ editor: BlockNoteEditor<BlockSchemaWithBlock<T["type"], T>, I, S>, // (note) if we want to fix the manual cast, we need to prevent circular references and separate block definition and render implementations // or allow manually passing <BSchema>, but that's not possible without passing the other generics because Typescript doesn't support partial inferred generics ) => { dom: HTMLElement; contentDOM?: HTMLElement; destroy?: () => void; }; // Exports block to external HTML. If not defined, the output will be the same // as `render(...).dom`. Used to create clipboard data when pasting outside // BlockNote. // TODO: Maybe can return undefined to ignore when serializing? toExternalHTML?: ( block: BlockFromConfig<T, I, S>, editor: BlockNoteEditor<BlockSchemaWithBlock<T["type"], T>, I, S>, ) => { dom: HTMLElement; contentDOM?: HTMLElement; }; parse?: ( el: HTMLElement, ) => PartialBlockFromConfig<T, I, S>["props"] | undefined; }; // 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( config: BlockConfig, customParseFunction: CustomBlockImplementation<any, any, any>["parse"], ) { const rules: TagParseRule[] = [ { tag: "[data-content-type=" + config.type + "]", contentElement: ".bn-inline-content", }, ]; if (customParseFunction) { rules.push({ tag: "*", getAttrs(node: string | HTMLElement) { if (typeof node === "string") { return false; } const props = customParseFunction?.(node); if (props === undefined) { return false; } return props; }, }); } // 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 createBlockSpec< T extends CustomBlockConfig, I extends InlineContentSchema, S extends StyleSchema, >( blockConfig: T, blockImplementation: CustomBlockImplementation<NoInfer<T>, I, S>, ) { const node = createStronglyTypedTiptapNode({ name: blockConfig.type as T["type"], content: (blockConfig.content === "inline" ? "inline*" : "") as T["content"] extends "inline" ? "inline*" : "", group: "blockContent", selectable: blockConfig.isSelectable ?? true, isolating: true, addAttributes() { return propsToAttributes(blockConfig.propSchema); }, parseHTML() { return getParseRules(blockConfig, blockImplementation.parse); }, 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, blockConfig.isFileBlock, HTMLAttributes, ); }, addNodeView() { return ({ getPos }) => { // Gets the BlockNote editor instance const editor = this.options.editor; // Gets the block const block = getBlockFromPos( getPos, editor, this.editor, blockConfig.type, ); // Gets the custom HTML attributes for `blockContent` nodes const blockContentDOMAttributes = this.options.domAttributes?.blockContent || {}; const output = blockImplementation.render(block as any, editor); const nodeView: NodeView = wrapInBlockStructure( output, block.type, block.props, blockConfig.propSchema, blockContentDOMAttributes, ); if (blockConfig.isSelectable === false) { applyNonSelectableBlockFix(nodeView, this.editor); } return nodeView; }; }, }); if (node.name !== blockConfig.type) { throw new Error( "Node name does not match block type. This is a bug in BlockNote.", ); } return createInternalBlockSpec(blockConfig, { node, toInternalHTML: (block, editor) => { const blockContentDOMAttributes = node.options.domAttributes?.blockContent || {}; const output = blockImplementation.render(block as any, editor as any); return wrapInBlockStructure( output, block.type, block.props, blockConfig.propSchema, blockConfig.isFileBlock, blockContentDOMAttributes, ); }, // 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 || {}; let output = blockImplementation.toExternalHTML?.( block as any, editor as any, ); if (output === undefined) { output = blockImplementation.render(block as any, editor as any); } return wrapInBlockStructure( output, block.type, block.props, blockConfig.propSchema, blockContentDOMAttributes, ); }, }); }