UNPKG

@blocknote/core

Version:

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

1,532 lines (1,382 loc) 58 kB
import { AnyExtension, EditorOptions, Extension, getSchema, isNodeSelection, Mark, posToDOMRect, Node as TipTapNode, } from "@tiptap/core"; import { Node, Schema } from "prosemirror-model"; // import "./blocknote.css"; import * as Y from "yjs"; import { insertBlocks } from "../api/blockManipulation/commands/insertBlocks/insertBlocks.js"; import { moveBlocksDown, moveBlocksUp, } from "../api/blockManipulation/commands/moveBlocks/moveBlocks.js"; import { canNestBlock, canUnnestBlock, nestBlock, unnestBlock, } from "../api/blockManipulation/commands/nestBlock/nestBlock.js"; import { removeAndInsertBlocks } from "../api/blockManipulation/commands/replaceBlocks/replaceBlocks.js"; import { updateBlock } from "../api/blockManipulation/commands/updateBlock/updateBlock.js"; import { getBlock, getNextBlock, getParentBlock, getPrevBlock, } from "../api/blockManipulation/getBlock/getBlock.js"; import { insertContentAt } from "../api/blockManipulation/insertContentAt.js"; import { getSelection, getSelectionCutBlocks, setSelection, } from "../api/blockManipulation/selections/selection.js"; import { getTextCursorPosition, setTextCursorPosition, } from "../api/blockManipulation/selections/textCursorPosition.js"; import { createExternalHTMLExporter } from "../api/exporters/html/externalHTMLExporter.js"; import { blocksToMarkdown } from "../api/exporters/markdown/markdownExporter.js"; import { HTMLToBlocks } from "../api/parsers/html/parseHTML.js"; import { markdownToBlocks, markdownToHTML, } from "../api/parsers/markdown/parseMarkdown.js"; import { Block, DefaultBlockSchema, DefaultInlineContentSchema, DefaultStyleSchema, PartialBlock, } from "../blocks/defaultBlocks.js"; import type { CommentsPlugin } from "../extensions/Comments/CommentsPlugin.js"; import { FilePanelProsemirrorPlugin } from "../extensions/FilePanel/FilePanelPlugin.js"; import { FormattingToolbarProsemirrorPlugin } from "../extensions/FormattingToolbar/FormattingToolbarPlugin.js"; import { LinkToolbarProsemirrorPlugin } from "../extensions/LinkToolbar/LinkToolbarPlugin.js"; import { ShowSelectionPlugin } from "../extensions/ShowSelection/ShowSelectionPlugin.js"; import { SideMenuProsemirrorPlugin } from "../extensions/SideMenu/SideMenuPlugin.js"; import { SuggestionMenuProseMirrorPlugin } from "../extensions/SuggestionMenu/SuggestionPlugin.js"; import { TableHandlesProsemirrorPlugin } from "../extensions/TableHandles/TableHandlesPlugin.js"; import { UniqueID } from "../extensions/UniqueID/UniqueID.js"; import { BlockIdentifier, BlockNoteDOMAttributes, BlockSchema, BlockSpecs, InlineContentSchema, InlineContentSpecs, PartialInlineContent, Styles, StyleSchema, StyleSpecs, } from "../schema/index.js"; import { mergeCSSClasses } from "../util/browser.js"; import { NoInfer, UnreachableCaseError } from "../util/typescript.js"; import { getBlockNoteExtensions } from "./BlockNoteExtensions.js"; import { TextCursorPosition } from "./cursorPositionTypes.js"; import { Selection } from "./selectionTypes.js"; import { transformPasted } from "./transformPasted.js"; import { checkDefaultBlockTypeInSchema } from "../blocks/defaultBlockTypeGuards.js"; import { BlockNoteSchema } from "./BlockNoteSchema.js"; import { BlockNoteTipTapEditor, BlockNoteTipTapEditorOptions, } from "./BlockNoteTipTapEditor.js"; import { Dictionary } from "../i18n/dictionary.js"; import { en } from "../i18n/locales/index.js"; import { redo, undo } from "@tiptap/pm/history"; import { TextSelection, type Command, type Plugin, type Transaction, } from "@tiptap/pm/state"; import { dropCursor } from "prosemirror-dropcursor"; import { EditorView } from "prosemirror-view"; import { redoCommand, undoCommand, ySyncPluginKey } from "y-prosemirror"; import { createInternalHTMLSerializer } from "../api/exporters/html/internalHTMLSerializer.js"; import { inlineContentToNodes } from "../api/nodeConversions/blockToNode.js"; import { docToBlocks } from "../api/nodeConversions/nodeToBlock.js"; import { BlocksChanged, getBlocksChangedByTransaction, } from "../api/nodeUtil.js"; import { nestedListsToBlockNoteStructure } from "../api/parsers/html/util/nestedLists.js"; import { CodeBlockOptions } from "../blocks/CodeBlockContent/CodeBlockContent.js"; import type { ThreadStore, User } from "../comments/index.js"; import type { CursorPlugin } from "../extensions/Collaboration/CursorPlugin.js"; import type { ForkYDocPlugin } from "../extensions/Collaboration/ForkYDocPlugin.js"; import { EventEmitter } from "../util/EventEmitter.js"; import { BlockNoteExtension } from "./BlockNoteExtension.js"; import "../style.css"; /** * A factory function that returns a BlockNoteExtension * This is useful so we can create extensions that require an editor instance * in the constructor */ export type BlockNoteExtensionFactory = ( editor: BlockNoteEditor<any, any, any>, ) => BlockNoteExtension; /** * We support Tiptap extensions and BlockNoteExtension based extensions */ export type SupportedExtension = AnyExtension | BlockNoteExtension; export type BlockCache< BSchema extends BlockSchema = any, ISchema extends InlineContentSchema = any, SSchema extends StyleSchema = any, > = WeakMap<Node, Block<BSchema, ISchema, SSchema>>; export type BlockNoteEditorOptions< BSchema extends BlockSchema, ISchema extends InlineContentSchema, SSchema extends StyleSchema, > = { /** * Whether changes to blocks (like indentation, creating lists, changing headings) should be animated or not. Defaults to `true`. * * @default true */ animations?: boolean; /** * When enabled, allows for collaboration between multiple users. */ collaboration: { /** * The Yjs XML fragment that's used for collaboration. */ fragment: Y.XmlFragment; /** * The user info for the current user that's shown to other collaborators. */ user: { name: string; color: string; }; /** * A Yjs provider (used for awareness / cursor information) */ provider: any; /** * Optional function to customize how cursors of users are rendered */ renderCursor?: (user: any) => HTMLElement; /** * Optional flag to set when the user label should be shown with the default * collaboration cursor. Setting to "always" will always show the label, * while "activity" will only show the label when the user moves the cursor * or types. Defaults to "activity". */ showCursorLabels?: "always" | "activity"; }; /** * Options for code blocks. */ codeBlock?: CodeBlockOptions; comments: { threadStore: ThreadStore; }; /** * Use default BlockNote font and reset the styles of <p> <li> <h1> elements etc., that are used in BlockNote. * * @default true */ defaultStyles: boolean; /** * A dictionary object containing translations for the editor. */ dictionary?: Dictionary & Record<string, any>; /** * Disable internal extensions (based on keys / extension name) */ disableExtensions: string[]; /** * An object containing attributes that should be added to HTML elements of the editor. * * @example { editor: { class: "my-editor-class" } } */ domAttributes: Partial<BlockNoteDOMAttributes>; dropCursor?: (opts: { editor: BlockNoteEditor< NoInfer<BSchema>, NoInfer<ISchema>, NoInfer<SSchema> >; color?: string | false; width?: number; class?: string; }) => Plugin; /** * The content that should be in the editor when it's created, represented as an array of partial block objects. */ initialContent: PartialBlock< NoInfer<BSchema>, NoInfer<ISchema>, NoInfer<SSchema> >[]; /** * @deprecated, provide placeholders via dictionary instead */ placeholders: Record< string | "default" | "emptyDocument", string | undefined >; /** * Custom paste handler that can be used to override the default paste behavior. * @returns The function should return `true` if the paste event was handled, otherwise it should return `false` if it should be canceled or `undefined` if it should be handled by another handler. * * @example * ```ts * pasteHandler: ({ defaultPasteHandler }) => { * return defaultPasteHandler({ pasteBehavior: "prefer-html" }); * } * ``` */ pasteHandler?: (context: { event: ClipboardEvent; editor: BlockNoteEditor<BSchema, ISchema, SSchema>; /** * The default paste handler * @param context The context object * @returns Whether the paste event was handled or not */ defaultPasteHandler: (context?: { /** * Whether to prioritize Markdown content in `text/plain` over `text/html` when pasting from the clipboard. * @default true */ prioritizeMarkdownOverHTML?: boolean; /** * Whether to parse `text/plain` content from the clipboard as Markdown content. * @default true */ plainTextAsMarkdown?: boolean; }) => boolean | undefined; }) => boolean | undefined; /** * Resolve a URL of a file block to one that can be displayed or downloaded. This can be used for creating authenticated URL or * implementing custom protocols / schemes * @returns The URL that's */ resolveFileUrl: (url: string) => Promise<string>; resolveUsers: (userIds: string[]) => Promise<User[]>; schema: BlockNoteSchema<BSchema, ISchema, SSchema>; /** * A flag indicating whether to set an HTML ID for every block * * When set to `true`, on each block an id attribute will be set with the block id * Otherwise, the HTML ID attribute will not be set. * * (note that the id is always set on the `data-id` attribute) */ setIdAttribute?: boolean; /** * The detection mode for showing the side menu - "viewport" always shows the * side menu for the block next to the mouse cursor, while "editor" only shows * it when hovering the editor or the side menu itself. * * @default "viewport" */ sideMenuDetection: "viewport" | "editor"; /** Select desired behavior when pressing `Tab` (or `Shift-Tab`). Specifically, what should happen when a user has selected multiple blocks while a toolbar is open: - `"prefer-navigate-ui"`: Change focus to the toolbar. The user needs to first press `Escape` to close the toolbar, and can then indent multiple blocks. Better for keyboard accessibility. - `"prefer-indent"`: Regardless of whether toolbars are open, indent the selection of blocks. In this case, it's not possible to navigate toolbars with the keyboard. @default "prefer-navigate-ui" */ tabBehavior: "prefer-navigate-ui" | "prefer-indent"; /** * Allows enabling / disabling features of tables. */ tables?: { /** * Whether to allow splitting and merging cells within a table. * * @default false */ splitCells?: boolean; /** * Whether to allow changing the background color of cells. * * @default false */ cellBackgroundColor?: boolean; /** * Whether to allow changing the text color of cells. * * @default false */ cellTextColor?: boolean; /** * Whether to allow changing cells into headers. * * @default false */ headers?: boolean; }; trailingBlock?: boolean; /** * The `uploadFile` method is what the editor uses when files need to be uploaded (for example when selecting an image to upload). * This method should set when creating the editor as this is application-specific. * * `undefined` means the application doesn't support file uploads. * * @param file The file that should be uploaded. * @returns The URL of the uploaded file OR an object containing props that should be set on the file block (such as an id) */ uploadFile: ( file: File, blockId?: string, ) => Promise<string | Record<string, any>>; /** * additional tiptap options, undocumented */ _tiptapOptions: Partial<EditorOptions>; /** * (experimental) add extra extensions to the editor * * @deprecated, should use `extensions` instead */ _extensions: Record< string, | { plugin: Plugin; priority?: number } | ((editor: BlockNoteEditor<any, any, any>) => { plugin: Plugin; priority?: number; }) >; /** * Register */ extensions: Array<BlockNoteExtension | BlockNoteExtensionFactory>; /** * Boolean indicating whether the editor is in headless mode. * Headless mode means we can use features like importing / exporting blocks, * but there's no underlying editor (UI) instantiated. * * You probably don't need to set this manually, but use the `server-util` package instead that uses this option internally */ _headless: boolean; }; const blockNoteTipTapOptions = { enableInputRules: true, enablePasteRules: true, enableCoreExtensions: false, }; export class BlockNoteEditor< BSchema extends BlockSchema = DefaultBlockSchema, ISchema extends InlineContentSchema = DefaultInlineContentSchema, SSchema extends StyleSchema = DefaultStyleSchema, > extends EventEmitter<{ create: void; }> { /** * The underlying prosemirror schema */ public readonly pmSchema: Schema; /** * extensions that are added to the editor, can be tiptap extensions or prosemirror plugins */ public extensions: Record<string, SupportedExtension> = {}; /** * Boolean indicating whether the editor is in headless mode. * Headless mode means we can use features like importing / exporting blocks, * but there's no underlying editor (UI) instantiated. * * You probably don't need to set this manually, but use the `server-util` package instead that uses this option internally */ public readonly headless: boolean = false; public readonly _tiptapEditor: Omit<BlockNoteTipTapEditor, "view"> & { view: EditorView | undefined; contentComponent: any; } = undefined as any; // TODO: Type should actually reflect that it can be `undefined` in headless mode /** * Used by React to store a reference to an `ElementRenderer` helper utility to make sure we can render React elements * in the correct context (used by `ReactRenderUtil`) */ public elementRenderer: ((node: any, container: HTMLElement) => void) | null = null; /** * Cache of all blocks. This makes sure we don't have to "recompute" blocks if underlying Prosemirror Nodes haven't changed. * This is especially useful when we want to keep track of the same block across multiple operations, * with this cache, blocks stay the same object reference (referential equality with ===). */ public blockCache: BlockCache = new WeakMap(); /** * The dictionary contains translations for the editor. */ public readonly dictionary: Dictionary & Record<string, any>; /** * The schema of the editor. The schema defines which Blocks, InlineContent, and Styles are available in the editor. */ public readonly schema: BlockNoteSchema<BSchema, ISchema, SSchema>; public readonly blockImplementations: BlockSpecs; public readonly inlineContentImplementations: InlineContentSpecs; public readonly styleImplementations: StyleSpecs; public readonly formattingToolbar: FormattingToolbarProsemirrorPlugin; public readonly linkToolbar: LinkToolbarProsemirrorPlugin< BSchema, ISchema, SSchema >; public readonly sideMenu: SideMenuProsemirrorPlugin< BSchema, ISchema, SSchema >; public readonly suggestionMenus: SuggestionMenuProseMirrorPlugin< BSchema, ISchema, SSchema >; public readonly filePanel?: FilePanelProsemirrorPlugin<ISchema, SSchema>; public readonly tableHandles?: TableHandlesProsemirrorPlugin< ISchema, SSchema >; public readonly comments?: CommentsPlugin; private readonly showSelectionPlugin: ShowSelectionPlugin; /** * The plugin for forking a document, only defined if in collaboration mode */ public readonly forkYDocPlugin?: ForkYDocPlugin; /** * The `uploadFile` method is what the editor uses when files need to be uploaded (for example when selecting an image to upload). * This method should set when creating the editor as this is application-specific. * * `undefined` means the application doesn't support file uploads. * * @param file The file that should be uploaded. * @returns The URL of the uploaded file OR an object containing props that should be set on the file block (such as an id) */ public readonly uploadFile: | ((file: File, blockId?: string) => Promise<string | Record<string, any>>) | undefined; private onUploadStartCallbacks: ((blockId?: string) => void)[] = []; private onUploadEndCallbacks: ((blockId?: string) => void)[] = []; public readonly resolveFileUrl?: (url: string) => Promise<string>; public readonly resolveUsers?: (userIds: string[]) => Promise<User[]>; /** * Editor settings */ public readonly settings: { tables: { splitCells: boolean; cellBackgroundColor: boolean; cellTextColor: boolean; headers: boolean; }; codeBlock: CodeBlockOptions; }; public static create< BSchema extends BlockSchema = DefaultBlockSchema, ISchema extends InlineContentSchema = DefaultInlineContentSchema, SSchema extends StyleSchema = DefaultStyleSchema, >(options: Partial<BlockNoteEditorOptions<BSchema, ISchema, SSchema>> = {}) { return new BlockNoteEditor<BSchema, ISchema, SSchema>(options); } protected constructor( protected readonly options: Partial<BlockNoteEditorOptions<any, any, any>>, ) { super(); const anyOpts = options as any; if (anyOpts.onEditorContentChange) { throw new Error( "onEditorContentChange initialization option is deprecated, use <BlockNoteView onChange={...} />, the useEditorChange(...) hook, or editor.onChange(...)", ); } if (anyOpts.onTextCursorPositionChange) { throw new Error( "onTextCursorPositionChange initialization option is deprecated, use <BlockNoteView onSelectionChange={...} />, the useEditorSelectionChange(...) hook, or editor.onSelectionChange(...)", ); } if (anyOpts.onEditorReady) { throw new Error( "onEditorReady is deprecated. Editor is immediately ready for use after creation.", ); } if (anyOpts.editable) { throw new Error( "editable initialization option is deprecated, use <BlockNoteView editable={true/false} />, or alternatively editor.isEditable = true/false", ); } this.dictionary = options.dictionary || en; this.settings = { tables: { splitCells: options?.tables?.splitCells ?? false, cellBackgroundColor: options?.tables?.cellBackgroundColor ?? false, cellTextColor: options?.tables?.cellTextColor ?? false, headers: options?.tables?.headers ?? false, }, codeBlock: { indentLineWithTab: options?.codeBlock?.indentLineWithTab ?? true, defaultLanguage: options?.codeBlock?.defaultLanguage ?? "text", supportedLanguages: options?.codeBlock?.supportedLanguages ?? {}, createHighlighter: options?.codeBlock?.createHighlighter ?? undefined, }, }; // apply defaults const newOptions = { defaultStyles: true, schema: options.schema || BlockNoteSchema.create(), _headless: false, ...options, placeholders: { ...this.dictionary.placeholders, ...options.placeholders, }, }; if (newOptions.comments && !newOptions.resolveUsers) { throw new Error("resolveUsers is required when using comments"); } this.resolveUsers = newOptions.resolveUsers; // @ts-ignore this.schema = newOptions.schema; this.blockImplementations = newOptions.schema.blockSpecs; this.inlineContentImplementations = newOptions.schema.inlineContentSpecs; this.styleImplementations = newOptions.schema.styleSpecs; this.extensions = getBlockNoteExtensions({ editor: this, domAttributes: newOptions.domAttributes || {}, blockSpecs: this.schema.blockSpecs, styleSpecs: this.schema.styleSpecs, inlineContentSpecs: this.schema.inlineContentSpecs, collaboration: newOptions.collaboration, trailingBlock: newOptions.trailingBlock, disableExtensions: newOptions.disableExtensions, setIdAttribute: newOptions.setIdAttribute, animations: newOptions.animations ?? true, tableHandles: checkDefaultBlockTypeInSchema("table", this), dropCursor: this.options.dropCursor ?? dropCursor, placeholders: newOptions.placeholders, tabBehavior: newOptions.tabBehavior, sideMenuDetection: newOptions.sideMenuDetection || "viewport", comments: newOptions.comments, pasteHandler: newOptions.pasteHandler, }); // add extensions from _tiptapOptions (newOptions._tiptapOptions?.extensions || []).forEach((ext) => { this.extensions[ext.name] = ext; }); // add extensions from options for (let ext of newOptions.extensions || []) { if (typeof ext === "function") { // factory ext = ext(this); } const key = (ext.constructor as any).key(); if (!key) { throw new Error( `Extension ${ext.constructor.name} does not have a key method`, ); } if (this.extensions[key]) { throw new Error( `Extension ${ext.constructor.name} already exists with key ${key}`, ); } this.extensions[key] = ext; } // (when passed in via the deprecated `_extensions` option) Object.entries(newOptions._extensions || {}).forEach(([key, ext]) => { // eslint-disable-next-line @typescript-eslint/no-this-alias const editor = this; const instance = typeof ext === "function" ? ext(editor) : ext; if (!("plugin" in instance)) { // Assume it is an Extension/Mark/Node this.extensions[key] = instance; return; } this.extensions[key] = new (class extends BlockNoteExtension { public static key() { return key; } constructor() { super(); this.addProsemirrorPlugin(instance.plugin); } public get priority() { return instance.priority; } })(); }); this.formattingToolbar = this.extensions["formattingToolbar"] as any; this.linkToolbar = this.extensions["linkToolbar"] as any; this.sideMenu = this.extensions["sideMenu"] as any; this.suggestionMenus = this.extensions["suggestionMenus"] as any; this.filePanel = this.extensions["filePanel"] as any; this.tableHandles = this.extensions["tableHandles"] as any; this.comments = this.extensions["comments"] as any; this.showSelectionPlugin = this.extensions["showSelection"] as any; this.forkYDocPlugin = this.extensions["forkYDocPlugin"] as any; if (newOptions.uploadFile) { const uploadFile = newOptions.uploadFile; this.uploadFile = async (file, blockId) => { this.onUploadStartCallbacks.forEach((callback) => callback.apply(this, [blockId]), ); try { return await uploadFile(file, blockId); } finally { this.onUploadEndCallbacks.forEach((callback) => callback.apply(this, [blockId]), ); } }; } this.resolveFileUrl = newOptions.resolveFileUrl; this.headless = newOptions._headless; const collaborationEnabled = "ySyncPlugin" in this.extensions || "liveblocksExtension" in this.extensions; if (collaborationEnabled && newOptions.initialContent) { // eslint-disable-next-line no-console console.warn( "When using Collaboration, initialContent might cause conflicts, because changes should come from the collaboration provider", ); } const initialContent = newOptions.initialContent || (collaborationEnabled ? [ { type: "paragraph", id: "initialBlockId", }, ] : [ { type: "paragraph", id: UniqueID.options.generateID(), }, ]); if (!Array.isArray(initialContent) || initialContent.length === 0) { throw new Error( "initialContent must be a non-empty array of blocks, received: " + initialContent, ); } const tiptapExtensions = [ ...Object.entries(this.extensions).map(([key, ext]) => { if ( ext instanceof Extension || ext instanceof TipTapNode || ext instanceof Mark ) { // tiptap extension return ext; } if (ext instanceof BlockNoteExtension && !ext.plugins.length) { return undefined; } // "blocknote" extensions (prosemirror plugins) return Extension.create({ name: key, priority: ext.priority, addProseMirrorPlugins: () => ext.plugins, }); }), ].filter((ext): ext is Extension => ext !== undefined); const tiptapOptions: BlockNoteTipTapEditorOptions = { ...blockNoteTipTapOptions, ...newOptions._tiptapOptions, content: initialContent, extensions: tiptapExtensions, editorProps: { ...newOptions._tiptapOptions?.editorProps, attributes: { // As of TipTap v2.5.0 the tabIndex is removed when the editor is not // editable, so you can't focus it. We want to revert this as we have // UI behaviour that relies on it. tabIndex: "0", ...newOptions._tiptapOptions?.editorProps?.attributes, ...newOptions.domAttributes?.editor, class: mergeCSSClasses( "bn-editor", newOptions.defaultStyles ? "bn-default-styles" : "", newOptions.domAttributes?.editor?.class || "", ), }, transformPasted, }, }; if (!this.headless) { this._tiptapEditor = BlockNoteTipTapEditor.create( tiptapOptions, this.schema.styleSchema, ) as BlockNoteTipTapEditor & { view: any; contentComponent: any; }; this.pmSchema = this._tiptapEditor.schema; } else { // In headless mode, we don't instantiate an underlying TipTap editor, // but we still need the schema this.pmSchema = getSchema(tiptapOptions.extensions!); } this.pmSchema.cached.blockNoteEditor = this; this.emit("create"); } /** * Stores the currently active transaction, which is the accumulated transaction from all {@link dispatch} calls during a {@link transact} calls */ private activeTransaction: Transaction | null = null; /** * Execute a prosemirror command. This is mostly for backwards compatibility with older code. * * @note You should prefer the {@link transact} method when possible, as it will automatically handle the dispatching of the transaction and work across blocknote transactions. * * @example * ```ts * editor.exec((state, dispatch, view) => { * dispatch(state.tr.insertText("Hello, world!")); * }); * ``` */ public exec(command: Command) { if (this.activeTransaction) { throw new Error( "`exec` should not be called within a `transact` call, move the `exec` call outside of the `transact` call", ); } const state = this._tiptapEditor.state; const view = this._tiptapEditor.view; const dispatch = (tr: Transaction) => this._tiptapEditor.dispatch(tr); return command(state, dispatch, view); } /** * Check if a command can be executed. A command should return `false` if it is not valid in the current state. * * @example * ```ts * if (editor.canExec(command)) { * // show button * } else { * // hide button * } * ``` */ public canExec(command: Command): boolean { if (this.activeTransaction) { throw new Error( "`canExec` should not be called within a `transact` call, move the `canExec` call outside of the `transact` call", ); } const state = this._tiptapEditor.state; const view = this._tiptapEditor.view; return command(state, undefined, view); } /** * Execute a function within a "blocknote transaction". * All changes to the editor within the transaction will be grouped together, so that * we can dispatch them as a single operation (thus creating only a single undo step) * * @note There is no need to dispatch the transaction, as it will be automatically dispatched when the callback is complete. * * @example * ```ts * // All changes to the editor will be grouped together * editor.transact((tr) => { * tr.insertText("Hello, world!"); * // These two operations will be grouped together in a single undo step * editor.transact((tr) => { * tr.insertText("Hello, world!"); * }); * }); * ``` */ public transact<T>( callback: ( /** * The current active transaction, this will automatically be dispatched to the editor when the callback is complete * If another `transact` call is made within the callback, it will be passed the same transaction as the parent call. */ tr: Transaction, ) => T, ): T { if (this.activeTransaction) { // Already in a transaction, so we can just callback immediately return callback(this.activeTransaction); } try { // Enter transaction mode, by setting a starting transaction this.activeTransaction = this._tiptapEditor.state.tr; // Capture all dispatch'd transactions const result = callback(this.activeTransaction); // Any transactions captured by the `dispatch` call will be stored in `this.activeTransaction` const activeTr = this.activeTransaction; this.activeTransaction = null; if ( activeTr && // Only dispatch if the transaction was actually modified in some way (activeTr.docChanged || activeTr.selectionSet || activeTr.scrolledIntoView || activeTr.storedMarksSet || !activeTr.isGeneric) ) { // Dispatch the transaction if it was modified this._tiptapEditor.dispatch(activeTr); } return result; } finally { // We wrap this in a finally block to ensure we don't disable future transactions just because of an error in the callback this.activeTransaction = null; } } // TO DISCUSS /** * Shorthand to get a typed extension from the editor, by * just passing in the extension class. * * @param ext - The extension class to get * @param key - optional, the key of the extension in the extensions object (defaults to the extension name) * @returns The extension instance */ public extension<T extends BlockNoteExtension>( ext: { new (...args: any[]): T } & typeof BlockNoteExtension, key = ext.key(), ): T { const extension = this.extensions[key] as T; if (!extension) { throw new Error(`Extension ${key} not found`); } return extension; } /** * Mount the editor to a parent DOM element. Call mount(undefined) to clean up * * @warning Not needed to call manually when using React, use BlockNoteView to take care of mounting */ public mount = ( parentElement?: HTMLElement | null, contentComponent?: any, ) => { this._tiptapEditor.mount(this, parentElement, contentComponent); }; /** * Get the underlying prosemirror state * @note Prefer using `editor.transact` to read the current editor state, as that will ensure the state is up to date * @see https://prosemirror.net/docs/ref/#state.EditorState */ public get prosemirrorState() { if (this.activeTransaction) { throw new Error( "`prosemirrorState` should not be called within a `transact` call, move the `prosemirrorState` call outside of the `transact` call or use `editor.transact` to read the current editor state", ); } return this._tiptapEditor.state; } /** * Get the underlying prosemirror view * @see https://prosemirror.net/docs/ref/#view.EditorView */ public get prosemirrorView() { return this._tiptapEditor.view; } public get domElement() { return this.prosemirrorView?.dom as HTMLDivElement | undefined; } public isFocused() { return this.prosemirrorView?.hasFocus() || false; } public focus() { this.prosemirrorView?.focus(); } public onUploadStart(callback: (blockId?: string) => void) { this.onUploadStartCallbacks.push(callback); return () => { const index = this.onUploadStartCallbacks.indexOf(callback); if (index > -1) { this.onUploadStartCallbacks.splice(index, 1); } }; } public onUploadEnd(callback: (blockId?: string) => void) { this.onUploadEndCallbacks.push(callback); return () => { const index = this.onUploadEndCallbacks.indexOf(callback); if (index > -1) { this.onUploadEndCallbacks.splice(index, 1); } }; } /** * @deprecated, use `editor.document` instead */ public get topLevelBlocks(): Block<BSchema, ISchema, SSchema>[] { return this.document; } /** * Gets a snapshot of all top-level (non-nested) blocks in the editor. * @returns A snapshot of all top-level (non-nested) blocks in the editor. */ public get document(): Block<BSchema, ISchema, SSchema>[] { return this.transact((tr) => { return docToBlocks(tr.doc, this.pmSchema); }); } /** * Gets a snapshot of an existing block from the editor. * @param blockIdentifier The identifier of an existing block that should be * retrieved. * @returns The block that matches the identifier, or `undefined` if no * matching block was found. */ public getBlock( blockIdentifier: BlockIdentifier, ): Block<BSchema, ISchema, SSchema> | undefined { return this.transact((tr) => getBlock(tr.doc, blockIdentifier)); } /** * Gets a snapshot of the previous sibling of an existing block from the * editor. * @param blockIdentifier The identifier of an existing block for which the * previous sibling should be retrieved. * @returns The previous sibling of the block that matches the identifier. * `undefined` if no matching block was found, or it's the first child/block * in the document. */ public getPrevBlock( blockIdentifier: BlockIdentifier, ): Block<BSchema, ISchema, SSchema> | undefined { return this.transact((tr) => getPrevBlock(tr.doc, blockIdentifier)); } /** * Gets a snapshot of the next sibling of an existing block from the editor. * @param blockIdentifier The identifier of an existing block for which the * next sibling should be retrieved. * @returns The next sibling of the block that matches the identifier. * `undefined` if no matching block was found, or it's the last child/block in * the document. */ public getNextBlock( blockIdentifier: BlockIdentifier, ): Block<BSchema, ISchema, SSchema> | undefined { return this.transact((tr) => getNextBlock(tr.doc, blockIdentifier)); } /** * Gets a snapshot of the parent of an existing block from the editor. * @param blockIdentifier The identifier of an existing block for which the * parent should be retrieved. * @returns The parent of the block that matches the identifier. `undefined` * if no matching block was found, or the block isn't nested. */ public getParentBlock( blockIdentifier: BlockIdentifier, ): Block<BSchema, ISchema, SSchema> | undefined { return this.transact((tr) => getParentBlock(tr.doc, blockIdentifier)); } /** * Traverses all blocks in the editor depth-first, and executes a callback for each. * @param callback The callback to execute for each block. Returning `false` stops the traversal. * @param reverse Whether the blocks should be traversed in reverse order. */ public forEachBlock( callback: (block: Block<BSchema, ISchema, SSchema>) => boolean, reverse = false, ): void { const blocks = this.document.slice(); if (reverse) { blocks.reverse(); } function traverseBlockArray( blockArray: Block<BSchema, ISchema, SSchema>[], ): boolean { for (const block of blockArray) { if (callback(block) === false) { return false; } const children = reverse ? block.children.slice().reverse() : block.children; if (!traverseBlockArray(children)) { return false; } } return true; } traverseBlockArray(blocks); } /** * Executes a callback whenever the editor's contents change. * @param callback The callback to execute. * * @deprecated use {@link BlockNoteEditor.onChange} instead */ public onEditorContentChange(callback: () => void) { this._tiptapEditor.on("update", callback); } /** * Executes a callback whenever the editor's selection changes. * @param callback The callback to execute. * * @deprecated use `onSelectionChange` instead */ public onEditorSelectionChange(callback: () => void) { this._tiptapEditor.on("selectionUpdate", callback); } /** * Gets a snapshot of the current text cursor position. * @returns A snapshot of the current text cursor position. */ public getTextCursorPosition(): TextCursorPosition< BSchema, ISchema, SSchema > { return this.transact((tr) => getTextCursorPosition(tr)); } /** * Sets the text cursor position to the start or end of an existing block. Throws an error if the target block could * not be found. * @param targetBlock The identifier of an existing block that the text cursor should be moved to. * @param placement Whether the text cursor should be placed at the start or end of the block. */ public setTextCursorPosition( targetBlock: BlockIdentifier, placement: "start" | "end" = "start", ) { return this.transact((tr) => setTextCursorPosition(tr, targetBlock, placement), ); } /** * Gets a snapshot of the current selection. This contains all blocks (included nested blocks) * that the selection spans across. * * If the selection starts / ends halfway through a block, the returned data will contain the entire block. */ public getSelection(): Selection<BSchema, ISchema, SSchema> | undefined { return this.transact((tr) => getSelection(tr)); } /** * Gets a snapshot of the current selection. This contains all blocks (included nested blocks) * that the selection spans across. * * If the selection starts / ends halfway through a block, the returned block will be * only the part of the block that is included in the selection. */ public getSelectionCutBlocks() { return this.transact((tr) => getSelectionCutBlocks(tr)); } /** * Sets the selection to a range of blocks. * @param startBlock The identifier of the block that should be the start of the selection. * @param endBlock The identifier of the block that should be the end of the selection. */ public setSelection(startBlock: BlockIdentifier, endBlock: BlockIdentifier) { return this.transact((tr) => setSelection(tr, startBlock, endBlock)); } /** * Checks if the editor is currently editable, or if it's locked. * @returns True if the editor is editable, false otherwise. */ public get isEditable(): boolean { if (!this._tiptapEditor) { if (!this.headless) { throw new Error("no editor, but also not headless?"); } return false; } return this._tiptapEditor.isEditable === undefined ? true : this._tiptapEditor.isEditable; } /** * Makes the editor editable or locks it, depending on the argument passed. * @param editable True to make the editor editable, or false to lock it. */ public set isEditable(editable: boolean) { if (!this._tiptapEditor) { if (!this.headless) { throw new Error("no editor, but also not headless?"); } // not relevant on headless return; } if (this._tiptapEditor.options.editable !== editable) { this._tiptapEditor.setEditable(editable); } } /** * Inserts new blocks into the editor. If a block's `id` is undefined, BlockNote generates one automatically. Throws an * error if the reference block could not be found. * @param blocksToInsert An array of partial blocks that should be inserted. * @param referenceBlock An identifier for an existing block, at which the new blocks should be inserted. * @param placement Whether the blocks should be inserted just before, just after, or nested inside the * `referenceBlock`. */ public insertBlocks( blocksToInsert: PartialBlock<BSchema, ISchema, SSchema>[], referenceBlock: BlockIdentifier, placement: "before" | "after" = "before", ) { return this.transact((tr) => insertBlocks(tr, blocksToInsert, referenceBlock, placement), ); } /** * Updates an existing block in the editor. Since updatedBlock is a PartialBlock object, some fields might not be * defined. These undefined fields are kept as-is from the existing block. Throws an error if the block to update could * not be found. * @param blockToUpdate The block that should be updated. * @param update A partial block which defines how the existing block should be changed. */ public updateBlock( blockToUpdate: BlockIdentifier, update: PartialBlock<BSchema, ISchema, SSchema>, ) { return this.transact((tr) => updateBlock(tr, blockToUpdate, update)); } /** * Removes existing blocks from the editor. Throws an error if any of the blocks could not be found. * @param blocksToRemove An array of identifiers for existing blocks that should be removed. */ public removeBlocks(blocksToRemove: BlockIdentifier[]) { return this.transact( (tr) => removeAndInsertBlocks(tr, blocksToRemove, []).removedBlocks, ); } /** * Replaces existing blocks in the editor with new blocks. If the blocks that should be removed are not adjacent or * are at different nesting levels, `blocksToInsert` will be inserted at the position of the first block in * `blocksToRemove`. Throws an error if any of the blocks to remove could not be found. * @param blocksToRemove An array of blocks that should be replaced. * @param blocksToInsert An array of partial blocks to replace the old ones with. */ public replaceBlocks( blocksToRemove: BlockIdentifier[], blocksToInsert: PartialBlock<BSchema, ISchema, SSchema>[], ) { return this.transact((tr) => removeAndInsertBlocks(tr, blocksToRemove, blocksToInsert), ); } /** * Undo the last action. */ public undo() { if (this.options.collaboration) { return this.exec(undoCommand); } return this.exec(undo); } /** * Redo the last action. */ public redo() { if (this.options.collaboration) { return this.exec(redoCommand); } return this.exec(redo); } /** * Insert a piece of content at the current cursor position. * * @param content can be a string, or array of partial inline content elements */ public insertInlineContent( content: PartialInlineContent<ISchema, SSchema>, { updateSelection = false }: { updateSelection?: boolean } = {}, ) { const nodes = inlineContentToNodes(content, this.pmSchema); this.transact((tr) => { insertContentAt( tr, { from: tr.selection.from, to: tr.selection.to, }, nodes, { updateSelection, }, ); }); } /** * Gets the active text styles at the text cursor position or at the end of the current selection if it's active. */ public getActiveStyles() { return this.transact((tr) => { const styles: Styles<SSchema> = {}; const marks = tr.selection.$to.marks(); for (const mark of marks) { const config = this.schema.styleSchema[mark.type.name]; if (!config) { if ( // Links are not considered styles in blocknote mark.type.name !== "link" && // "blocknoteIgnore" tagged marks (such as comments) are also not considered BlockNote "styles" !mark.type.spec.blocknoteIgnore ) { // eslint-disable-next-line no-console console.warn("mark not found in styleschema", mark.type.name); } continue; } if (config.propSchema === "boolean") { (styles as any)[config.type] = true; } else { (styles as any)[config.type] = mark.attrs.stringValue; } } return styles; }); } /** * Adds styles to the currently selected content. * @param styles The styles to add. */ public addStyles(styles: Styles<SSchema>) { for (const [style, value] of Object.entries(styles)) { const config = this.schema.styleSchema[style]; if (!config) { throw new Error(`style ${style} not found in styleSchema`); } if (config.propSchema === "boolean") { this._tiptapEditor.commands.setMark(style); } else if (config.propSchema === "string") { this._tiptapEditor.commands.setMark(style, { stringValue: value }); } else { throw new UnreachableCaseError(config.propSchema); } } } /** * Removes styles from the currently selected content. * @param styles The styles to remove. */ public removeStyles(styles: Styles<SSchema>) { for (const style of Object.keys(styles)) { this._tiptapEditor.commands.unsetMark(style); } } /** * Toggles styles on the currently selected content. * @param styles The styles to toggle. */ public toggleStyles(styles: Styles<SSchema>) { for (const [style, value] of Object.entries(styles)) { const config = this.schema.styleSchema[style]; if (!config) { throw new Error(`style ${style} not found in styleSchema`); } if (config.propSchema === "boolean") { this._tiptapEditor.commands.toggleMark(style); } else if (config.propSchema === "string") { this._tiptapEditor.commands.toggleMark(style, { stringValue: value }); } else { throw new UnreachableCaseError(config.propSchema); } } } /** * Gets the currently selected text. */ public getSelectedText() { return this.transact((tr) => { return tr.doc.textBetween(tr.selection.from, tr.selection.to); }); } /** * Gets the URL of the last link in the current selection, or `undefined` if there are no links in the selection. */ public getSelectedLinkUrl() { return this._tiptapEditor.getAttributes("link").href as string | undefined; } /** * Creates a new link to replace the selected content. * @param url The link URL. * @param text The text to display the link with. */ public createLink(url: string, text?: string) { if (url === "") { return; } const mark = this.pmSchema.mark("link", { href: url }); this.transact((tr) => { const { from, to } = tr.selection; if (text) { tr.insertText(text, from, to).addMark(from, from + text.length, mark); } else { tr.setSelection(TextSelection.create(tr.doc, to)).addMark( from, to, mark, ); } }); } /** * Checks if the block containing the text cursor can be nested. */ public canNestBlock() { return canNestBlock(this); } /** * Nests the block containing the text cursor into the block above it. */ public nestBlock() { nestBlock(this); } /** * Checks if the block containing the text cursor is nested. */ public canUnnestBlock() { return canUnnestBlock(this); } /** * Lifts the block containing the text cursor out of its parent. */ public unnestBlock() { unnestBlock(this); } /** * Moves the selected blocks up. If the previous block has children, moves * them to the end of its children. If there is no previous block, but the * current blocks share a common parent, moves them out of & before it. */ public moveBlocksUp() { return moveBlocksUp(this); } /** * Moves the selected blocks down. If the next block has children, moves * them to the start of its children. If there is no next block, but the * current blocks share a common parent, moves them out of & after it. */ public moveBlocksDown() { return moveBlocksDown(this); } /** * Exports blocks into a simplified HTML string. To better conform to HTML standards, children of blocks which aren't list * items are un-nested in the output HTML. * * @param blocks An array of blocks that should be serialized into HTML. * @returns The blocks, serialized as an HTML string. */ public async blocksToHTMLLossy( blocks: PartialBlock<BSchema, ISchema, SSchema>[] = this.document, ): Promise<string> { const exporter = createExternalHTMLExporter(this.pmSchema, this); return exporter.exportBlocks(blocks, {}); } /** * Serializes blocks into an HTML string in the format that would normally be rendered by the editor. * * Use this method if you want to server-side render HTML (for example, a blog post that has been edited in BlockNote) * and serve it to users without loading the editor on the client (i.e.: displaying the blog post) * * @param blocks An array of blocks that should be serialized into HTML. * @returns The blocks, serialized as an HTML