@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
text/typescript
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>;
}