diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index bb85302e7f..e0d3c2258d 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -5,91 +5,38 @@ import { Extension, getSchema, InputRule, - isNodeSelection, Mark, - posToDOMRect, Editor as TiptapEditor, Node as TipTapNode, } from "@tiptap/core"; -import { redo, undo } from "@tiptap/pm/history"; -import { - TextSelection, - type Command, - type Plugin, - type Transaction, -} from "@tiptap/pm/state"; +import { type Command, type Plugin, type Transaction } from "@tiptap/pm/state"; import { dropCursor } from "prosemirror-dropcursor"; import { Node, Schema } from "prosemirror-model"; -import { redoCommand, undoCommand, ySyncPluginKey } from "y-prosemirror"; 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, - updateBlockTr, -} 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 { createInternalHTMLSerializer } from "../api/exporters/html/internalHTMLSerializer.js"; -import { blocksToMarkdown } from "../api/exporters/markdown/markdownExporter.js"; -import { getBlockInfoFromTransaction } from "../api/getBlockInfoFromPos.js"; -import { - BlocksChanged, - getBlocksChangedByTransaction, -} from "../api/getBlocksChangedByTransaction.js"; -import { - blockToNode, - inlineContentToNodes, -} from "../api/nodeConversions/blockToNode.js"; -import { docToBlocks } from "../api/nodeConversions/nodeToBlock.js"; -import { HTMLToBlocks } from "../api/parsers/html/parseHTML.js"; -import { - markdownToBlocks, - markdownToHTML, -} from "../api/parsers/markdown/parseMarkdown.js"; +import type { BlocksChanged } from "../api/getBlocksChangedByTransaction.js"; import { editorHasBlockWithType } from "../blocks/defaultBlockTypeGuards.js"; +import { + Block, + BlockNoteSchema, + DefaultBlockSchema, + DefaultInlineContentSchema, + DefaultStyleSchema, + PartialBlock, +} from "../blocks/index.js"; import type { ThreadStore, User } from "../comments/index.js"; -import { BlockChangePlugin } from "../extensions/BlockChange/BlockChangePlugin.js"; -import type { CursorPlugin } from "../extensions/Collaboration/CursorPlugin.js"; -import type { ForkYDocPlugin } from "../extensions/Collaboration/ForkYDocPlugin.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 type { FilePanelProsemirrorPlugin } from "../extensions/FilePanel/FilePanelPlugin.js"; +import type { FormattingToolbarProsemirrorPlugin } from "../extensions/FormattingToolbar/FormattingToolbarPlugin.js"; +import type { LinkToolbarProsemirrorPlugin } from "../extensions/LinkToolbar/LinkToolbarPlugin.js"; +import type { ShowSelectionPlugin } from "../extensions/ShowSelection/ShowSelectionPlugin.js"; +import type { SideMenuProsemirrorPlugin } from "../extensions/SideMenu/SideMenuPlugin.js"; +import type { SuggestionMenuProseMirrorPlugin } from "../extensions/SuggestionMenu/SuggestionPlugin.js"; +import type { TableHandlesProsemirrorPlugin } from "../extensions/TableHandles/TableHandlesPlugin.js"; import { UniqueID } from "../extensions/UniqueID/UniqueID.js"; -import { Dictionary } from "../i18n/dictionary.js"; +import type { Dictionary } from "../i18n/dictionary.js"; import { en } from "../i18n/locales/index.js"; -import { +import type { BlockIdentifier, BlockNoteDOMAttributes, BlockSchema, @@ -102,26 +49,29 @@ import { StyleSchema, StyleSpecs, } from "../schema/index.js"; -import "../style.css"; import { mergeCSSClasses } from "../util/browser.js"; import { EventEmitter } from "../util/EventEmitter.js"; -import { NoInfer, UnreachableCaseError } from "../util/typescript.js"; +import type { NoInfer } from "../util/typescript.js"; import { BlockNoteExtension } from "./BlockNoteExtension.js"; import { getBlockNoteExtensions } from "./BlockNoteExtensions.js"; -import { TextCursorPosition } from "./cursorPositionTypes.js"; -import { Selection } from "./selectionTypes.js"; -import { transformPasted } from "./transformPasted.js"; - -// TODO eventually we will want to de-couple this from the editor instance, for now it provides a default schema to use +import type { TextCursorPosition } from "./cursorPositionTypes.js"; import { - Block, - BlockNoteSchema, - DefaultBlockSchema, - DefaultInlineContentSchema, - DefaultStyleSchema, - PartialBlock, -} from "../blocks/index.js"; + BlockManager, + CollaborationManager, + type CollaborationOptions, + EventManager, + ExportManager, + ExtensionManager, + SelectionManager, + StateManager, + StyleManager, +} from "./managers/index.js"; +import type { Selection } from "./selectionTypes.js"; +import { transformPasted } from "./transformPasted.js"; +import { updateBlockTr } from "../api/blockManipulation/commands/updateBlock/updateBlock.js"; +import { getBlockInfoFromTransaction } from "../api/getBlockInfoFromPos.js"; +import { blockToNode } from "../api/nodeConversions/blockToNode.js"; import "../style.css"; /** @@ -489,41 +439,62 @@ export class BlockNoteEditor< /** * The schema of the editor. The schema defines which Blocks, InlineContent, and Styles are available in the editor. */ - public readonly schema: CustomBlockNoteSchema; + public readonly schema: BlockNoteSchema; 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< + public get formattingToolbar(): FormattingToolbarProsemirrorPlugin { + return this._extensionManager.formattingToolbar; + } + + public get linkToolbar(): LinkToolbarProsemirrorPlugin< BSchema, ISchema, SSchema - >; - public readonly suggestionMenus: SuggestionMenuProseMirrorPlugin< + > { + return this._extensionManager.linkToolbar; + } + + public get sideMenu(): SideMenuProsemirrorPlugin { + return this._extensionManager.sideMenu; + } + + public get suggestionMenus(): SuggestionMenuProseMirrorPlugin< BSchema, ISchema, SSchema - >; - public readonly filePanel?: FilePanelProsemirrorPlugin; - public readonly tableHandles?: TableHandlesProsemirrorPlugin< - ISchema, - SSchema - >; - public readonly comments?: CommentsPlugin; + > { + return this._extensionManager.suggestionMenus; + } + + public get filePanel(): + | FilePanelProsemirrorPlugin + | undefined { + return this._extensionManager.filePanel; + } + + public get tableHandles(): + | TableHandlesProsemirrorPlugin + | undefined { + return this._extensionManager.tableHandles; + } - private readonly showSelectionPlugin: ShowSelectionPlugin; + public get comments(): CommentsPlugin | undefined { + return this._collaborationManager?.comments; + } + + public get showSelectionPlugin(): ShowSelectionPlugin { + return this._extensionManager.showSelectionPlugin; + } /** * The plugin for forking a document, only defined if in collaboration mode */ - public readonly forkYDocPlugin?: ForkYDocPlugin; + public get forkYDocPlugin() { + return this._collaborationManager?.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. @@ -627,35 +598,59 @@ export class BlockNoteEditor< }, }; + // Initialize CollaborationManager if collaboration is enabled or if comments are configured + if (newOptions.collaboration || newOptions.comments) { + const collaborationOptions: CollaborationOptions = { + // Use collaboration options if available, otherwise provide defaults + fragment: newOptions.collaboration?.fragment || new Y.XmlFragment(), + user: newOptions.collaboration?.user || { + name: "User", + color: "#FF0000", + }, + provider: newOptions.collaboration?.provider || null, + renderCursor: newOptions.collaboration?.renderCursor, + showCursorLabels: newOptions.collaboration?.showCursorLabels, + comments: newOptions.comments, + resolveUsers: newOptions.resolveUsers, + }; + this._collaborationManager = new CollaborationManager( + this as any, + collaborationOptions, + ); + } else { + this._collaborationManager = undefined; + } + 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: editorHasBlockWithType(this, "table"), - dropCursor: this.options.dropCursor ?? dropCursor, - placeholders: newOptions.placeholders, - tabBehavior: newOptions.tabBehavior, - comments: newOptions.comments, - pasteHandler: newOptions.pasteHandler, - }); + 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: editorHasBlockWithType(this, "table"), + dropCursor: this.options.dropCursor ?? dropCursor, + placeholders: newOptions.placeholders, + tabBehavior: newOptions.tabBehavior, + pasteHandler: newOptions.pasteHandler, + }), + ...this._collaborationManager?.initExtensions(), + } as any; // add extensions from _tiptapOptions (newOptions._tiptapOptions?.extensions || []).forEach((ext) => { @@ -708,16 +703,6 @@ export class BlockNoteEditor< })(); }); - 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) => { @@ -924,13 +909,37 @@ export class BlockNoteEditor< } this.pmSchema.cached.blockNoteEditor = this; + + // Initialize managers + this._blockManager = new BlockManager(this as any); + + this._eventManager = new EventManager(this as any); + this._exportManager = new ExportManager(this as any); + this._extensionManager = new ExtensionManager(this as any); + this._selectionManager = new SelectionManager(this as any); + this._stateManager = new StateManager( + this as any, + collaborationEnabled + ? { + undo: this._collaborationManager?.getUndoCommand(), + redo: this._collaborationManager?.getRedoCommand(), + } + : undefined, + ); + this._styleManager = new StyleManager(this as any); + 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; + // Manager instances + private readonly _blockManager: BlockManager; + private readonly _collaborationManager?: CollaborationManager; + private readonly _eventManager: EventManager; + private readonly _exportManager: ExportManager; + private readonly _extensionManager: ExtensionManager; + private readonly _selectionManager: SelectionManager; + private readonly _stateManager: StateManager; + private readonly _styleManager: StyleManager; /** * Execute a prosemirror command. This is mostly for backwards compatibility with older code. @@ -945,16 +954,7 @@ export class BlockNoteEditor< * ``` */ 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) => view.dispatch(tr); - - return command(state, dispatch, view); + return this._stateManager.exec(command); } /** @@ -970,15 +970,7 @@ export class BlockNoteEditor< * ``` */ 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); + return this._stateManager.canExec(command); } /** @@ -1009,40 +1001,7 @@ export class BlockNoteEditor< 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.view.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; - } + return this._stateManager.transact(callback); } // TO DISCUSS @@ -1058,12 +1017,9 @@ export class BlockNoteEditor< 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; + return this._extensionManager.extension(ext, key); } + /** * Mount the editor to a DOM element. * @@ -1087,12 +1043,7 @@ export class BlockNoteEditor< * @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; + return this._stateManager.prosemirrorState; } /** @@ -1100,7 +1051,7 @@ export class BlockNoteEditor< * @see https://prosemirror.net/docs/ref/#view.EditorView */ public get prosemirrorView() { - return this._tiptapEditor.view; + return this._stateManager.prosemirrorView; } public get domElement() { @@ -1156,9 +1107,7 @@ export class BlockNoteEditor< * @returns A snapshot of all top-level (non-nested) blocks in the editor. */ public get document(): Block[] { - return this.transact((tr) => { - return docToBlocks(tr.doc, this.pmSchema); - }); + return this._blockManager.document; } /** @@ -1171,7 +1120,7 @@ export class BlockNoteEditor< public getBlock( blockIdentifier: BlockIdentifier, ): Block | undefined { - return this.transact((tr) => getBlock(tr.doc, blockIdentifier)); + return this._blockManager.getBlock(blockIdentifier); } /** @@ -1186,7 +1135,7 @@ export class BlockNoteEditor< public getPrevBlock( blockIdentifier: BlockIdentifier, ): Block | undefined { - return this.transact((tr) => getPrevBlock(tr.doc, blockIdentifier)); + return this._blockManager.getPrevBlock(blockIdentifier); } /** @@ -1200,7 +1149,7 @@ export class BlockNoteEditor< public getNextBlock( blockIdentifier: BlockIdentifier, ): Block | undefined { - return this.transact((tr) => getNextBlock(tr.doc, blockIdentifier)); + return this._blockManager.getNextBlock(blockIdentifier); } /** @@ -1213,7 +1162,7 @@ export class BlockNoteEditor< public getParentBlock( blockIdentifier: BlockIdentifier, ): Block | undefined { - return this.transact((tr) => getParentBlock(tr.doc, blockIdentifier)); + return this._blockManager.getParentBlock(blockIdentifier); } /** @@ -1225,33 +1174,7 @@ export class BlockNoteEditor< callback: (block: Block) => boolean, reverse = false, ): void { - const blocks = this.document.slice(); - - if (reverse) { - blocks.reverse(); - } - - function traverseBlockArray( - blockArray: Block[], - ): 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); + this._blockManager.forEachBlock(callback, reverse); } /** @@ -1283,7 +1206,7 @@ export class BlockNoteEditor< ISchema, SSchema > { - return this.transact((tr) => getTextCursorPosition(tr)); + return this._selectionManager.getTextCursorPosition(); } /** @@ -1296,9 +1219,7 @@ export class BlockNoteEditor< targetBlock: BlockIdentifier, placement: "start" | "end" = "start", ) { - return this.transact((tr) => - setTextCursorPosition(tr, targetBlock, placement), - ); + return this._selectionManager.setTextCursorPosition(targetBlock, placement); } /** @@ -1308,7 +1229,7 @@ export class BlockNoteEditor< * If the selection starts / ends halfway through a block, the returned data will contain the entire block. */ public getSelection(): Selection | undefined { - return this.transact((tr) => getSelection(tr)); + return this._selectionManager.getSelection(); } /** @@ -1319,7 +1240,7 @@ export class BlockNoteEditor< * only the part of the block that is included in the selection. */ public getSelectionCutBlocks() { - return this.transact((tr) => getSelectionCutBlocks(tr)); + return this._selectionManager.getSelectionCutBlocks(); } /** @@ -1328,7 +1249,7 @@ export class BlockNoteEditor< * @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)); + return this._selectionManager.setSelection(startBlock, endBlock); } /** @@ -1336,9 +1257,7 @@ export class BlockNoteEditor< * @returns True if the editor is editable, false otherwise. */ public get isEditable(): boolean { - return this._tiptapEditor.isEditable === undefined - ? true - : this._tiptapEditor.isEditable; + return this._stateManager.isEditable; } /** @@ -1346,9 +1265,7 @@ export class BlockNoteEditor< * @param editable True to make the editor editable, or false to lock it. */ public set isEditable(editable: boolean) { - if (this._tiptapEditor.options.editable !== editable) { - this._tiptapEditor.setEditable(editable); - } + this._stateManager.isEditable = editable; } /** @@ -1364,8 +1281,10 @@ export class BlockNoteEditor< referenceBlock: BlockIdentifier, placement: "before" | "after" = "before", ) { - return this.transact((tr) => - insertBlocks(tr, blocksToInsert, referenceBlock, placement), + return this._blockManager.insertBlocks( + blocksToInsert, + referenceBlock, + placement, ); } @@ -1380,7 +1299,7 @@ export class BlockNoteEditor< blockToUpdate: BlockIdentifier, update: PartialBlock, ) { - return this.transact((tr) => updateBlock(tr, blockToUpdate, update)); + return this._blockManager.updateBlock(blockToUpdate, update); } /** @@ -1388,9 +1307,7 @@ export class BlockNoteEditor< * @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, - ); + return this._blockManager.removeBlocks(blocksToRemove); } /** @@ -1404,30 +1321,21 @@ export class BlockNoteEditor< blocksToRemove: BlockIdentifier[], blocksToInsert: PartialBlock[], ) { - return this.transact((tr) => - removeAndInsertBlocks(tr, blocksToRemove, blocksToInsert), - ); + return this._blockManager.replaceBlocks(blocksToRemove, blocksToInsert); } /** * Undo the last action. */ public undo() { - if (this.options.collaboration) { - return this.exec(undoCommand); - } - - return this.exec(undo); + return this._stateManager.undo(); } /** * Redo the last action. */ public redo() { - if (this.options.collaboration) { - return this.exec(redoCommand); - } - return this.exec(redo); + return this._stateManager.redo(); } /** @@ -1439,55 +1347,14 @@ export class BlockNoteEditor< content: PartialInlineContent, { 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, - }, - ); - }); + this._styleManager.insertInlineContent(content, { 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 = {}; - 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; - }); + public getActiveStyles(): Styles { + return this._styleManager.getActiveStyles(); } /** @@ -1495,19 +1362,7 @@ export class BlockNoteEditor< * @param styles The styles to add. */ public addStyles(styles: Styles) { - 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); - } - } + this._styleManager.addStyles(styles); } /** @@ -1515,9 +1370,7 @@ export class BlockNoteEditor< * @param styles The styles to remove. */ public removeStyles(styles: Styles) { - for (const style of Object.keys(styles)) { - this._tiptapEditor.commands.unsetMark(style); - } + this._styleManager.removeStyles(styles); } /** @@ -1525,35 +1378,21 @@ export class BlockNoteEditor< * @param styles The styles to toggle. */ public toggleStyles(styles: Styles) { - 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); - } - } + this._styleManager.toggleStyles(styles); } /** * Gets the currently selected text. */ public getSelectedText() { - return this.transact((tr) => { - return tr.doc.textBetween(tr.selection.from, tr.selection.to); - }); + return this._styleManager.getSelectedText(); } /** * 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; + return this._styleManager.getSelectedLinkUrl(); } /** @@ -1562,51 +1401,35 @@ export class BlockNoteEditor< * @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, - ); - } - }); + this._styleManager.createLink(url, text); } /** * Checks if the block containing the text cursor can be nested. */ public canNestBlock() { - return canNestBlock(this); + return this._blockManager.canNestBlock(); } /** * Nests the block containing the text cursor into the block above it. */ public nestBlock() { - nestBlock(this); + this._blockManager.nestBlock(); } /** * Checks if the block containing the text cursor is nested. */ public canUnnestBlock() { - return canUnnestBlock(this); + return this._blockManager.canUnnestBlock(); } /** * Lifts the block containing the text cursor out of its parent. */ public unnestBlock() { - unnestBlock(this); + this._blockManager.unnestBlock(); } /** @@ -1615,7 +1438,7 @@ export class BlockNoteEditor< * current blocks share a common parent, moves them out of & before it. */ public moveBlocksUp() { - return moveBlocksUp(this); + return this._blockManager.moveBlocksUp(); } /** @@ -1624,7 +1447,7 @@ export class BlockNoteEditor< * current blocks share a common parent, moves them out of & after it. */ public moveBlocksDown() { - return moveBlocksDown(this); + return this._blockManager.moveBlocksDown(); } /** @@ -1637,8 +1460,7 @@ export class BlockNoteEditor< public blocksToHTMLLossy( blocks: PartialBlock[] = this.document, ): string { - const exporter = createExternalHTMLExporter(this.pmSchema, this); - return exporter.exportBlocks(blocks, {}); + return this._exportManager.blocksToHTMLLossy(blocks); } /** @@ -1653,8 +1475,7 @@ export class BlockNoteEditor< public blocksToFullHTML( blocks: PartialBlock[], ): string { - const exporter = createInternalHTMLSerializer(this.pmSchema, this); - return exporter.serializeBlocks(blocks, {}); + return this._exportManager.blocksToFullHTML(blocks); } /** * Parses blocks from an HTML string. Tries to create `Block` objects out of any HTML block-level elements, and @@ -1666,7 +1487,7 @@ export class BlockNoteEditor< public tryParseHTMLToBlocks( html: string, ): Block[] { - return HTMLToBlocks(html, this.pmSchema); + return this._exportManager.tryParseHTMLToBlocks(html); } /** @@ -1678,7 +1499,7 @@ export class BlockNoteEditor< public blocksToMarkdownLossy( blocks: PartialBlock[] = this.document, ): string { - return blocksToMarkdown(blocks, this.pmSchema, this, {}); + return this._exportManager.blocksToMarkdownLossy(blocks); } /** @@ -1688,43 +1509,23 @@ export class BlockNoteEditor< * @param markdown The Markdown string to parse blocks from. * @returns The blocks parsed from the Markdown string. */ - public async tryParseMarkdownToBlocks( + public tryParseMarkdownToBlocks( markdown: string, - ): Promise[]> { - return markdownToBlocks(markdown, this.pmSchema); + ): Block[] { + return this._exportManager.tryParseMarkdownToBlocks(markdown); } /** * Updates the user info for the current user that's shown to other collaborators. */ public updateCollaborationUserInfo(user: { name: string; color: string }) { - if (!this.options.collaboration) { + if (!this._collaborationManager) { throw new Error( "Cannot update collaboration user info when collaboration is disabled.", ); } - (this.extensions["yCursorPlugin"] as CursorPlugin).updateUser(user); - } - - /** - * Registers a callback which will be called before any change is applied to the editor, allowing you to cancel the change. - */ - public onBeforeChange( - /** - * If the callback returns `false`, the change will be canceled & not applied to the editor. - */ - callback: ( - editor: BlockNoteEditor, - context: { - getChanges: () => BlocksChanged; - tr: Transaction; - }, - ) => boolean | void, - ): () => void { - return (this.extensions["blockChange"] as BlockChangePlugin).subscribe( - (context) => callback(this, context), - ); + this._collaborationManager.updateUserInfo(user); } /** @@ -1744,24 +1545,7 @@ export class BlockNoteEditor< }, ) => void, ) { - const cb = ({ - transaction, - appendedTransactions, - }: { - transaction: Transaction; - appendedTransactions: Transaction[]; - }) => { - callback(this, { - getChanges: () => - getBlocksChangedByTransaction(transaction, appendedTransactions), - }); - }; - - this._tiptapEditor.on("update", cb); - - return () => { - this._tiptapEditor.off("update", cb); - }; + return this._eventManager.onChange(callback); } /** @@ -1774,23 +1558,10 @@ export class BlockNoteEditor< callback: (editor: BlockNoteEditor) => void, includeSelectionChangedByRemote?: boolean, ) { - const cb = (e: { transaction: Transaction }) => { - if ( - e.transaction.getMeta(ySyncPluginKey) && - !includeSelectionChangedByRemote - ) { - // selection changed because of a yjs sync (i.e.: other user was typing) - // we don't want to trigger the callback in this case - return; - } - callback(this); - }; - - this._tiptapEditor.on("selectionUpdate", cb); - - return () => { - this._tiptapEditor.off("selectionUpdate", cb); - }; + return this._eventManager.onSelectionChange( + callback, + includeSelectionChangedByRemote, + ); } /** @@ -1799,6 +1570,7 @@ export class BlockNoteEditor< * This can be useful for plugins to initialize themselves after the editor has been initialized. */ public onCreate(callback: () => void) { + // TODO I think this create handler is wrong actually... this.on("create", callback); return () => { @@ -1807,25 +1579,7 @@ export class BlockNoteEditor< } public getSelectionBoundingBox() { - if (!this.prosemirrorView) { - return undefined; - } - - const { selection } = this.prosemirrorState; - - // support for CellSelections - const { ranges } = selection; - const from = Math.min(...ranges.map((range) => range.$from.pos)); - const to = Math.max(...ranges.map((range) => range.$to.pos)); - - if (isNodeSelection(selection)) { - const node = this.prosemirrorView.nodeDOM(from) as HTMLElement; - if (node) { - return node.getBoundingClientRect(); - } - } - - return posToDOMRect(this.prosemirrorView, from, to); + return this._selectionManager.getSelectionBoundingBox(); } public get isEmpty() { @@ -1883,15 +1637,7 @@ export class BlockNoteEditor< * @param raw Whether to paste the HTML as is, or to convert it to BlockNote HTML. */ public pasteHTML(html: string, raw = false) { - let htmlToPaste = html; - if (!raw) { - const blocks = this.tryParseHTMLToBlocks(html); - htmlToPaste = this.blocksToFullHTML(blocks); - } - if (!htmlToPaste) { - return; - } - this.prosemirrorView?.pasteHTML(htmlToPaste); + this._exportManager.pasteHTML(html, raw); } /** @@ -1899,7 +1645,7 @@ export class BlockNoteEditor< * @param text The text to paste. */ public pasteText(text: string) { - return this.prosemirrorView?.pasteText(text); + return this._exportManager.pasteText(text); } /** @@ -1907,7 +1653,6 @@ export class BlockNoteEditor< * @param markdown The markdown to paste. */ public pasteMarkdown(markdown: string) { - const html = markdownToHTML(markdown); - return this.pasteHTML(html); + return this._exportManager.pasteMarkdown(markdown); } } diff --git a/packages/core/src/editor/BlockNoteExtensions.ts b/packages/core/src/editor/BlockNoteExtensions.ts index 082bdcd1b8..754c90b428 100644 --- a/packages/core/src/editor/BlockNoteExtensions.ts +++ b/packages/core/src/editor/BlockNoteExtensions.ts @@ -9,7 +9,7 @@ import * as Y from "yjs"; import { createDropFileExtension } from "../api/clipboard/fromClipboard/fileDropExtension.js"; import { createPasteFromClipboardExtension } from "../api/clipboard/fromClipboard/pasteExtension.js"; import { createCopyToClipboardExtension } from "../api/clipboard/toClipboard/copyExtension.js"; -import type { ThreadStore } from "../comments/index.js"; +import type { ThreadStore, User } from "../comments/index.js"; import { BackgroundColorExtension } from "../extensions/BackgroundColor/BackgroundColorExtension.js"; import { BlockChangePlugin } from "../extensions/BlockChange/BlockChangePlugin.js"; import { CursorPlugin } from "../extensions/Collaboration/CursorPlugin.js"; @@ -96,6 +96,7 @@ type ExtensionOptions< comments?: { schema?: BlockNoteSchema; threadStore: ThreadStore; + resolveUsers?: (userIds: string[]) => Promise; }; pasteHandler: BlockNoteEditorOptions["pasteHandler"]; }; @@ -162,6 +163,7 @@ export const getBlockNoteExtensions = < opts.editor, opts.comments.threadStore, CommentMark.name, + opts.comments.resolveUsers, opts.comments.schema, ); } diff --git a/packages/core/src/editor/managers/BlockManager.ts b/packages/core/src/editor/managers/BlockManager.ts new file mode 100644 index 0000000000..2ea90ae984 --- /dev/null +++ b/packages/core/src/editor/managers/BlockManager.ts @@ -0,0 +1,251 @@ +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 { docToBlocks } from "../../api/nodeConversions/nodeToBlock.js"; +import { + Block, + DefaultBlockSchema, + DefaultInlineContentSchema, + DefaultStyleSchema, + PartialBlock, +} from "../../blocks/defaultBlocks.js"; +import { + BlockIdentifier, + BlockSchema, + InlineContentSchema, + StyleSchema, +} from "../../schema/index.js"; +import { BlockNoteEditor } from "../BlockNoteEditor.js"; + +export class BlockManager< + BSchema extends BlockSchema = DefaultBlockSchema, + ISchema extends InlineContentSchema = DefaultInlineContentSchema, + SSchema extends StyleSchema = DefaultStyleSchema, +> { + constructor(private editor: BlockNoteEditor) {} + + /** + * 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[] { + return this.editor.transact((tr) => { + return docToBlocks(tr.doc, this.editor.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 | undefined { + return this.editor.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 | undefined { + return this.editor.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 | undefined { + return this.editor.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 | undefined { + return this.editor.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) => boolean, + reverse = false, + ): void { + const blocks = this.document.slice(); + + if (reverse) { + blocks.reverse(); + } + + function traverseBlockArray( + blockArray: Block[], + ): 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); + } + + /** + * 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[], + referenceBlock: BlockIdentifier, + placement: "before" | "after" = "before", + ) { + return this.editor.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, + ) { + return this.editor.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.editor.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[], + ) { + return this.editor.transact((tr) => + removeAndInsertBlocks(tr, blocksToRemove, blocksToInsert), + ); + } + + /** + * Checks if the block containing the text cursor can be nested. + */ + public canNestBlock() { + return canNestBlock(this.editor); + } + + /** + * Nests the block containing the text cursor into the block above it. + */ + public nestBlock() { + nestBlock(this.editor); + } + + /** + * Checks if the block containing the text cursor is nested. + */ + public canUnnestBlock() { + return canUnnestBlock(this.editor); + } + + /** + * Lifts the block containing the text cursor out of its parent. + */ + public unnestBlock() { + unnestBlock(this.editor); + } + + /** + * 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.editor); + } + + /** + * 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.editor); + } +} diff --git a/packages/core/src/editor/managers/CollaborationManager.ts b/packages/core/src/editor/managers/CollaborationManager.ts new file mode 100644 index 0000000000..8273fb5cb4 --- /dev/null +++ b/packages/core/src/editor/managers/CollaborationManager.ts @@ -0,0 +1,212 @@ +import * as Y from "yjs"; +import { redoCommand, undoCommand } from "y-prosemirror"; +import { CommentsPlugin } from "../../extensions/Comments/CommentsPlugin.js"; +import { CommentMark } from "../../extensions/Comments/CommentMark.js"; +import { ForkYDocPlugin } from "../../extensions/Collaboration/ForkYDocPlugin.js"; +import { SyncPlugin } from "../../extensions/Collaboration/SyncPlugin.js"; +import { UndoPlugin } from "../../extensions/Collaboration/UndoPlugin.js"; +import { CursorPlugin } from "../../extensions/Collaboration/CursorPlugin.js"; +import type { ThreadStore, User } from "../../comments/index.js"; +import type { BlockNoteEditor } from "../BlockNoteEditor.js"; +import { CustomBlockNoteSchema } from "../../schema/schema.js"; + +export interface CollaborationOptions { + /** + * 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) + * Can be null for comments-only mode + */ + 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"; + /** + * Comments configuration - can be used with or without collaboration + */ + comments?: { + schema?: CustomBlockNoteSchema; + threadStore: ThreadStore; + }; + /** + * Function to resolve user IDs to user objects - required for comments + */ + resolveUsers?: (userIds: string[]) => Promise; +} + +/** + * CollaborationManager handles all collaboration-related functionality + * This manager is completely optional and can be tree-shaken if not used + */ +export class CollaborationManager { + private editor: BlockNoteEditor; + private options: CollaborationOptions; + private _commentsPlugin?: CommentsPlugin; + private _forkYDocPlugin?: ForkYDocPlugin; + private _syncPlugin?: SyncPlugin; + private _undoPlugin?: UndoPlugin; + private _cursorPlugin?: CursorPlugin; + + constructor(editor: BlockNoteEditor, options: CollaborationOptions) { + this.editor = editor; + this.options = options; + } + + /** + * Get the sync plugin instance + */ + public get syncPlugin(): SyncPlugin | undefined { + return this._syncPlugin; + } + + /** + * Get the undo plugin instance + */ + public get undoPlugin(): UndoPlugin | undefined { + return this._undoPlugin; + } + + /** + * Get the cursor plugin instance + */ + public get cursorPlugin(): CursorPlugin | undefined { + return this._cursorPlugin; + } + + /** + * Get the fork YDoc plugin instance + */ + public get forkYDocPlugin(): ForkYDocPlugin | undefined { + return this._forkYDocPlugin; + } + + // Initialize collaboration plugins + public initExtensions(): Record { + // Only create collaboration plugins when real-time collaboration is enabled + const extensions: Record = {}; + + // Initialize sync plugin + this._syncPlugin = new SyncPlugin(this.options.fragment); + extensions.ySyncPlugin = this._syncPlugin; + + // Initialize undo plugin + this._undoPlugin = new UndoPlugin({ editor: this.editor }); + extensions.yUndoPlugin = this._undoPlugin; + + // Initialize cursor plugin if provider has awareness + if (this.options.provider?.awareness) { + this._cursorPlugin = new CursorPlugin(this.options); + extensions.yCursorPlugin = this._cursorPlugin; + } + + // Initialize fork YDoc plugin + this._forkYDocPlugin = new ForkYDocPlugin({ + editor: this.editor, + collaboration: this.options, + }); + extensions.forkYDocPlugin = this._forkYDocPlugin; + + if (this.options.comments) { + if (!this.options.resolveUsers) { + throw new Error("resolveUsers is required when using comments"); + } + + // Create CommentsPlugin instance and add it to editor extensions + this._commentsPlugin = new CommentsPlugin( + this.editor, + this.options.comments.threadStore, + CommentMark.name, + this.options.resolveUsers, + this.options.comments.schema, + ); + + // Add the comments plugin to the editor's extensions + extensions.comments = this._commentsPlugin; + extensions.commentMark = CommentMark; + } + return extensions; + } + + /** + * Update the user info for the current user that's shown to other collaborators + */ + public updateUserInfo(user: { name: string; color: string }) { + const cursor = this.cursorPlugin; + if (!cursor) { + throw new Error( + "Cannot update collaboration user info when collaboration is disabled.", + ); + } + cursor.updateUser(user); + } + + /** + * Get the collaboration undo command + */ + public getUndoCommand() { + return undoCommand; + } + + /** + * Get the collaboration redo command + */ + public getRedoCommand() { + return redoCommand; + } + + /** + * Check if initial content should be avoided due to collaboration + */ + public shouldAvoidInitialContent(): boolean { + // Only avoid initial content when real-time collaboration is enabled + // (i.e., when we have a provider) + return !!this.options.provider; + } + + /** + * Get the collaboration options + */ + public getOptions(): CollaborationOptions { + return this.options; + } + + /** + * Get the comments plugin if available + */ + public get comments(): CommentsPlugin | undefined { + return this._commentsPlugin; + } + + /** + * Check if comments are enabled + */ + public get hasComments(): boolean { + return !!this.options.comments; + } + + /** + * Get the resolveUsers function + */ + public get resolveUsers(): + | ((userIds: string[]) => Promise) + | undefined { + return this.options.resolveUsers; + } +} diff --git a/packages/core/src/editor/managers/EventManager.ts b/packages/core/src/editor/managers/EventManager.ts new file mode 100644 index 0000000000..abd5fc5860 --- /dev/null +++ b/packages/core/src/editor/managers/EventManager.ts @@ -0,0 +1,116 @@ +import type { BlockNoteEditor } from "../BlockNoteEditor.js"; +import { + getBlocksChangedByTransaction, + type BlocksChanged, +} from "../../api/getBlocksChangedByTransaction.js"; +import { Transaction } from "prosemirror-state"; + +/** + * A function that can be used to unsubscribe from an event. + */ +export type Unsubscribe = () => void; + +/** + * EventManager is a class which manages the events of the editor + */ +export class EventManager { + constructor(private editor: Editor) {} + + /** + * Register a callback that will be called when the editor changes. + */ + public onChange( + callback: ( + editor: Editor, + ctx: { + getChanges(): BlocksChanged< + Editor["schema"]["blockSchema"], + Editor["schema"]["inlineContentSchema"], + Editor["schema"]["styleSchema"] + >; + }, + ) => void, + ): Unsubscribe { + const cb = ({ + transaction, + appendedTransactions, + }: { + transaction: Transaction; + appendedTransactions: Transaction[]; + }) => { + callback(this.editor, { + getChanges() { + return getBlocksChangedByTransaction( + transaction, + appendedTransactions, + ); + }, + }); + }; + + this.editor._tiptapEditor.on("update", cb); + + return () => { + this.editor._tiptapEditor.off("update", cb); + }; + } + + /** + * Register a callback that will be called when the selection changes. + */ + public onSelectionChange( + callback: (editor: Editor) => void, + /** + * If true, the callback will be triggered when the selection changes due to a yjs sync (i.e.: other user was typing) + */ + includeSelectionChangedByRemote = false, + ): Unsubscribe { + const cb = (e: { transaction: Transaction }) => { + if ( + e.transaction.getMeta("$y-sync") && + !includeSelectionChangedByRemote + ) { + // selection changed because of a yjs sync (i.e.: other user was typing) + // we don't want to trigger the callback in this case + return; + } + callback(this.editor); + }; + + this.editor._tiptapEditor.on("selectionUpdate", cb); + + return () => { + this.editor._tiptapEditor.off("selectionUpdate", cb); + }; + } + + /** + * Register a callback that will be called when the editor is mounted. + */ + public onMount(callback: (ctx: { editor: Editor }) => void): Unsubscribe { + const cb = () => { + callback({ editor: this.editor }); + }; + + this.editor._tiptapEditor.on("mount", cb); + + return () => { + this.editor._tiptapEditor.off("mount", cb); + }; + } + + /** + * Register a callback that will be called when the editor is unmounted. + */ + public onUnmount(callback: (ctx: { editor: Editor }) => void): Unsubscribe { + const cb = () => { + callback({ editor: this.editor }); + }; + + this.editor._tiptapEditor.on("unmount", cb); + + return () => { + this.editor._tiptapEditor.off("unmount", cb); + }; + } +} diff --git a/packages/core/src/editor/managers/ExportManager.ts b/packages/core/src/editor/managers/ExportManager.ts new file mode 100644 index 0000000000..aba001ce65 --- /dev/null +++ b/packages/core/src/editor/managers/ExportManager.ts @@ -0,0 +1,137 @@ +import { createExternalHTMLExporter } from "../../api/exporters/html/externalHTMLExporter.js"; +import { createInternalHTMLSerializer } from "../../api/exporters/html/internalHTMLSerializer.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 { + BlockSchema, + InlineContentSchema, + StyleSchema, +} from "../../schema/index.js"; +import { BlockNoteEditor } from "../BlockNoteEditor.js"; + +export class ExportManager< + BSchema extends BlockSchema = DefaultBlockSchema, + ISchema extends InlineContentSchema = DefaultInlineContentSchema, + SSchema extends StyleSchema = DefaultStyleSchema, +> { + constructor(private editor: BlockNoteEditor) {} + + /** + * 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 blocksToHTMLLossy( + blocks: PartialBlock[] = this.editor.document, + ): string { + const exporter = createExternalHTMLExporter( + this.editor.pmSchema, + this.editor, + ); + 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 string. + */ + public blocksToFullHTML( + blocks: PartialBlock[], + ): string { + const exporter = createInternalHTMLSerializer( + this.editor.pmSchema, + this.editor, + ); + return exporter.serializeBlocks(blocks, {}); + } + + /** + * Parses blocks from an HTML string. Tries to create `Block` objects out of any HTML block-level elements, and + * `InlineNode` objects from any HTML inline elements, though not all element types are recognized. If BlockNote + * doesn't recognize an HTML element's tag, it will parse it as a paragraph or plain text. + * @param html The HTML string to parse blocks from. + * @returns The blocks parsed from the HTML string. + */ + public tryParseHTMLToBlocks( + html: string, + ): Block[] { + return HTMLToBlocks(html, this.editor.pmSchema); + } + + /** + * Serializes blocks into a Markdown string. The output is simplified as Markdown does not support all features of + * BlockNote - children of blocks which aren't list items are un-nested and certain styles are removed. + * @param blocks An array of blocks that should be serialized into Markdown. + * @returns The blocks, serialized as a Markdown string. + */ + public blocksToMarkdownLossy( + blocks: PartialBlock[] = this.editor.document, + ): string { + return blocksToMarkdown(blocks, this.editor.pmSchema, this.editor, {}); + } + + /** + * Creates a list of blocks from a Markdown string. Tries to create `Block` and `InlineNode` objects based on + * Markdown syntax, though not all symbols are recognized. If BlockNote doesn't recognize a symbol, it will parse it + * as text. + * @param markdown The Markdown string to parse blocks from. + * @returns The blocks parsed from the Markdown string. + */ + public tryParseMarkdownToBlocks( + markdown: string, + ): Block[] { + return markdownToBlocks(markdown, this.editor.pmSchema); + } + + /** + * Paste HTML into the editor. Defaults to converting HTML to BlockNote HTML. + * @param html The HTML to paste. + * @param raw Whether to paste the HTML as is, or to convert it to BlockNote HTML. + */ + public pasteHTML(html: string, raw = false) { + let htmlToPaste = html; + if (!raw) { + const blocks = this.tryParseHTMLToBlocks(html); + htmlToPaste = this.blocksToFullHTML(blocks); + } + if (!htmlToPaste) { + return; + } + this.editor.prosemirrorView?.pasteHTML(htmlToPaste); + } + + /** + * Paste text into the editor. Defaults to interpreting text as markdown. + * @param text The text to paste. + */ + public pasteText(text: string) { + return this.editor.prosemirrorView?.pasteText(text); + } + + /** + * Paste markdown into the editor. + * @param markdown The markdown to paste. + */ + public pasteMarkdown(markdown: string) { + const html = markdownToHTML(markdown); + return this.pasteHTML(html); + } +} diff --git a/packages/core/src/editor/managers/ExtensionManager.ts b/packages/core/src/editor/managers/ExtensionManager.ts new file mode 100644 index 0000000000..4d35b68c17 --- /dev/null +++ b/packages/core/src/editor/managers/ExtensionManager.ts @@ -0,0 +1,130 @@ +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 { BlockNoteExtension } from "../BlockNoteExtension.js"; +import { BlockNoteEditor } from "../BlockNoteEditor.js"; + +export class ExtensionManager { + constructor(private editor: BlockNoteEditor) {} + + /** + * 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( + ext: { new (...args: any[]): T } & typeof BlockNoteExtension, + key = ext.key(), + ): T { + const extension = this.editor.extensions[key] as T; + if (!extension) { + throw new Error(`Extension ${key} not found`); + } + return extension; + } + + /** + * Get all extensions + */ + public getExtensions() { + return this.editor.extensions; + } + + /** + * Get a specific extension by key + */ + public getExtension(key: string) { + return this.editor.extensions[key]; + } + + /** + * Check if an extension exists + */ + public hasExtension(key: string): boolean { + return key in this.editor.extensions; + } + + // Plugin getters - these provide access to the core BlockNote plugins + + /** + * Get the formatting toolbar plugin + */ + public get formattingToolbar(): FormattingToolbarProsemirrorPlugin { + return this.editor.extensions[ + "formattingToolbar" + ] as FormattingToolbarProsemirrorPlugin; + } + + /** + * Get the link toolbar plugin + */ + public get linkToolbar(): LinkToolbarProsemirrorPlugin { + return this.editor.extensions[ + "linkToolbar" + ] as LinkToolbarProsemirrorPlugin; + } + + /** + * Get the side menu plugin + */ + public get sideMenu(): SideMenuProsemirrorPlugin { + return this.editor.extensions["sideMenu"] as SideMenuProsemirrorPlugin< + any, + any, + any + >; + } + + /** + * Get the suggestion menus plugin + */ + public get suggestionMenus(): SuggestionMenuProseMirrorPlugin { + return this.editor.extensions[ + "suggestionMenus" + ] as SuggestionMenuProseMirrorPlugin; + } + + /** + * Get the file panel plugin (if available) + */ + public get filePanel(): FilePanelProsemirrorPlugin | undefined { + return this.editor.extensions["filePanel"] as + | FilePanelProsemirrorPlugin + | undefined; + } + + /** + * Get the table handles plugin (if available) + */ + public get tableHandles(): + | TableHandlesProsemirrorPlugin + | undefined { + return this.editor.extensions["tableHandles"] as + | TableHandlesProsemirrorPlugin + | undefined; + } + + /** + * Get the show selection plugin + */ + public get showSelectionPlugin(): ShowSelectionPlugin { + return this.editor.extensions["showSelection"] as ShowSelectionPlugin; + } + + /** + * Check if collaboration is enabled (Yjs or Liveblocks) + */ + public get isCollaborationEnabled(): boolean { + return ( + this.hasExtension("ySyncPlugin") || + this.hasExtension("liveblocksExtension") + ); + } +} diff --git a/packages/core/src/editor/managers/SelectionManager.ts b/packages/core/src/editor/managers/SelectionManager.ts new file mode 100644 index 0000000000..a91487251c --- /dev/null +++ b/packages/core/src/editor/managers/SelectionManager.ts @@ -0,0 +1,114 @@ +import { + getSelection, + getSelectionCutBlocks, + setSelection, +} from "../../api/blockManipulation/selections/selection.js"; +import { + getTextCursorPosition, + setTextCursorPosition, +} from "../../api/blockManipulation/selections/textCursorPosition.js"; +import { isNodeSelection, posToDOMRect } from "@tiptap/core"; +import { + BlockIdentifier, + BlockSchema, + InlineContentSchema, + StyleSchema, +} from "../../schema/index.js"; +import { + DefaultBlockSchema, + DefaultInlineContentSchema, + DefaultStyleSchema, +} from "../../blocks/defaultBlocks.js"; +import { Selection } from "../selectionTypes.js"; +import { TextCursorPosition } from "../cursorPositionTypes.js"; +import { BlockNoteEditor } from "../BlockNoteEditor.js"; + +export class SelectionManager< + BSchema extends BlockSchema = DefaultBlockSchema, + ISchema extends InlineContentSchema = DefaultInlineContentSchema, + SSchema extends StyleSchema = DefaultStyleSchema, +> { + constructor(private editor: BlockNoteEditor) {} + + /** + * 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 | undefined { + return this.editor.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.editor.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.editor.transact((tr) => setSelection(tr, startBlock, endBlock)); + } + + /** + * 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.editor.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.editor.transact((tr) => + setTextCursorPosition(tr, targetBlock, placement), + ); + } + + /** + * Gets the bounding box of the current selection. + */ + public getSelectionBoundingBox() { + if (!this.editor.prosemirrorView) { + return undefined; + } + + const { selection } = this.editor.prosemirrorState; + + // support for CellSelections + const { ranges } = selection; + const from = Math.min(...ranges.map((range) => range.$from.pos)); + const to = Math.max(...ranges.map((range) => range.$to.pos)); + + if (isNodeSelection(selection)) { + const node = this.editor.prosemirrorView.nodeDOM(from) as HTMLElement; + if (node) { + return node.getBoundingClientRect(); + } + } + + return posToDOMRect(this.editor.prosemirrorView, from, to); + } +} diff --git a/packages/core/src/editor/managers/StateManager.ts b/packages/core/src/editor/managers/StateManager.ts new file mode 100644 index 0000000000..6e9f2be42b --- /dev/null +++ b/packages/core/src/editor/managers/StateManager.ts @@ -0,0 +1,238 @@ +import { redo, undo } from "@tiptap/pm/history"; +import { Command, Transaction } from "prosemirror-state"; +import { BlockNoteEditor } from "../BlockNoteEditor.js"; + +export class StateManager { + constructor( + private editor: BlockNoteEditor, + private options?: { + /** + * Swap the default undo command with a custom command. + */ + undo?: typeof undo; + /** + * Swap the default redo command with a custom command. + */ + redo?: typeof redo; + }, + ) {} + + /** + * 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; + + /** + * For any command that can be executed, you can check if it can be executed by calling `editor.can(command)`. + * @example + * ```ts + * if (editor.can(editor.undo)) { + * // show button + * } else { + * // hide button + * } + */ + public can(cb: () => boolean) { + try { + this.isInCan = true; + return cb(); + } finally { + this.isInCan = false; + } + } + + // Flag to indicate if we're in a `can` call + private isInCan = false; + + /** + * 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", + ); + } + if (this.isInCan) { + return this.canExec(command); + } + const state = this.prosemirrorState; + const view = this.prosemirrorView; + const dispatch = (tr: Transaction) => this.prosemirrorView.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.prosemirrorState; + const view = this.prosemirrorView; + + 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( + 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.editor._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.prosemirrorView.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; + } + } + /** + * 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.editor._tiptapEditor.state; + } + + /** + * Get the underlying prosemirror view + * @see https://prosemirror.net/docs/ref/#view.EditorView + */ + public get prosemirrorView() { + return this.editor._tiptapEditor.view; + } + + public isFocused() { + return this.prosemirrorView?.hasFocus() || false; + } + + public focus() { + this.prosemirrorView?.focus(); + } + + /** + * 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.editor._tiptapEditor) { + if (!this.editor.headless) { + throw new Error("no editor, but also not headless?"); + } + return false; + } + return this.editor._tiptapEditor.isEditable === undefined + ? true + : this.editor._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.editor._tiptapEditor) { + if (!this.editor.headless) { + throw new Error("no editor, but also not headless?"); + } + // not relevant on headless + return; + } + if (this.editor._tiptapEditor.options.editable !== editable) { + this.editor._tiptapEditor.setEditable(editable); + } + } + + /** + * Undo the last action. + */ + public undo() { + return this.exec(this.options?.undo ?? undo); + } + + /** + * Redo the last action. + */ + public redo() { + return this.exec(this.options?.redo ?? redo); + } +} diff --git a/packages/core/src/editor/managers/StyleManager.ts b/packages/core/src/editor/managers/StyleManager.ts new file mode 100644 index 0000000000..e03c46a6d1 --- /dev/null +++ b/packages/core/src/editor/managers/StyleManager.ts @@ -0,0 +1,182 @@ +import { insertContentAt } from "../../api/blockManipulation/insertContentAt.js"; +import { inlineContentToNodes } from "../../api/nodeConversions/blockToNode.js"; +import { + BlockSchema, + InlineContentSchema, + PartialInlineContent, + StyleSchema, + Styles, +} from "../../schema/index.js"; +import { + DefaultBlockSchema, + DefaultInlineContentSchema, + DefaultStyleSchema, +} from "../../blocks/defaultBlocks.js"; +import { TextSelection } from "@tiptap/pm/state"; +import { UnreachableCaseError } from "../../util/typescript.js"; +import { BlockNoteEditor } from "../BlockNoteEditor.js"; + +export class StyleManager< + BSchema extends BlockSchema = DefaultBlockSchema, + ISchema extends InlineContentSchema = DefaultInlineContentSchema, + SSchema extends StyleSchema = DefaultStyleSchema, +> { + constructor(private editor: BlockNoteEditor) {} + + /** + * 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, + { updateSelection = false }: { updateSelection?: boolean } = {}, + ) { + const nodes = inlineContentToNodes(content, this.editor.pmSchema); + + this.editor.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.editor.transact((tr) => { + const styles: Styles = {}; + const marks = tr.selection.$to.marks(); + + for (const mark of marks) { + const config = this.editor.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) { + for (const [style, value] of Object.entries(styles)) { + const config = this.editor.schema.styleSchema[style]; + if (!config) { + throw new Error(`style ${style} not found in styleSchema`); + } + if (config.propSchema === "boolean") { + this.editor._tiptapEditor.commands.setMark(style); + } else if (config.propSchema === "string") { + this.editor._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) { + for (const style of Object.keys(styles)) { + this.editor._tiptapEditor.commands.unsetMark(style); + } + } + + /** + * Toggles styles on the currently selected content. + * @param styles The styles to toggle. + */ + public toggleStyles(styles: Styles) { + for (const [style, value] of Object.entries(styles)) { + const config = this.editor.schema.styleSchema[style]; + if (!config) { + throw new Error(`style ${style} not found in styleSchema`); + } + if (config.propSchema === "boolean") { + this.editor._tiptapEditor.commands.toggleMark(style); + } else if (config.propSchema === "string") { + this.editor._tiptapEditor.commands.toggleMark(style, { + stringValue: value, + }); + } else { + throw new UnreachableCaseError(config.propSchema); + } + } + } + + /** + * Gets the currently selected text. + */ + public getSelectedText() { + return this.editor.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.editor._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.editor.pmSchema.mark("link", { href: url }); + this.editor.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, + ); + } + }); + } +} diff --git a/packages/core/src/editor/managers/index.ts b/packages/core/src/editor/managers/index.ts new file mode 100644 index 0000000000..c4f64b6e3d --- /dev/null +++ b/packages/core/src/editor/managers/index.ts @@ -0,0 +1,11 @@ +export { BlockManager } from "./BlockManager.js"; +export { + CollaborationManager, + type CollaborationOptions, +} from "./CollaborationManager.js"; +export { EventManager } from "./EventManager.js"; +export { ExportManager } from "./ExportManager.js"; +export { ExtensionManager } from "./ExtensionManager.js"; +export { SelectionManager } from "./SelectionManager.js"; +export { StateManager } from "./StateManager.js"; +export { StyleManager } from "./StyleManager.js"; diff --git a/packages/core/src/extensions/Comments/CommentsPlugin.ts b/packages/core/src/extensions/Comments/CommentsPlugin.ts index c68197363a..a5d1e24b6d 100644 --- a/packages/core/src/extensions/Comments/CommentsPlugin.ts +++ b/packages/core/src/extensions/Comments/CommentsPlugin.ts @@ -10,7 +10,7 @@ import type { } from "../../comments/index.js"; import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import { BlockNoteExtension } from "../../editor/BlockNoteExtension.js"; -import { BlockNoteSchema } from "../../blocks/BlockNoteSchema.js"; +import { CustomBlockNoteSchema } from "../../schema/schema.js"; import { UserStore } from "./userstore/UserStore.js"; const PLUGIN_KEY = new PluginKey(`blocknote-comments`); @@ -135,14 +135,17 @@ export class CommentsPlugin extends BlockNoteExtension { private readonly editor: BlockNoteEditor, public readonly threadStore: ThreadStore, private readonly markType: string, - public readonly commentEditorSchema?: BlockNoteSchema, + public readonly resolveUsers: + | undefined + | ((userIds: string[]) => Promise), + public readonly commentEditorSchema?: CustomBlockNoteSchema, ) { super(); - if (!editor.resolveUsers) { + if (!resolveUsers) { throw new Error("resolveUsers is required for comments"); } - this.userStore = new UserStore(editor.resolveUsers); + this.userStore = new UserStore(resolveUsers); // Note: Plugins are currently not destroyed when the editor is destroyed. // We should unsubscribe from the threadStore when the editor is destroyed. diff --git a/packages/server-util/src/context/ServerBlockNoteEditor.ts b/packages/server-util/src/context/ServerBlockNoteEditor.ts index c33f89244e..66daeee6c9 100644 --- a/packages/server-util/src/context/ServerBlockNoteEditor.ts +++ b/packages/server-util/src/context/ServerBlockNoteEditor.ts @@ -292,7 +292,7 @@ export class ServerBlockNoteEditor< public async tryParseMarkdownToBlocks( markdown: string, ): Promise[]> { - return this._withJSDOM(() => { + return this._withJSDOM(async () => { return this.editor.tryParseMarkdownToBlocks(markdown); }); }