UNPKG

@blocknote/core

Version:

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

222 lines (200 loc) 6.26 kB
import { Attribute, Attributes, Editor, Node } from "@tiptap/core"; import { defaultBlockToHTML } from "../../blocks/defaultBlockHelpers.js"; import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import type { ExtensionFactoryInstance } from "../../editor/BlockNoteExtension.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, BlockSchemaWithBlock, LooseBlockSpec, SpecificBlock, } 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).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 | undefined, editor: BlockNoteEditor<BSchema, I, S>, tipTapEditor: Editor, type: BType, ) { const pos = getPos(); // Gets position of the node if (pos === undefined) { throw new Error("Cannot find node position"); } // 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 | DocumentFragment; contentDOM?: HTMLElement; destroy?: () => void; }, blockType: BType, blockProps: Partial<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 (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) { element.contentDOM.className = mergeCSSClasses( "bn-inline-content", element.contentDOM.className, ); } return { ...element, dom: blockContent, }; } export function createBlockSpecFromTiptapNode< const T extends { node: Node; type: string; content: "inline" | "table" | "none"; }, P extends PropSchema, >( config: T, propSchema: P, extensions?: ExtensionFactoryInstance[], ): LooseBlockSpec<T["type"], P, T["content"]> { return { config: { type: config.type as T["type"], content: config.content, propSchema, }, implementation: { node: config.node, render: defaultBlockToHTML, toExternalHTML: defaultBlockToHTML, }, extensions, }; }