UNPKG

@blocknote/core

Version:

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

411 lines (372 loc) 13.7 kB
import { Node } from "@tiptap/core"; import { DOMParser, Fragment, Node as ProsemirrorNode, Schema, TagParseRule, } from "@tiptap/pm/model"; import { inlineContentToNodes } from "../../api/nodeConversions/blockToNode.js"; import { nodeToCustomInlineContent } from "../../api/nodeConversions/nodeToBlock.js"; import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import { ignoreNonContentMutations } from "../nodeViewMutations.js"; import { propsToAttributes } from "../blocks/internal.js"; import { nonFormattingMarks } from "../markGroups.js"; import { Props } from "../propTypes.js"; import { StyleSchema } from "../styles/types.js"; import { addInlineContentAttributes, addInlineContentKeyboardShortcuts, createInlineContentSpecFromTipTapNode, } from "./internal.js"; import { CustomInlineContentConfig, InlineContentFromConfig, InlineContentSpec, PartialCustomInlineContentFromConfig, } from "./types.js"; export type CustomInlineContentImplementation< T extends CustomInlineContentConfig, S extends StyleSchema, > = { meta?: { draggable?: boolean; code?: boolean; /** * When {@link code} is `true`, this can syntax highlight the contents of the * inline content with the result of this callback. */ // Method syntax (rather than an arrow-function property) so its parameter is // checked bivariantly, keeping a specific implementation assignable to the // generic spec record type. highlight?( inlineContent: Pick<InlineContentFromConfig<T, S>, "type" | "props">, ): string | undefined; /** * Marks the inline content as rendering a preview with an editable source * popup, driven by the editor-wide * `SourceInlineContentWithPreviewExtension`. */ hasPreview?: boolean; }; /** * Parses an external HTML element into a inline content of this type when it returns the block props object, otherwise undefined */ parse?: (el: HTMLElement) => Partial<Props<T["propSchema"]>> | undefined; /** * Advanced parsing function that controls how the content within the inline * content is parsed. This is not recommended to use, and is only useful for * advanced use cases. Only applies to inline content with `content: "styled"`. * Return `undefined` to fall through to the default inline content parsing. */ parseContent?: (options: { el: HTMLElement; schema: Schema; }) => Fragment | undefined; /** * Renders an inline content to DOM elements */ render: ( /** * The custom inline content to render */ inlineContent: InlineContentFromConfig<T, S>, /** * A callback that allows overriding the inline content element */ updateInlineContent: ( update: PartialCustomInlineContentFromConfig<T, S>, ) => void, /** * 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<any, any, 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 /** * The ProseMirror node backing this inline content. */ node: ProsemirrorNode, /** * Returns this inline content's position in the document. When rendered * outside the editor (i.e. serialized to HTML), this is a no-op that returns * `undefined`. */ getPos: () => number | undefined, ) => { dom: HTMLElement; contentDOM?: HTMLElement; destroy?: () => void; }; /** * Renders an inline content to external HTML elements for use outside the editor * If not provided, falls back to the render method */ toExternalHTML?: ( /** * The custom inline content to render */ inlineContent: InlineContentFromConfig<T, 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<any, any, S>, ) => | { dom: HTMLElement | DocumentFragment; contentDOM?: HTMLElement; } | undefined; runsBefore?: string[]; }; // Resolves the element whose children hold the inline content's editable // content, i.e. the `[data-editable]` element (or the element itself if it is / // contains none). function getEditableElement(element: HTMLElement) { if (element.matches("[data-editable]")) { return element; } return element.querySelector<HTMLElement>("[data-editable]") || element; } // Parses an element's children as inline content. function parseInlineContent(el: HTMLElement, schema: Schema) { return DOMParser.fromSchema(schema).parse(el, { topNode: schema.nodes.paragraph.create(), preserveWhitespace: true, }).content; } // Flattens parsed inline content into text nodes only. "plain" inline content // holds text only, so non-text inline nodes are flattened: line breaks become // newline characters and other nodes (e.g. mentions) are kept as their text. function flattenToText(content: Fragment, schema: Schema) { const textNodes: ProsemirrorNode[] = []; content.forEach((child) => { if (child.isText) { textNodes.push(child); } else { const text = child.type === schema.linebreakReplacement ? "\n" : child.textContent; if (text) { textNodes.push(schema.text(text, child.marks)); } } }); return Fragment.fromArray(textNodes); } export function getInlineContentParseRules<C extends CustomInlineContentConfig>( config: C, customParseFunction?: CustomInlineContentImplementation<C, any>["parse"], customParseContentFunction?: CustomInlineContentImplementation< C, any >["parseContent"], ) { // When a custom `parseContent` function is provided (and this inline content // actually holds content), it controls how content within the inline content // is parsed. This applies to _both_ parse rules below, as content copied from // within the editor is tagged with `data-inline-content-type` (matched by the // first rule), while content pasted from outside is matched by the custom // `parse` function (the second rule). `resolveContentElement` locates the // element whose children to parse as a fallback when `parseContent` returns // `undefined`. // "plain" inline content always needs `getContent` so its parsed content is // flattened to text (`<br>`/line breaks become newline characters), regardless // of whether a custom `parseContent` is provided — mirroring "plain" blocks. // "styled" inline content only needs it to run a custom `parseContent`. const getContent = config.content === "plain" || (customParseContentFunction && config.content === "styled") ? (resolveContentElement: (el: HTMLElement) => HTMLElement) => (node: HTMLElement, schema: Schema) => { const result = customParseContentFunction?.({ el: node, schema }); // `parseContent` may return `undefined` to fall through to the // default inline content parsing. if (result !== undefined) { return config.content === "plain" ? flattenToText(result, schema) : result; } const parsed = parseInlineContent( resolveContentElement(node), schema, ); return config.content === "plain" ? flattenToText(parsed, schema) : parsed; } : undefined; const rules: TagParseRule[] = [ { tag: `[data-inline-content-type="${config.type}"]`, contentElement: (element) => getEditableElement(element as HTMLElement), getContent: getContent ? (node, schema) => getContent(getEditableElement)(node as HTMLElement, schema) : undefined, }, ]; 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; }, // Because we do the parsing ourselves, we want to preserve whitespace for // content we've parsed. preserveWhitespace: getContent ? true : undefined, getContent: getContent ? (node, schema) => getContent((el) => el)(node as HTMLElement, schema) : undefined, }); } return rules; } export function createInlineContentSpec< T extends CustomInlineContentConfig, S extends StyleSchema, >( inlineContentConfig: T, inlineContentImplementation: CustomInlineContentImplementation<T, S>, ): InlineContentSpec<T> { const node = Node.create({ name: inlineContentConfig.type, inline: true, group: "inline", draggable: inlineContentImplementation.meta?.draggable, selectable: inlineContentConfig.content !== "none", atom: inlineContentConfig.content === "none", code: inlineContentImplementation.meta?.code, content: inlineContentConfig.content === "styled" ? "inline*" : inlineContentConfig.content === "plain" ? "text*" : "", // "plain" inline content holds unstyled text, so it disallows formatting // marks (mirroring "plain" blocks). It still allows the non-formatting marks // (comments and suggestions/diffs), which annotate content without changing // it and are ignored by the content model. `nonFormattingMarks` resolves the // group only when at least one such mark is registered, so a plain inline // content in an editor without any of them doesn't reference an empty // (unknown) mark group. marks() { return inlineContentConfig.content === "plain" ? nonFormattingMarks(this.editor) : undefined; }, addAttributes() { return propsToAttributes(inlineContentConfig.propSchema); }, addKeyboardShortcuts() { return addInlineContentKeyboardShortcuts(inlineContentConfig); }, parseHTML() { return getInlineContentParseRules( inlineContentConfig, inlineContentImplementation.parse, inlineContentImplementation.parseContent, ); }, renderHTML({ node }) { const editor = this.options.editor; const output = inlineContentImplementation.render.call( { renderType: "dom", props: undefined }, nodeToCustomInlineContent( node, editor.schema.inlineContentSchema, editor.schema.styleSchema, ) as any as InlineContentFromConfig<T, S>, // TODO: fix cast () => { // No-op }, editor, node, () => undefined, ); return addInlineContentAttributes( output, inlineContentConfig.type, node.attrs as Props<T["propSchema"]>, inlineContentConfig.propSchema, ); }, addNodeView() { return (props) => { const { node, getPos } = props; const editor = this.options.editor as BlockNoteEditor<any, any, S>; const output = inlineContentImplementation.render.call( { renderType: "nodeView", props }, nodeToCustomInlineContent( node, editor.schema.inlineContentSchema, editor.schema.styleSchema, ) as any as InlineContentFromConfig<T, S>, // TODO: fix cast (update) => { const content = inlineContentToNodes([update], editor.pmSchema); const pos = getPos(); if (!pos) { return; } editor.transact((tr) => tr.replaceWith(pos, pos + node.nodeSize, content), ); }, editor, node, getPos, ); const nodeView = addInlineContentAttributes( output, inlineContentConfig.type, node.attrs as Props<T["propSchema"]>, inlineContentConfig.propSchema, ); // Ignores DOM mutations that don't affect the inline content, so that // browser extensions which rewrite the DOM (e.g. Dark Reader) can't // trigger an infinite re-render loop that freezes the tab. ignoreNonContentMutations(nodeView); return nodeView; }; }, }); return createInlineContentSpecFromTipTapNode( node, inlineContentConfig.propSchema, { ...inlineContentImplementation, toExternalHTML: inlineContentImplementation.toExternalHTML, render(inlineContent, updateInlineContent, editor) { // Rendered outside the editor (serialization), so there's no live node // view - derive the node from the content and stub out `getPos`. const node = inlineContentToNodes( [inlineContent] as any, editor.pmSchema, )[0]; const output = inlineContentImplementation.render( inlineContent, updateInlineContent, editor, node, () => undefined, ); return addInlineContentAttributes( output, inlineContentConfig.type, inlineContent.props, inlineContentConfig.propSchema, ); }, }, ) as InlineContentSpec<T>; }