UNPKG

@blocknote/core

Version:

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

256 lines (234 loc) 8.58 kB
import { Attribute, Attributes, Node } from "@tiptap/core"; import type { Node as PMNode } from "prosemirror-model"; import { nodeToBlock } from "../../api/nodeConversions/nodeToBlock.js"; import { defaultBlockToHTML } from "../../blocks/defaultBlockHelpers.js"; import type { ExtensionFactoryInstance } from "../../editor/BlockNoteExtension.js"; import { mergeCSSClasses } from "../../util/browser.js"; import { camelToDataKebab } from "../../util/string.js"; import { PropSchema, Props } from "../propTypes.js"; import { LooseBlockSpec } 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(getPos: () => number | undefined, doc: PMNode) { // TODO is there a cleaner implementation of this? Probably... const pos = getPos(); // Gets position of the node if (pos === undefined) { throw new Error("Cannot find node position"); } // Gets parent blockContainer node const blockContainer = doc.resolve(pos).node(); if (!blockContainer) { throw new Error("Cannot find block container"); } const block = nodeToBlock(blockContainer, doc); return block; } /** * Resolves the block a node view should render, tolerating a `getPos()` that * can't be trusted. * * ProseMirror derives `getPos()` from its view-desc tree, but * `EditorView.updateStateInner` assigns the new state *before* it reconciles * that tree. Anything that runs partway through reconciliation - a re-entrant * dispatch from a node view's effect, TipTap's `flushSync` while mounting a * node view - therefore sees positions that no longer line up with * `view.state.doc`. The position then either lands out of range or, just as * bad, in range but pointing at the wrong node. * * Node views are constructed inside that same window, so this applies at * construction just as much as on re-render. The state is always transient: * ProseMirror finishes reconciling and rebuilds the node view against the * current document immediately after. So we degrade instead of throwing, * because a stale frame is invisible where a throw is not. * * See issues #2937, #2682 and #2621. */ export function getBlockFromNodeView( getPos: () => number | undefined, node: PMNode, doc: PMNode, ) { try { return getBlockFromPos(getPos, doc); } catch (e) { // Failing here means the node is not in `doc` — a re-entrant dispatch // superseded the document ProseMirror is building node views for, and the // node went with it. So there is no container to read an id from, and the // block has to be built from the node alone. Deliberately silent: this is // expected and self-correcting, and there is nothing a consumer could do // about it in the meantime. // // `type`, `props` and `content` are read off the node and are correct. // `children` is empty and `id` is freshly generated, i.e. it belongs to no // block in the document — callers must not treat it as addressable. (It // can't collide with a real block: ids are uuids, and the deterministic // test-mode generator shares one monotonic counter with real ids.) This is // short-lived; ProseMirror rebuilds the node view against the real document // right after. // // The alternatives are worse. Throwing is the crash this exists to prevent. // Returning an empty placeholder node view can leave the block // *permanently* blank, since vanilla node views don't implement `update()` // and so are only rebuilt when something else changes the document. // // `createAndFill` rather than `create` so an unexpected node shape yields // `null` instead of throwing over the top of the original failure. const standalone = doc.type.schema.nodes["blockContainer"]?.createAndFill( null, node, ); if (standalone) { return nodeToBlock(standalone, doc); } // Nothing left to render from. Surface the original failure rather than // inventing a block that isn't grounded in anything. throw e; } } // 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]; if (value !== spec?.default) { 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" | "plain"; }, 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, }; }