diff --git a/docs/content/docs/features/extensions.mdx b/docs/content/docs/features/extensions.mdx index e2fa8e904e..8b1dcd37c0 100644 --- a/docs/content/docs/features/extensions.mdx +++ b/docs/content/docs/features/extensions.mdx @@ -14,7 +14,7 @@ BlockNote includes an extensions system which lets you expand the editor's behav ## Creating an extension -An extension is an instance of the [`BlockNoteExtension`](https://github.com/TypeCellOS/BlockNote/blob/10cdbfb5f77ef82f3617c0fa1191e0bf5b7358c5/packages/core/src/editor/BlockNoteExtension.ts#L13) class. However, it's recommended for most use cases to create extensions using the `createBlockNoteExtension` function, rather than instanciating the class directly: +An extension is an instance of the [`BlockNoteExtension`](https://github.com/TypeCellOS/BlockNote/blob/10cdbfb5f77ef82f3617c0fa1191e0bf5b7358c5/packages/core/src/editor/BlockNoteExtension.ts#L13) class. However, it's recommended for most use cases to create extensions using the `createExtension` function, rather than instanciating the class directly: ```typescript type BlockNoteExtensionOptions = { @@ -43,10 +43,10 @@ const customBlockExtensionOptions: BlockNoteExtensionOptions = { tiptapExtensions: ..., } -const CustomExtension = createBlockNoteExtension(customBlockExtensionOptions); +const CustomExtension = createExtension(customBlockExtensionOptions); ``` -Let's go over the options that can be passed into `createBlockNoteExtension`: +Let's go over the options that can be passed into `createExtension`: `key:` The name of the extension. @@ -74,7 +74,7 @@ The `extensions` [editor option](/docs/reference/editor/overview#options) takes const editor = useCreateBlockNote({ extensions: [ // Add extensions here: - createBlockNoteExtension({ ... }) + createExtension({ ... }) ], }); ``` @@ -95,7 +95,7 @@ const createCustomBlock = createReactBlockSpec( } [ // Add extensions here: - createBlockNoteExtension({ ... }) + createExtension({ ... }) ], }); ``` diff --git a/examples/03-ui-components/11-uppy-file-panel/src/FileReplaceButton.tsx b/examples/03-ui-components/11-uppy-file-panel/src/FileReplaceButton.tsx index e73723a9f9..6fccc130e4 100644 --- a/examples/03-ui-components/11-uppy-file-panel/src/FileReplaceButton.tsx +++ b/examples/03-ui-components/11-uppy-file-panel/src/FileReplaceButton.tsx @@ -70,7 +70,7 @@ export const FileReplaceButton = () => { variant={"panel-popover"} > {/* Replaces default file panel with our Uppy one. */} - + ); diff --git a/examples/03-ui-components/11-uppy-file-panel/src/UppyFilePanel.tsx b/examples/03-ui-components/11-uppy-file-panel/src/UppyFilePanel.tsx index eaf2d4c253..4094bc4441 100644 --- a/examples/03-ui-components/11-uppy-file-panel/src/UppyFilePanel.tsx +++ b/examples/03-ui-components/11-uppy-file-panel/src/UppyFilePanel.tsx @@ -43,7 +43,7 @@ const uppy = new Uppy() }); export function UppyFilePanel(props: FilePanelProps) { - const { block } = props; + const { blockId } = props; const editor = useBlockNoteEditor(); useEffect(() => { @@ -68,7 +68,7 @@ export function UppyFilePanel(props: FilePanelProps) { url: response.uploadURL, }, }; - editor.updateBlock(block, updateData); + editor.updateBlock(blockId, updateData); // File should be removed from the Uppy instance after upload. uppy.removeFile(file.id); @@ -78,7 +78,7 @@ export function UppyFilePanel(props: FilePanelProps) { return () => { uppy.off("upload-success", handler); }; - }, [block, editor]); + }, [blockId, editor]); // set up dashboard as in https://uppy.io/examples/ return ; diff --git a/packages/core/package.json b/packages/core/package.json index 2a74d1317c..d72f4bd9d9 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -80,7 +80,9 @@ }, "dependencies": { "@emoji-mart/data": "^1.2.1", + "@handlewithcare/prosemirror-inputrules": "0.1.3", "@shikijs/types": "3.13.0", + "@tanstack/store": "0.7.7", "@tiptap/core": "^3.7.2", "@tiptap/extension-bold": "^3.7.2", "@tiptap/extension-code": "^3.7.2", diff --git a/packages/core/src/blocks/Code/block.ts b/packages/core/src/blocks/Code/block.ts index 1458257e48..ec5a11392a 100644 --- a/packages/core/src/blocks/Code/block.ts +++ b/packages/core/src/blocks/Code/block.ts @@ -1,5 +1,5 @@ import type { HighlighterGeneric } from "@shikijs/types"; -import { createBlockNoteExtension } from "../../editor/BlockNoteExtension.js"; +import { createExtension } from "../../editor/BlockNoteExtension.js"; import { createBlockConfig, createBlockSpec } from "../../schema/index.js"; import { lazyShikiPlugin } from "./shiki.js"; import { DOMParser } from "@tiptap/pm/model"; @@ -169,11 +169,11 @@ export const createCodeBlockSpec = createBlockSpec( }), (options) => { return [ - createBlockNoteExtension({ + createExtension({ key: "code-block-highlighter", plugins: [lazyShikiPlugin(options)], }), - createBlockNoteExtension({ + createExtension({ key: "code-block-keyboard-shortcuts", keyboardShortcuts: { Delete: ({ editor }) => { diff --git a/packages/core/src/blocks/Divider/block.ts b/packages/core/src/blocks/Divider/block.ts index 3de2211d3f..ad847ddd0c 100644 --- a/packages/core/src/blocks/Divider/block.ts +++ b/packages/core/src/blocks/Divider/block.ts @@ -1,4 +1,4 @@ -import { createBlockNoteExtension } from "../../editor/BlockNoteExtension.js"; +import { createExtension } from "../../editor/BlockNoteExtension.js"; import { createBlockConfig, createBlockSpec } from "../../schema/index.js"; export type DividerBlockConfig = ReturnType; @@ -34,7 +34,7 @@ export const createDividerBlockSpec = createBlockSpec( }, }, [ - createBlockNoteExtension({ + createExtension({ key: "divider-block-shortcuts", inputRules: [ { diff --git a/packages/core/src/blocks/File/helpers/render/createAddFileButton.ts b/packages/core/src/blocks/File/helpers/render/createAddFileButton.ts index 227856b4ac..7e6d6bd7f5 100644 --- a/packages/core/src/blocks/File/helpers/render/createAddFileButton.ts +++ b/packages/core/src/blocks/File/helpers/render/createAddFileButton.ts @@ -1,4 +1,5 @@ import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js"; +import { FilePanelPlugin } from "../../../../extensions/FilePanel/FilePanelPlugin.js"; import { BlockConfig, BlockFromConfigNoChildren, @@ -36,11 +37,7 @@ export const createAddFileButton = ( }; // Opens the file toolbar. const addFileButtonClickHandler = () => { - editor.transact((tr) => - tr.setMeta(editor.filePanel!.plugins[0], { - block: block, - }), - ); + editor.getExtension(FilePanelPlugin)?.showMenu(block.id); }; addFileButton.addEventListener( "mousedown", diff --git a/packages/core/src/blocks/Heading/block.ts b/packages/core/src/blocks/Heading/block.ts index 7181298d1f..8be8c9fa80 100644 --- a/packages/core/src/blocks/Heading/block.ts +++ b/packages/core/src/blocks/Heading/block.ts @@ -1,5 +1,5 @@ import { createBlockConfig, createBlockSpec } from "../../schema/index.js"; -import { createBlockNoteExtension } from "../../editor/BlockNoteExtension.js"; +import { createExtension } from "../../editor/BlockNoteExtension.js"; import { addDefaultPropsExternalHTML, defaultProps, @@ -97,7 +97,7 @@ export const createHeadingBlockSpec = createBlockSpec( }, }), ({ levels = HEADING_LEVELS }: HeadingOptions = {}) => [ - createBlockNoteExtension({ + createExtension({ key: "heading-shortcuts", keyboardShortcuts: Object.fromEntries( levels.map((level) => [ diff --git a/packages/core/src/blocks/ListItem/BulletListItem/block.ts b/packages/core/src/blocks/ListItem/BulletListItem/block.ts index 029538d4a3..2314d09ced 100644 --- a/packages/core/src/blocks/ListItem/BulletListItem/block.ts +++ b/packages/core/src/blocks/ListItem/BulletListItem/block.ts @@ -1,5 +1,5 @@ import { getBlockInfoFromSelection } from "../../../api/getBlockInfoFromPos.js"; -import { createBlockNoteExtension } from "../../../editor/BlockNoteExtension.js"; +import { createExtension } from "../../../editor/BlockNoteExtension.js"; import { createBlockConfig, createBlockSpec } from "../../../schema/index.js"; import { addDefaultPropsExternalHTML, @@ -78,7 +78,7 @@ export const createBulletListItemBlockSpec = createBlockSpec( }, }, [ - createBlockNoteExtension({ + createExtension({ key: "bullet-list-item-shortcuts", keyboardShortcuts: { Enter: ({ editor }) => { diff --git a/packages/core/src/blocks/ListItem/CheckListItem/block.ts b/packages/core/src/blocks/ListItem/CheckListItem/block.ts index 7887144b1b..f954e8a429 100644 --- a/packages/core/src/blocks/ListItem/CheckListItem/block.ts +++ b/packages/core/src/blocks/ListItem/CheckListItem/block.ts @@ -1,4 +1,4 @@ -import { createBlockNoteExtension } from "../../../editor/BlockNoteExtension.js"; +import { createExtension } from "../../../editor/BlockNoteExtension.js"; import { createBlockConfig, createBlockSpec } from "../../../schema/index.js"; import { addDefaultPropsExternalHTML, @@ -123,7 +123,7 @@ export const createCheckListItemBlockSpec = createBlockSpec( runsBefore: ["bulletListItem"], }, [ - createBlockNoteExtension({ + createExtension({ key: "check-list-item-shortcuts", keyboardShortcuts: { Enter: ({ editor }) => { diff --git a/packages/core/src/blocks/ListItem/NumberedListItem/block.ts b/packages/core/src/blocks/ListItem/NumberedListItem/block.ts index 70f1bcaa90..22fd14f727 100644 --- a/packages/core/src/blocks/ListItem/NumberedListItem/block.ts +++ b/packages/core/src/blocks/ListItem/NumberedListItem/block.ts @@ -1,5 +1,5 @@ import { getBlockInfoFromSelection } from "../../../api/getBlockInfoFromPos.js"; -import { createBlockNoteExtension } from "../../../editor/BlockNoteExtension.js"; +import { createExtension } from "../../../editor/BlockNoteExtension.js"; import { createBlockConfig, createBlockSpec } from "../../../schema/index.js"; import { addDefaultPropsExternalHTML, @@ -91,7 +91,7 @@ export const createNumberedListItemBlockSpec = createBlockSpec( }, }, [ - createBlockNoteExtension({ + createExtension({ key: "numbered-list-item-shortcuts", inputRules: [ { diff --git a/packages/core/src/blocks/ListItem/ToggleListItem/block.ts b/packages/core/src/blocks/ListItem/ToggleListItem/block.ts index e70da1b4b9..02b69d4e5b 100644 --- a/packages/core/src/blocks/ListItem/ToggleListItem/block.ts +++ b/packages/core/src/blocks/ListItem/ToggleListItem/block.ts @@ -1,4 +1,4 @@ -import { createBlockNoteExtension } from "../../../editor/BlockNoteExtension.js"; +import { createExtension } from "../../../editor/BlockNoteExtension.js"; import { createBlockConfig, createBlockSpec } from "../../../schema/index.js"; import { addDefaultPropsExternalHTML, @@ -50,7 +50,7 @@ export const createToggleListItemBlockSpec = createBlockSpec( }, }, [ - createBlockNoteExtension({ + createExtension({ key: "toggle-list-item-shortcuts", keyboardShortcuts: { Enter: ({ editor }) => { diff --git a/packages/core/src/blocks/Paragraph/block.ts b/packages/core/src/blocks/Paragraph/block.ts index 09a9cc9ddb..be52b6c9fc 100644 --- a/packages/core/src/blocks/Paragraph/block.ts +++ b/packages/core/src/blocks/Paragraph/block.ts @@ -1,4 +1,4 @@ -import { createBlockNoteExtension } from "../../editor/BlockNoteExtension.js"; +import { createExtension } from "../../editor/BlockNoteExtension.js"; import { createBlockConfig, createBlockSpec } from "../../schema/index.js"; import { addDefaultPropsExternalHTML, @@ -55,7 +55,7 @@ export const createParagraphBlockSpec = createBlockSpec( runsBefore: ["default"], }, [ - createBlockNoteExtension({ + createExtension({ key: "paragraph-shortcuts", keyboardShortcuts: { "Mod-Alt-0": ({ editor }) => { diff --git a/packages/core/src/blocks/Quote/block.ts b/packages/core/src/blocks/Quote/block.ts index a0f6d6cb4a..bb14afbd77 100644 --- a/packages/core/src/blocks/Quote/block.ts +++ b/packages/core/src/blocks/Quote/block.ts @@ -1,4 +1,4 @@ -import { createBlockNoteExtension } from "../../editor/BlockNoteExtension.js"; +import { createExtension } from "../../editor/BlockNoteExtension.js"; import { createBlockConfig, createBlockSpec } from "../../schema/index.js"; import { addDefaultPropsExternalHTML, @@ -54,7 +54,7 @@ export const createQuoteBlockSpec = createBlockSpec( }, }, [ - createBlockNoteExtension({ + createExtension({ key: "quote-block-shortcuts", keyboardShortcuts: { "Mod-Alt-q": ({ editor }) => { diff --git a/packages/core/src/blocks/Table/block.ts b/packages/core/src/blocks/Table/block.ts index d101c5144f..18762d7cfe 100644 --- a/packages/core/src/blocks/Table/block.ts +++ b/packages/core/src/blocks/Table/block.ts @@ -2,7 +2,7 @@ import { Node, mergeAttributes } from "@tiptap/core"; import { DOMParser, Fragment, Node as PMNode, Schema } from "prosemirror-model"; import { CellSelection, TableView } from "prosemirror-tables"; import { NodeView } from "prosemirror-view"; -import { createBlockNoteExtension } from "../../editor/BlockNoteExtension.js"; +import { createExtension } from "../../editor/BlockNoteExtension.js"; import { BlockConfig, createBlockSpecFromTiptapNode, @@ -385,7 +385,7 @@ export const createTableBlockSpec = () => { node: TiptapTableNode, type: "table", content: "table" }, tablePropSchema, [ - createBlockNoteExtension({ + createExtension({ key: "table-extensions", tiptapExtensions: [ TableExtension, @@ -399,7 +399,7 @@ export const createTableBlockSpec = () => // and all cells are selected. Uses a separate extension as it needs // priority over keyboard handlers in the `TableExtension`'s // `tableEditing` plugin. - createBlockNoteExtension({ + createExtension({ key: "table-keyboard-delete", keyboardShortcuts: { Backspace: ({ editor }) => { diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index 9a5451763e..3cb4a4d50c 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -1,22 +1,17 @@ import { - AnyExtension, + AnyExtension as AnyTiptapExtension, createDocument, EditorOptions, - Extension, FocusPosition, getSchema, - InputRule, - Mark, Editor as TiptapEditor, - Node as TipTapNode, } from "@tiptap/core"; import { type Command, type Plugin, type Transaction } from "@tiptap/pm/state"; -import { dropCursor } from "prosemirror-dropcursor"; + import { Node, Schema } from "prosemirror-model"; import * as Y from "yjs"; import type { BlocksChanged } from "../api/getBlocksChangedByTransaction.js"; -import { editorHasBlockWithType } from "../blocks/defaultBlockTypeGuards.js"; import { Block, BlockNoteSchema, @@ -26,14 +21,6 @@ import { PartialBlock, } from "../blocks/index.js"; import type { ThreadStore, User } from "../comments/index.js"; -import type { CommentsPlugin } from "../extensions/Comments/CommentsPlugin.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 type { Dictionary } from "../i18n/dictionary.js"; import { en } from "../i18n/locales/index.js"; @@ -53,13 +40,10 @@ import type { import { mergeCSSClasses } from "../util/browser.js"; import { EventEmitter } from "../util/EventEmitter.js"; import type { NoInfer } from "../util/typescript.js"; -import { BlockNoteExtension } from "./BlockNoteExtension.js"; -import { getBlockNoteExtensions } from "./BlockNoteExtensions.js"; import type { TextCursorPosition } from "./cursorPositionTypes.js"; import { BlockManager, CollaborationManager, - type CollaborationOptions, EventManager, ExportManager, ExtensionManager, @@ -70,24 +54,9 @@ import { 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"; - -/** - * 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, -) => BlockNoteExtension; - -/** - * We support Tiptap extensions and BlockNoteExtension based extensions - */ -export type SupportedExtension = AnyExtension | BlockNoteExtension; +import { Extension, ExtensionFactory } from "./BlockNoteExtension.js"; export type BlockCache< BSchema extends BlockSchema = any, @@ -95,11 +64,11 @@ export type BlockCache< SSchema extends StyleSchema = any, > = WeakMap>; -export type BlockNoteEditorOptions< +export interface 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`. * @@ -373,30 +342,15 @@ export type BlockNoteEditorOptions< */ _tiptapOptions?: Partial; - /** - * (experimental) add extra extensions to the editor - * - * @deprecated, should use `extensions` instead - * @internal - */ - _extensions?: Record< - string, - | { plugin: Plugin; priority?: number } - | ((editor: BlockNoteEditor) => { - plugin: Plugin; - priority?: number; - }) - >; - /** * Register extensions to the editor. * * See [Extensions](/docs/features/extensions) for more info. * - * @remarks `BlockNoteExtension[]` + * @remarks `Extension[]` */ - extensions?: Array; -}; + extensions?: Array; +} const blockNoteTipTapOptions = { enableInputRules: true, @@ -417,9 +371,11 @@ export class BlockNoteEditor< public readonly pmSchema: Schema; /** - * extensions that are added to the editor, can be tiptap extensions or prosemirror plugins + * BlockNote extensions that are added to the editor, keyed by the extension key */ - public extensions: Record = {}; + public extensions = new Map(); + + public tiptapExtensions: AnyTiptapExtension[] = []; public readonly _tiptapEditor: TiptapEditor & { contentComponent: any; @@ -453,56 +409,6 @@ export class BlockNoteEditor< public readonly inlineContentImplementations: InlineContentSpecs; public readonly styleImplementations: StyleSpecs; - public get formattingToolbar(): FormattingToolbarProsemirrorPlugin { - return this._extensionManager.formattingToolbar; - } - - public get linkToolbar(): LinkToolbarProsemirrorPlugin< - BSchema, - ISchema, - SSchema - > { - return this._extensionManager.linkToolbar; - } - - public get sideMenu(): SideMenuProsemirrorPlugin { - return this._extensionManager.sideMenu; - } - - public get suggestionMenus(): SuggestionMenuProseMirrorPlugin< - BSchema, - ISchema, - SSchema - > { - return this._extensionManager.suggestionMenus; - } - - public get filePanel(): - | FilePanelProsemirrorPlugin - | undefined { - return this._extensionManager.filePanel; - } - - public get tableHandles(): - | TableHandlesProsemirrorPlugin - | undefined { - return this._extensionManager.tableHandles; - } - - 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 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. @@ -554,30 +460,6 @@ export class BlockNoteEditor< >, ) { super(); - const anyOpts = options as any; - if (anyOpts.onEditorContentChange) { - throw new Error( - "onEditorContentChange initialization option is deprecated, use , the useEditorChange(...) hook, or editor.onChange(...)", - ); - } - - if (anyOpts.onTextCursorPositionChange) { - throw new Error( - "onTextCursorPositionChange initialization option is deprecated, use , 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 , or alternatively editor.isEditable = true/false", - ); - } this.dictionary = options.dictionary || en; this.settings = { @@ -608,22 +490,22 @@ 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, - }; + // 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, + newOptions, ); } else { this._collaborationManager = undefined; @@ -639,78 +521,7 @@ export class BlockNoteEditor< 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, - pasteHandler: newOptions.pasteHandler, - }), - ...this._collaborationManager?.initExtensions(), - } as any; - - // 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 as any).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; - } - })(); - }); - + // TODO this should just be an extension if (newOptions.uploadFile) { const uploadFile = newOptions.uploadFile; this.uploadFile = async (file, blockId) => { @@ -740,105 +551,14 @@ export class BlockNoteEditor< ); } - const blockExtensions = Object.fromEntries( - Object.values(this.schema.blockSpecs) - .map((block) => (block as any).extensions as any) - .filter((ext) => ext !== undefined) - .flat() - .map((ext) => [ext.key ?? ext.constructor.key(), ext]), - ); - const tiptapExtensions = [ - ...Object.entries({ ...this.extensions, ...blockExtensions }).map( - ([key, ext]) => { - if ( - ext instanceof Extension || - ext instanceof TipTapNode || - ext instanceof Mark - ) { - // tiptap extension - return ext; - } - - if (ext instanceof BlockNoteExtension) { - if ( - !ext.plugins.length && - !ext.keyboardShortcuts && - !ext.inputRules && - !ext.tiptapExtensions - ) { - return undefined; - } - // "blocknote" extensions (prosemirror plugins) - return Extension.create({ - name: key, - priority: ext.priority, - addProseMirrorPlugins: () => ext.plugins, - addExtensions: () => ext.tiptapExtensions || [], - // TODO maybe collect all input rules from all extensions into one plugin - // TODO consider using the prosemirror-inputrules package instead - addInputRules: ext.inputRules - ? () => - ext.inputRules!.map( - (inputRule) => - new InputRule({ - find: inputRule.find, - handler: ({ range, match, state }) => { - const replaceWith = inputRule.replace({ - match, - range, - editor: this, - }); - if (replaceWith) { - const cursorPosition = - this.getTextCursorPosition(); - - if ( - this.schema.blockSchema[ - cursorPosition.block.type - ].content !== "inline" - ) { - return undefined; - } - - const blockInfo = getBlockInfoFromTransaction( - state.tr, - ); - const tr = state.tr.deleteRange( - range.from, - range.to, - ); - - updateBlockTr( - tr, - blockInfo.bnBlock.beforePos, - replaceWith, - ); - return undefined; - } - return null; - }, - }), - ) - : undefined, - addKeyboardShortcuts: ext.keyboardShortcuts - ? () => { - return Object.fromEntries( - Object.entries(ext.keyboardShortcuts!).map( - ([key, value]) => [ - key, - () => value({ editor: this as any }), - ], - ), - ); - } - : undefined, - }); - } - - return undefined; - }, - ), - ].filter((ext): ext is Extension => ext !== undefined); + this._eventManager = new EventManager(this as any); + this._extensionManager = new ExtensionManager(this, newOptions); + + const tiptapExtensions = this._extensionManager.getTiptapExtensions(); + + this.tiptapExtensions = tiptapExtensions; + this.extensions = this._extensionManager.getExtensions(); + const tiptapOptions: EditorOptions = { ...blockNoteTipTapOptions, ...newOptions._tiptapOptions, @@ -922,19 +642,9 @@ export class BlockNoteEditor< // 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._stateManager = new StateManager(this as any); this._styleManager = new StyleManager(this as any); this.emit("create"); @@ -1013,21 +723,25 @@ export class BlockNoteEditor< return this._stateManager.transact(callback); } - // 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 + * Remove an extension(s) from the editor */ - public extension( - ext: { new (...args: any[]): T } & typeof BlockNoteExtension, - key = ext.key(), - ): T { - return this._extensionManager.extension(ext, key); - } + public removeExtension: ExtensionManager["unregisterExtension"] = ( + ...args: Parameters + ) => this._extensionManager.unregisterExtension(...args); + + /** + * Register an extension to the editor + */ + public registerExtension: ExtensionManager["registerExtension"] = ( + ...args: Parameters + ) => this._extensionManager.registerExtension(...args) as any; + + /** + * Get an extension from the editor + */ + public getExtension: ExtensionManager["getExtension"] = (...args) => + this._extensionManager.getExtension(...args); /** * Mount the editor to a DOM element. @@ -1047,7 +761,7 @@ export class BlockNoteEditor< return; } - this._tiptapEditor.mount({ mount: element }); + this._tiptapEditor.mount({ mount: element } as any); }; /** @@ -1100,6 +814,9 @@ export class BlockNoteEditor< return !this._tiptapEditor.isInitialized; } + /** + * Focus on the editor + */ public focus() { if (this.headless) { return; @@ -1107,6 +824,9 @@ export class BlockNoteEditor< this.prosemirrorView.focus(); } + /** + * Blur the editor + */ public blur() { if (this.headless) { return; @@ -1114,6 +834,7 @@ export class BlockNoteEditor< this.prosemirrorView.dom.blur(); } + // TODO move to extension public onUploadStart(callback: (blockId?: string) => void) { this.onUploadStartCallbacks.push(callback); @@ -1368,14 +1089,14 @@ export class BlockNoteEditor< /** * Undo the last action. */ - public undo() { + public undo(): boolean { return this._stateManager.undo(); } /** * Redo the last action. */ - public redo() { + public redo(): boolean { return this._stateManager.redo(); } @@ -1518,6 +1239,7 @@ export class BlockNoteEditor< ): string { return this._exportManager.blocksToFullHTML(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 @@ -1585,8 +1307,13 @@ export class BlockNoteEditor< getChanges(): BlocksChanged; }, ) => void, + /** + * If true, the callback will be triggered when the changes are caused by a remote user + * @default true + */ + includeUpdatesFromRemote?: boolean, ) { - return this._eventManager.onChange(callback); + return this._eventManager.onChange(callback, includeUpdatesFromRemote); } /** @@ -1605,6 +1332,22 @@ export class BlockNoteEditor< ); } + /** + * Gets the bounding box of a block. + * @param blockId The id of the block to get the bounding box of. + * @returns The bounding box of the block or undefined if the block is not found. + */ + public getBlockClientRect(blockId: string): DOMRect | undefined { + const blockElement = this.prosemirrorView.root.querySelector( + `[data-node-type="blockContainer"][data-id="${blockId}"]`, + ); + if (!blockElement) { + return; + } + + return blockElement.getBoundingClientRect(); + } + /** * A callback function that runs when the editor has been initialized. * @@ -1673,42 +1416,30 @@ export class BlockNoteEditor< ); } - public openSuggestionMenu( - triggerCharacter: string, - pluginState?: { - deleteTriggerCharacter?: boolean; - ignoreQueryLength?: boolean; - }, - ) { - if (!this.prosemirrorView) { - return; - } - - this.focus(); - this.transact((tr) => { - if (pluginState?.deleteTriggerCharacter) { - tr.insertText(triggerCharacter); - } - tr.scrollIntoView().setMeta(this.suggestionMenus.plugins[0], { - triggerCharacter: triggerCharacter, - deleteTriggerCharacter: pluginState?.deleteTriggerCharacter || false, - ignoreQueryLength: pluginState?.ignoreQueryLength || false, - }); - }); - } - - // `forceSelectionVisible` determines whether the editor selection is shows - // even when the editor is not focused. This is useful for e.g. creating new - // links, so the user still sees the affected content when an input field is - // focused. - // TODO: Reconsider naming? - public getForceSelectionVisible() { - return this.showSelectionPlugin.getEnabled(); - } - - public setForceSelectionVisible(forceSelectionVisible: boolean) { - this.showSelectionPlugin.setEnabled(forceSelectionVisible); - } + // TODO move to extension + // public openSuggestionMenu( + // triggerCharacter: string, + // pluginState?: { + // deleteTriggerCharacter?: boolean; + // ignoreQueryLength?: boolean; + // }, + // ) { + // if (!this.prosemirrorView) { + // return; + // } + + // this.focus(); + // this.transact((tr) => { + // if (pluginState?.deleteTriggerCharacter) { + // tr.insertText(triggerCharacter); + // } + // tr.scrollIntoView().setMeta(this.suggestionMenus.plugins[0], { + // triggerCharacter: triggerCharacter, + // deleteTriggerCharacter: pluginState?.deleteTriggerCharacter || false, + // ignoreQueryLength: pluginState?.ignoreQueryLength || false, + // }); + // }); + // } /** * Paste HTML into the editor. Defaults to converting HTML to BlockNote HTML. diff --git a/packages/core/src/editor/BlockNoteExtension.ts b/packages/core/src/editor/BlockNoteExtension.ts index f56d6c736a..d1e544df06 100644 --- a/packages/core/src/editor/BlockNoteExtension.ts +++ b/packages/core/src/editor/BlockNoteExtension.ts @@ -1,42 +1,59 @@ -import { Plugin } from "prosemirror-state"; -import { EventEmitter } from "../util/EventEmitter.js"; - -import { AnyExtension } from "@tiptap/core"; -import { - BlockSchema, - InlineContentSchema, - PartialBlockNoDefaults, - StyleSchema, -} from "../schema/index.js"; -import { BlockNoteEditor } from "./BlockNoteEditor.js"; - -export abstract class BlockNoteExtension< - TEvent extends Record = any, -> extends EventEmitter { - public static key(): string { - throw new Error("You must implement the key method in your extension"); - } +import { Store, StoreOptions } from "@tanstack/store"; +import { type AnyExtension } from "@tiptap/core"; +import type { Plugin } from "prosemirror-state"; +import type { PartialBlockNoDefaults } from "../schema/index.js"; +import type { + BlockNoteEditor, + BlockNoteEditorOptions, +} from "./BlockNoteEditor.js"; - protected addProsemirrorPlugin(plugin: Plugin) { - this.plugins.push(plugin); - } +/** + * This function is called when the extension is destroyed. + */ +type OnDestroy = () => void; - public readonly plugins: Plugin[] = []; - public get priority(): number | undefined { - return undefined; - } +/** + * Describes a BlockNote extension. + */ +export interface Extension { + /** + * The unique identifier for the extension. + */ + readonly key: Key; - // eslint-disable-next-line - constructor(..._args: any[]) { - super(); - // Allow subclasses to have constructors with parameters - // without this, we can't easily implement BlockNoteEditor.extension(MyExtension) pattern - } + /** + * Triggered when the extension is mounted to the editor. + */ + readonly init?: (ctx: { + /** + * The DOM element that the editor is mounted to. + */ + dom: HTMLElement; + /** + * The root document of the {@link document} that the editor is mounted to. + */ + root: Document | ShadowRoot; + /** + * An {@link AbortController} that will be aborted when the extension is destroyed. + */ + abortController: AbortController; + }) => void | OnDestroy; /** - * Input rules for the block + * The store for the extension. */ - public inputRules?: InputRule[]; + readonly store?: Store; + + /** + * Declares what {@link Extension}s that this extension depends on. + */ + readonly runsBefore?: ReadonlyArray; + + /** + * Input rules for a block: An input rule is what is used to replace text in a block when a regular expression match is found. + * As an example, typing `#` in a paragraph block will trigger an input rule to replace the text with a heading block. + */ + readonly inputRules?: ReadonlyArray; /** * A mapping of a keyboard shortcut to a function that will be called when the shortcut is pressed @@ -60,17 +77,27 @@ export abstract class BlockNoteExtension< * } * ``` */ - public keyboardShortcuts?: Record< + readonly keyboardShortcuts?: Record< string, - (ctx: { - editor: BlockNoteEditor; - }) => boolean + (ctx: { editor: BlockNoteEditor }) => boolean >; - public tiptapExtensions?: AnyExtension[]; + /** + * Add additional prosemirror plugins to the editor. + */ + readonly plugins?: ReadonlyArray; + + /** + * Add additional tiptap extensions to the editor. + */ + readonly tiptapExtensions?: ReadonlyArray; } -export type InputRule = { +/** + * An input rule is what is used to replace text in a block when a regular expression match is found. + * As an example, typing `#` in a paragraph block will trigger an input rule to replace the text with a heading block. + */ +type InputRule = { /** * The regex to match when to trigger the input rule */ @@ -96,23 +123,83 @@ export type InputRule = { }) => undefined | PartialBlockNoDefaults; }; +export type ExtensionFactory< + State = any, + Options extends BlockNoteEditorOptions< + any, + any, + any + > = BlockNoteEditorOptions, + Key extends string = string, +> = ( + editor: BlockNoteEditor, + options: Options, +) => Extension | undefined; + +export type ExtractExtensionKey = + T extends ( + editor: BlockNoteEditor, + options: BlockNoteEditorOptions, + ) => Extension | undefined + ? Key + : never; + +export type ExtractExtensionByKey< + T extends ExtensionFactory, + Key extends string, +> = T extends ( + editor: BlockNoteEditor, + options: BlockNoteEditorOptions, +) => Extension | undefined + ? ReturnType + : never; + +// a type that maps the extension key to the return type of the extension factory +export type ExtensionMap< + T extends ReadonlyArray, +> = { + [K in ExtractExtensionKey]: ExtractExtensionByKey< + Exclude, + K + >; +}; + /** - * This creates an instance of a BlockNoteExtension that can be used to add to a schema. - * It is a bit of a hack, but it works. + * Helper function to create a BlockNote extension. + * Can accept either an ExtensionFactory (function) or an Extension (object). + * If an Extension is provided, it will be wrapped in a factory function. */ -export function createBlockNoteExtension( - options: Partial< - Pick< - BlockNoteExtension, - "inputRules" | "keyboardShortcuts" | "plugins" | "tiptapExtensions" - > - > & { key: string }, -) { - const x = Object.create(BlockNoteExtension.prototype); - x.key = options.key; - x.inputRules = options.inputRules; - x.keyboardShortcuts = options.keyboardShortcuts; - x.plugins = options.plugins ?? []; - x.tiptapExtensions = options.tiptapExtensions; - return x as BlockNoteExtension; +export function createExtension(ext: T): T; +export function createExtension( + ext: Extension, +): () => Extension; +export function createExtension< + const T extends ExtensionFactory | Extension | undefined = any, +>( + extension: T, +): T extends Extension + ? () => T + : T extends ExtensionFactory + ? T + : () => undefined { + if (typeof extension === "function") { + return extension as any; + } + + if (typeof extension === "object" && "key" in extension) { + return (() => extension) as any; + } + + if (!extension) { + return (() => undefined) as any; + } + + throw new Error("Invalid extension", { cause: { extension } }); +} + +export function createStore( + initialState: T, + options?: StoreOptions, +): Store { + return new Store(initialState, options); } diff --git a/packages/core/src/editor/BlockNoteExtensions.ts b/packages/core/src/editor/BlockNoteExtensions.ts deleted file mode 100644 index 754c90b428..0000000000 --- a/packages/core/src/editor/BlockNoteExtensions.ts +++ /dev/null @@ -1,325 +0,0 @@ -import { AnyExtension, Extension, extensions, Node } from "@tiptap/core"; -import { Gapcursor } from "@tiptap/extension-gapcursor"; -import { History } from "@tiptap/extension-history"; -import { Link } from "@tiptap/extension-link"; -import { Text } from "@tiptap/extension-text"; -import { Plugin } from "prosemirror-state"; -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, 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"; -import { ForkYDocPlugin } from "../extensions/Collaboration/ForkYDocPlugin.js"; -import { SyncPlugin } from "../extensions/Collaboration/SyncPlugin.js"; -import { UndoPlugin } from "../extensions/Collaboration/UndoPlugin.js"; -import { SchemaMigrationPlugin } from "../extensions/Collaboration/schemaMigration/SchemaMigrationPlugin.js"; -import { CommentMark } from "../extensions/Comments/CommentMark.js"; -import { CommentsPlugin } from "../extensions/Comments/CommentsPlugin.js"; -import { FilePanelProsemirrorPlugin } from "../extensions/FilePanel/FilePanelPlugin.js"; -import { FormattingToolbarProsemirrorPlugin } from "../extensions/FormattingToolbar/FormattingToolbarPlugin.js"; -import { HardBreak } from "../extensions/HardBreak/HardBreak.js"; -import { KeyboardShortcutsExtension } from "../extensions/KeyboardShortcuts/KeyboardShortcutsExtension.js"; -import { LinkToolbarProsemirrorPlugin } from "../extensions/LinkToolbar/LinkToolbarPlugin.js"; -import { - DEFAULT_LINK_PROTOCOL, - VALID_LINK_PROTOCOLS, -} from "../extensions/LinkToolbar/protocols.js"; -import { NodeSelectionKeyboardPlugin } from "../extensions/NodeSelectionKeyboard/NodeSelectionKeyboardPlugin.js"; -import { PlaceholderPlugin } from "../extensions/Placeholder/PlaceholderPlugin.js"; -import { PreviousBlockTypePlugin } from "../extensions/PreviousBlockType/PreviousBlockTypePlugin.js"; -import { ShowSelectionPlugin } from "../extensions/ShowSelection/ShowSelectionPlugin.js"; -import { SideMenuProsemirrorPlugin } from "../extensions/SideMenu/SideMenuPlugin.js"; -import { SuggestionMenuProseMirrorPlugin } from "../extensions/SuggestionMenu/SuggestionPlugin.js"; -import { - SuggestionAddMark, - SuggestionDeleteMark, - SuggestionModificationMark, -} from "../extensions/Suggestions/SuggestionMarks.js"; -import { TableHandlesProsemirrorPlugin } from "../extensions/TableHandles/TableHandlesPlugin.js"; -import { TextAlignmentExtension } from "../extensions/TextAlignment/TextAlignmentExtension.js"; -import { TextColorExtension } from "../extensions/TextColor/TextColorExtension.js"; -import { TrailingNode } from "../extensions/TrailingNode/TrailingNodeExtension.js"; -import UniqueID from "../extensions/UniqueID/UniqueID.js"; -import { BlockContainer, BlockGroup, Doc } from "../pm-nodes/index.js"; -import { - BlockNoteDOMAttributes, - BlockSchema, - BlockSpecs, - InlineContentSchema, - InlineContentSpecs, - StyleSchema, - StyleSpecs, -} from "../schema/index.js"; -import type { - BlockNoteEditor, - BlockNoteEditorOptions, - SupportedExtension, -} from "./BlockNoteEditor.js"; -import { BlockNoteSchema } from "../blocks/BlockNoteSchema.js"; - -type ExtensionOptions< - BSchema extends BlockSchema, - I extends InlineContentSchema, - S extends StyleSchema, -> = { - editor: BlockNoteEditor; - domAttributes: Partial; - blockSpecs: BlockSpecs; - inlineContentSpecs: InlineContentSpecs; - styleSpecs: StyleSpecs; - trailingBlock: boolean | undefined; - collaboration?: { - fragment: Y.XmlFragment; - user: { - name: string; - color: string; - [key: string]: string; - }; - provider: any; - renderCursor?: (user: any) => HTMLElement; - showCursorLabels?: "always" | "activity"; - }; - disableExtensions: string[] | undefined; - setIdAttribute?: boolean; - animations: boolean; - tableHandles: boolean; - dropCursor: (opts: any) => Plugin; - placeholders: Record< - string | "default" | "emptyDocument", - string | undefined - >; - tabBehavior?: "prefer-navigate-ui" | "prefer-indent"; - comments?: { - schema?: BlockNoteSchema; - threadStore: ThreadStore; - resolveUsers?: (userIds: string[]) => Promise; - }; - pasteHandler: BlockNoteEditorOptions["pasteHandler"]; -}; - -/** - * Get all the Tiptap extensions BlockNote is configured with by default - */ -export const getBlockNoteExtensions = < - BSchema extends BlockSchema, - I extends InlineContentSchema, - S extends StyleSchema, ->( - opts: ExtensionOptions, -) => { - const ret: Record = {}; - const tiptapExtensions = getTipTapExtensions(opts); - - for (const ext of tiptapExtensions) { - ret[ext.name] = ext; - } - - if (opts.collaboration) { - ret["ySyncPlugin"] = new SyncPlugin(opts.collaboration.fragment); - ret["yUndoPlugin"] = new UndoPlugin({ editor: opts.editor }); - - if (opts.collaboration.provider?.awareness) { - ret["yCursorPlugin"] = new CursorPlugin(opts.collaboration); - } - ret["forkYDocPlugin"] = new ForkYDocPlugin({ - editor: opts.editor, - collaboration: opts.collaboration, - }); - ret["schemaMigrationPlugin"] = new SchemaMigrationPlugin( - opts.collaboration.fragment, - ); - } - - // Note: this is pretty hardcoded and will break when user provides plugins with same keys. - // Define name on plugins instead and not make this a map? - ret["formattingToolbar"] = new FormattingToolbarProsemirrorPlugin( - opts.editor, - ); - ret["linkToolbar"] = new LinkToolbarProsemirrorPlugin(opts.editor); - ret["sideMenu"] = new SideMenuProsemirrorPlugin(opts.editor); - ret["suggestionMenus"] = new SuggestionMenuProseMirrorPlugin(opts.editor); - ret["filePanel"] = new FilePanelProsemirrorPlugin(opts.editor as any); - ret["placeholder"] = new PlaceholderPlugin(opts.editor, opts.placeholders); - - if (opts.animations ?? true) { - ret["animations"] = new PreviousBlockTypePlugin(); - } - - if (opts.tableHandles) { - ret["tableHandles"] = new TableHandlesProsemirrorPlugin(opts.editor as any); - } - - ret["nodeSelectionKeyboard"] = new NodeSelectionKeyboardPlugin(); - ret["blockChange"] = new BlockChangePlugin(); - - ret["showSelection"] = new ShowSelectionPlugin(opts.editor); - - if (opts.comments) { - ret["comments"] = new CommentsPlugin( - opts.editor, - opts.comments.threadStore, - CommentMark.name, - opts.comments.resolveUsers, - opts.comments.schema, - ); - } - - const disableExtensions: string[] = opts.disableExtensions || []; - for (const ext of disableExtensions) { - delete ret[ext]; - } - - return ret; -}; - -let LINKIFY_INITIALIZED = false; - -/** - * Get all the Tiptap extensions BlockNote is configured with by default - */ -const getTipTapExtensions = < - BSchema extends BlockSchema, - I extends InlineContentSchema, - S extends StyleSchema, ->( - opts: ExtensionOptions, -) => { - const tiptapExtensions: AnyExtension[] = [ - extensions.ClipboardTextSerializer, - extensions.Commands, - extensions.Editable, - extensions.FocusEvents, - extensions.Tabindex, - - // DevTools, - Gapcursor, - - // DropCursor, - Extension.create({ - name: "dropCursor", - addProseMirrorPlugins: () => [ - opts.dropCursor({ - width: 5, - color: "#ddeeff", - editor: opts.editor, - }), - ], - }), - - UniqueID.configure({ - // everything from bnBlock group (nodes that represent a BlockNote block should have an id) - types: ["blockContainer", "columnList", "column"], - setIdAttribute: opts.setIdAttribute, - }), - HardBreak, - // Comments, - - // basics: - Text, - - // marks: - SuggestionAddMark, - SuggestionDeleteMark, - SuggestionModificationMark, - Link.extend({ - inclusive: false, - }).configure({ - defaultProtocol: DEFAULT_LINK_PROTOCOL, - // only call this once if we have multiple editors installed. Or fix https://github.com/ueberdosis/tiptap/issues/5450 - protocols: LINKIFY_INITIALIZED ? [] : VALID_LINK_PROTOCOLS, - }), - ...(Object.values(opts.styleSpecs).map((styleSpec) => { - return styleSpec.implementation.mark.configure({ - editor: opts.editor as any, - }); - }) as any[]), - - TextColorExtension, - - BackgroundColorExtension, - TextAlignmentExtension, - - // make sure escape blurs editor, so that we can tab to other elements in the host page (accessibility) - Extension.create({ - name: "OverrideEscape", - addKeyboardShortcuts() { - return { - Escape: () => { - if (opts.editor.suggestionMenus.shown) { - // escape is handled by suggestionmenu - return false; - } - return this.editor.commands.blur(); - }, - }; - }, - }), - - // nodes - Doc, - BlockContainer.configure({ - editor: opts.editor, - domAttributes: opts.domAttributes, - }), - KeyboardShortcutsExtension.configure({ - editor: opts.editor, - tabBehavior: opts.tabBehavior, - }), - BlockGroup.configure({ - domAttributes: opts.domAttributes, - }), - ...Object.values(opts.inlineContentSpecs) - .filter((a) => a.config !== "link" && a.config !== "text") - .map((inlineContentSpec) => { - return inlineContentSpec.implementation!.node.configure({ - editor: opts.editor as any, - }); - }), - - ...Object.values(opts.blockSpecs).flatMap((blockSpec) => { - return [ - // the node extension implementations - ...("node" in blockSpec.implementation - ? [ - (blockSpec.implementation.node as Node).configure({ - editor: opts.editor, - domAttributes: opts.domAttributes, - }), - ] - : []), - ]; - }), - createCopyToClipboardExtension(opts.editor), - createPasteFromClipboardExtension( - opts.editor, - opts.pasteHandler || - ((context: { - defaultPasteHandler: (context?: { - prioritizeMarkdownOverHTML?: boolean; - plainTextAsMarkdown?: boolean; - }) => boolean | undefined; - }) => context.defaultPasteHandler()), - ), - createDropFileExtension(opts.editor), - - // This needs to be at the bottom of this list, because Key events (such as enter, when selecting a /command), - // should be handled before Enter handlers in other components like splitListItem - ...(opts.trailingBlock === undefined || opts.trailingBlock - ? [TrailingNode] - : []), - ...(opts.comments ? [CommentMark] : []), - ]; - - LINKIFY_INITIALIZED = true; - - if (!opts.collaboration) { - // disable history extension when collaboration is enabled as y-prosemirror takes care of undo / redo - tiptapExtensions.push(History); - } - - return tiptapExtensions; -}; diff --git a/packages/core/src/editor/managers/CollaborationManager.ts b/packages/core/src/editor/managers/CollaborationManager.ts index 8273fb5cb4..8d2b5a19a2 100644 --- a/packages/core/src/editor/managers/CollaborationManager.ts +++ b/packages/core/src/editor/managers/CollaborationManager.ts @@ -1,55 +1,11 @@ -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 type { User } from "../../comments/index.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"; +import type { + BlockNoteEditor, + BlockNoteEditorOptions, +} from "../BlockNoteEditor.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; -} +// TODO remove this manager completely /** * CollaborationManager handles all collaboration-related functionality @@ -57,98 +13,21 @@ export interface CollaborationOptions { */ export class CollaborationManager { private editor: BlockNoteEditor; - private options: CollaborationOptions; - private _commentsPlugin?: CommentsPlugin; - private _forkYDocPlugin?: ForkYDocPlugin; - private _syncPlugin?: SyncPlugin; - private _undoPlugin?: UndoPlugin; - private _cursorPlugin?: CursorPlugin; + private options: BlockNoteEditorOptions; - constructor(editor: BlockNoteEditor, options: CollaborationOptions) { + constructor( + editor: BlockNoteEditor, + options: BlockNoteEditorOptions, + ) { 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; + const cursor = this.editor.getExtension(CursorPlugin); if (!cursor) { throw new Error( "Cannot update collaboration user info when collaboration is disabled.", @@ -157,41 +36,13 @@ export class CollaborationManager { 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; + // (i.e., when we have a fragment) + return !!this.options.collaboration?.fragment; } /** diff --git a/packages/core/src/editor/managers/EventManager.ts b/packages/core/src/editor/managers/EventManager.ts index abb2d1f3fb..c69a13fbb1 100644 --- a/packages/core/src/editor/managers/EventManager.ts +++ b/packages/core/src/editor/managers/EventManager.ts @@ -16,13 +16,10 @@ export type Unsubscribe = () => void; */ export class EventManager extends EventEmitter<{ onChange: [ - editor: Editor, ctx: { - getChanges(): BlocksChanged< - Editor["schema"]["blockSchema"], - Editor["schema"]["inlineContentSchema"], - Editor["schema"]["styleSchema"] - >; + editor: Editor; + transaction: Transaction; + appendedTransactions: Transaction[]; }, ]; onSelectionChange: [ctx: { editor: Editor; transaction: Transaction }]; @@ -37,14 +34,7 @@ export class EventManager extends EventEmitter<{ editor._tiptapEditor.on( "update", ({ transaction, appendedTransactions }) => { - this.emit("onChange", editor, { - getChanges() { - return getBlocksChangedByTransaction( - transaction, - appendedTransactions, - ); - }, - }); + this.emit("onChange", { editor, transaction, appendedTransactions }); }, ); editor._tiptapEditor.on("selectionUpdate", ({ transaction }) => { @@ -73,11 +63,36 @@ export class EventManager extends EventEmitter<{ >; }, ) => void, + /** + * If true, the callback will be triggered when the changes are caused by a remote user + * @default true + */ + includeUpdatesFromRemote = true, ): Unsubscribe { - this.on("onChange", callback); + const cb = ({ + transaction, + appendedTransactions, + }: { + transaction: Transaction; + appendedTransactions: Transaction[]; + }) => { + if (!includeUpdatesFromRemote && isRemoteTransaction(transaction)) { + // don't trigger the callback if the changes are caused by a remote user + return; + } + callback(this.editor, { + getChanges() { + return getBlocksChangedByTransaction( + transaction, + appendedTransactions, + ); + }, + }); + }; + this.on("onChange", cb); return () => { - this.off("onChange", callback); + this.off("onChange", cb); }; } @@ -93,11 +108,10 @@ export class EventManager extends EventEmitter<{ ): Unsubscribe { const cb = (e: { transaction: Transaction }) => { if ( - e.transaction.getMeta("$y-sync") && - !includeSelectionChangedByRemote + !includeSelectionChangedByRemote && + isRemoteTransaction(e.transaction) ) { - // selection changed because of a yjs sync (i.e.: other user was typing) - // we don't want to trigger the callback in this case + // don't trigger the callback if the selection changed because of a remote user return; } callback(this.editor); @@ -132,3 +146,7 @@ export class EventManager extends EventEmitter<{ }; } } + +function isRemoteTransaction(transaction: Transaction): boolean { + return !!transaction.getMeta("$y-sync"); +} diff --git a/packages/core/src/editor/managers/ExtensionManager.ts b/packages/core/src/editor/managers/ExtensionManager.ts index 4d35b68c17..2e5b6b5d37 100644 --- a/packages/core/src/editor/managers/ExtensionManager.ts +++ b/packages/core/src/editor/managers/ExtensionManager.ts @@ -1,130 +1,585 @@ -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"; +import { + AnyExtension as AnyTiptapExtension, + extensions, + Node, + Extension as TiptapExtension, +} from "@tiptap/core"; +import { Gapcursor } from "@tiptap/extension-gapcursor"; +import { Link } from "@tiptap/extension-link"; +import { Text } from "@tiptap/extension-text"; +import { updateBlockTr } from "../../api/blockManipulation/commands/updateBlock/updateBlock.js"; +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 { getBlockInfoFromTransaction } from "../../api/getBlockInfoFromPos.js"; +import { BackgroundColorExtension } from "../../extensions/BackgroundColor/BackgroundColorExtension.js"; +import { HardBreak } from "../../extensions/HardBreak/HardBreak.js"; +import { DEFAULT_EXTENSIONS } from "../../extensions/index.js"; +import { KeyboardShortcutsExtension } from "../../extensions/KeyboardShortcuts/KeyboardShortcutsExtension.js"; +import { + DEFAULT_LINK_PROTOCOL, + VALID_LINK_PROTOCOLS, +} from "../../extensions/LinkToolbar/protocols.js"; +import { SuggestionMenuPlugin } from "../../extensions/SuggestionMenu/SuggestionPlugin.js"; +import { + SuggestionAddMark, + SuggestionDeleteMark, + SuggestionModificationMark, +} from "../../extensions/Suggestions/SuggestionMarks.js"; +import { TextAlignmentExtension } from "../../extensions/TextAlignment/TextAlignmentExtension.js"; +import { TextColorExtension } from "../../extensions/TextColor/TextColorExtension.js"; +import UniqueID from "../../extensions/UniqueID/UniqueID.js"; +import { BlockContainer, BlockGroup, Doc } from "../../pm-nodes/index.js"; +import type { + BlockNoteEditor, + BlockNoteEditorOptions, +} from "../BlockNoteEditor.js"; +import type { Extension, ExtensionFactory } from "../BlockNoteExtension.js"; +import { sortByDependencies } from "../../util/topo-sort.js"; +import { Plugin } from "prosemirror-state"; +import { keymap } from "@tiptap/pm/keymap"; +import { InputRule, inputRules } from "@handlewithcare/prosemirror-inputrules"; -export class ExtensionManager { - constructor(private editor: BlockNoteEditor) {} +// TODO remove linkify completely by vendoring the link extension & dropping linkifyjs as a dependency +let LINKIFY_INITIALIZED = false; +export class ExtensionManager { /** - * 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 + * A set of extension keys which are disabled by the options */ - 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`); + private disabledExtensions = new Set(); + /** + * A list of all the extensions that are registered to the editor + */ + private extensions: Extension[] = []; + /** + * A map of all the abort controllers for each extension that has an init method defined + */ + private abortMap = new Map(); + /** + * A map of all the extension factories that are registered to the editor + */ + private extensionFactories = new Map(); + /** + * Because a single blocknote extension can both have it's own prosemirror plugins & additional generated ones (e.g. keymap & input rules plugins) + * We need to keep track of all the plugins for each extension, so that we can remove them when the extension is unregistered + */ + private extensionPlugins: Map = new Map(); + + constructor( + private editor: BlockNoteEditor, + private options: BlockNoteEditorOptions, + ) { + /** + * When the editor is first mounted, we need to initialize all the extensions + */ + editor.onMount(() => { + for (const extension of this.extensions) { + // If the extension has an init function, we can initialize it, otherwise, it is already added to the editor + if (extension.init) { + // We create an abort controller for each extension, so that we can abort the extension when the editor is unmounted + const abortController = new AbortController(); + const unmountCallback = extension.init({ + dom: editor.prosemirrorView.dom, + root: editor.prosemirrorView.root, + abortController, + }); + // If the extension returns a method to unmount it, we can register it to be called when the abort controller is aborted + if (unmountCallback) { + abortController.signal.addEventListener("abort", () => { + unmountCallback(); + }); + } + // Keep track of the abort controller for each extension, so that we can abort it when the editor is unmounted + this.abortMap.set(extension, abortController); + } + } + }); + + /** + * When the editor is unmounted, we need to abort all the extensions' abort controllers + */ + editor.onUnmount(() => { + for (const [extension, abortController] of this.abortMap.entries()) { + // No longer track the abort controller for this extension + this.abortMap.delete(extension); + // Abort each extension's abort controller + abortController.abort(); + } + }); + + // TODO do disabled extensions need to be only for editor base extensions? Or all of them? + this.disabledExtensions = new Set(options.disableExtensions || []); + + // Add the default extensions + for (const extension of DEFAULT_EXTENSIONS) { + this.addExtension(extension); + } + + // Add the extensions from the options + for (const extension of this.options.extensions ?? []) { + this.addExtension(extension); + } + + // Add the extensions from blocks specs + for (const block of Object.values(this.editor.schema.blockSpecs)) { + for (const extension of block.extensions ?? []) { + this.addExtension(extension); + } } - return extension; } /** - * Get all extensions + * Register one or more extensions to the editor */ - public getExtensions() { - return this.editor.extensions; + public registerExtension( + extension: + | undefined + | string + | Extension + | ExtensionFactory + | (Extension | ExtensionFactory | string | undefined)[], + ): void { + const extensions = this.resolveExtensions(extension); + if (!extensions.length) { + // eslint-disable-next-line no-console + console.warn(`No extensions found to register`, extension); + return; + } + + const registeredExtensions = extensions.map((extension) => + this.addExtension(extension), + ); + + const pluginsToAdd = new Set(); + for (const extension of registeredExtensions) { + if (extension?.tiptapExtensions) { + // eslint-disable-next-line no-console + console.warn( + `Extension ${extension.key} has tiptap extensions, but they will not be added to the editor. Please separate the extension into multiple extensions if you want to add them, or re-initialize the editor.`, + extension, + ); + } + + this.getProsemirrorPluginsFromExtension(extension).forEach((plugin) => { + pluginsToAdd.add(plugin); + }); + } + + // TODO there isn't a great way to do sorting right now. This is something that should be improved in the future. + this.updatePlugins((plugins) => [...plugins, ...pluginsToAdd]); } /** - * Get a specific extension by key + * Register an extension to the editor + * @param extension - The extension to register + * @returns The extension instance */ - public getExtension(key: string) { - return this.editor.extensions[key]; + private addExtension( + extension: T, + ): T extends ExtensionFactory + ? ReturnType + : T extends Extension | undefined + ? T + : never { + let instance: Extension | undefined; + if (typeof extension === "function") { + instance = extension(this.editor, this.options); + } else { + instance = extension; + } + + if (!instance || this.disabledExtensions.has(instance.key)) { + return undefined as any; + } + + // Now that we know that the extension is not disabled, we can add it to the extension factories + if (typeof extension === "function") { + this.extensionFactories.set(extension, instance); + } + + this.extensions.push(instance); + + return instance as any; } /** - * Check if an extension exists + * Resolve an extension or a list of extensions into a list of extension instances + * @param toResolve - The extension or list of extensions to resolve + * @returns A list of extension instances */ - public hasExtension(key: string): boolean { - return key in this.editor.extensions; + private resolveExtensions( + toResolve: + | undefined + | string + | Extension + | ExtensionFactory + | (Extension | ExtensionFactory | string | undefined)[], + ): Extension[] { + const extensions = [] as Extension[]; + if (typeof toResolve === "function") { + const instance = this.extensionFactories.get(toResolve); + if (instance) { + extensions.push(instance); + } + } else if (Array.isArray(toResolve)) { + for (const extension of toResolve) { + extensions.push(...this.resolveExtensions(extension)); + } + } else if (typeof toResolve === "object" && "key" in toResolve) { + extensions.push(toResolve); + } else if (typeof toResolve === "string") { + const instance = this.extensions.find((e) => e.key === toResolve); + if (instance) { + extensions.push(instance); + } + } + return extensions; } - // Plugin getters - these provide access to the core BlockNote plugins - /** - * Get the formatting toolbar plugin + * Unregister an extension from the editor + * @param toUnregister - The extension to unregister + * @returns void */ - public get formattingToolbar(): FormattingToolbarProsemirrorPlugin { - return this.editor.extensions[ - "formattingToolbar" - ] as FormattingToolbarProsemirrorPlugin; + public unregisterExtension( + toUnregister: + | undefined + | string + | Extension + | ExtensionFactory + | (Extension | ExtensionFactory | string | undefined)[], + ): void { + const extensions = this.resolveExtensions(toUnregister); + + if (!extensions.length) { + // eslint-disable-next-line no-console + console.warn(`No extensions found to unregister`, toUnregister); + return; + } + let didWarn = false; + + const pluginsToRemove = new Set(); + for (const extension of extensions) { + this.extensions = this.extensions.filter((e) => e !== extension); + this.extensionFactories.entries().forEach(([factory, instance]) => { + if (instance === extension) { + this.extensionFactories.delete(factory); + } + }); + this.abortMap.get(extension)?.abort(); + this.abortMap.delete(extension); + + const plugins = this.extensionPlugins.get(extension); + plugins?.forEach((plugin) => { + pluginsToRemove.add(plugin); + }); + this.extensionPlugins.delete(extension); + + if (extension.tiptapExtensions && !didWarn) { + didWarn = true; + // eslint-disable-next-line no-console + console.warn( + `Extension ${extension.key} has tiptap extensions, but they will not be removed. Please separate the extension into multiple extensions if you want to remove them, or re-initialize the editor.`, + toUnregister, + ); + } + } + + this.updatePlugins((plugins) => + plugins.filter((plugin) => !pluginsToRemove.has(plugin)), + ); } /** - * Get the link toolbar plugin + * Allows resetting the current prosemirror state's plugins + * @param update - A function that takes the current plugins and returns the new plugins + * @returns void */ - public get linkToolbar(): LinkToolbarProsemirrorPlugin { - return this.editor.extensions[ - "linkToolbar" - ] as LinkToolbarProsemirrorPlugin; + private updatePlugins(update: (plugins: Plugin[]) => Plugin[]): void { + const currentState = this.editor.prosemirrorState; + + const state = currentState.reconfigure({ + plugins: update(currentState.plugins.slice()), + }); + + this.editor.prosemirrorView.updateState(state); } /** - * Get the side menu plugin + * Get all the extensions that are registered to the editor */ - public get sideMenu(): SideMenuProsemirrorPlugin { - return this.editor.extensions["sideMenu"] as SideMenuProsemirrorPlugin< - any, - any, - any - >; + public getTiptapExtensions(): AnyTiptapExtension[] { + // Start with the default tiptap extensions + const tiptapExtensions = this.getDefaultTiptapExtensions(); + // TODO filter out the default extensions via the disabledExtensions set? + + const getPriority = sortByDependencies(this.extensions); + + for (const extension of this.extensions) { + if (extension.tiptapExtensions) { + tiptapExtensions.push(...extension.tiptapExtensions); + } + + const prosemirrorPlugins = + this.getProsemirrorPluginsFromExtension(extension); + // Sometimes a blocknote extension might need to make additional prosemirror plugins, so we generate them here + if (prosemirrorPlugins.length) { + tiptapExtensions.push( + TiptapExtension.create({ + name: extension.key, + priority: getPriority(extension.key), + addProseMirrorPlugins: () => prosemirrorPlugins, + }), + ); + } + } + + // Add any tiptap extensions from the `_tiptapOptions` + for (const extension of this.options._tiptapOptions?.extensions ?? []) { + tiptapExtensions.push(extension); + } + + return tiptapExtensions; } /** - * Get the suggestion menus plugin + * This maps a blocknote extension into an array of Prosemirror plugins if it has any of the following: + * - plugins + * - keyboard shortcuts + * - input rules */ - public get suggestionMenus(): SuggestionMenuProseMirrorPlugin { - return this.editor.extensions[ - "suggestionMenus" - ] as SuggestionMenuProseMirrorPlugin; + private getProsemirrorPluginsFromExtension(extension: Extension): Plugin[] { + if ( + !extension.plugins?.length && + !extension.keyboardShortcuts?.length && + !extension.inputRules?.length + ) { + // We can bail out early if the extension has no features to add to the tiptap editor + return []; + } + + const plugins: Plugin[] = [...(extension.plugins ?? [])]; + + this.extensionPlugins.set(extension, plugins); + + if (extension.inputRules?.length) { + plugins.push( + inputRules({ + rules: extension.inputRules.map((inputRule) => { + return new InputRule(inputRule.find, (state, match, start, end) => { + const replaceWith = inputRule.replace({ + match, + range: { from: start, to: end }, + editor: this.editor, + }); + if (replaceWith) { + const cursorPosition = this.editor.getTextCursorPosition(); + + if ( + this.editor.schema.blockSchema[cursorPosition.block.type] + .content !== "inline" + ) { + return null; + } + + const blockInfo = getBlockInfoFromTransaction(state.tr); + const tr = state.tr.deleteRange(start, end); + + updateBlockTr(tr, blockInfo.bnBlock.beforePos, replaceWith); + return tr; + } + return null; + }); + }), + }), + ); + } + + if (extension.keyboardShortcuts?.length) { + plugins.push( + keymap( + Object.fromEntries( + Object.entries(extension.keyboardShortcuts).map(([key, value]) => [ + key, + () => value({ editor: this.editor }), + ]), + ), + ), + ); + } + + return plugins; } /** - * Get the file panel plugin (if available) + * Get all extensions */ - public get filePanel(): FilePanelProsemirrorPlugin | undefined { - return this.editor.extensions["filePanel"] as - | FilePanelProsemirrorPlugin - | undefined; + public getExtensions(): Map { + return new Map( + this.extensions.map((extension) => [extension.key, extension]), + ); } /** - * Get the table handles plugin (if available) + * Get a specific extension by it's instance */ - public get tableHandles(): - | TableHandlesProsemirrorPlugin + public getExtension< + T extends ExtensionFactory | Extension | string | undefined, + >( + extension: T, + ): + | (T extends ExtensionFactory + ? ReturnType + : T extends Extension + ? T + : T extends string + ? Extension + : never) | undefined { - return this.editor.extensions["tableHandles"] as - | TableHandlesProsemirrorPlugin - | undefined; + if (!extension) { + return undefined; + } else if (typeof extension === "string") { + const instance = this.extensions.find((e) => e.key === extension); + if (!instance) { + return undefined; + } + return instance as any; + } else if (typeof extension === "function") { + const instance = this.extensionFactories.get(extension); + if (!instance) { + return undefined; + } + return instance as any; + } else if (typeof extension === "object" && "key" in extension) { + return this.getExtension(extension.key) as any; + } + throw new Error(`Invalid extension type: ${typeof extension}`); } /** - * Get the show selection plugin + * Check if an extension exists */ - public get showSelectionPlugin(): ShowSelectionPlugin { - return this.editor.extensions["showSelection"] as ShowSelectionPlugin; + public hasExtension(key: string | Extension | ExtensionFactory): boolean { + if (typeof key === "string") { + return this.extensions.some((e) => e.key === key); + } else if (typeof key === "object" && "key" in key) { + return this.extensions.some((e) => e.key === key.key); + } else if (typeof key === "function") { + return this.extensionFactories.has(key); + } + return false; } /** - * Check if collaboration is enabled (Yjs or Liveblocks) + * Get all the Tiptap extensions BlockNote is configured with by default */ - public get isCollaborationEnabled(): boolean { - return ( - this.hasExtension("ySyncPlugin") || - this.hasExtension("liveblocksExtension") - ); - } + private getDefaultTiptapExtensions = () => { + const tiptapExtensions: AnyTiptapExtension[] = [ + extensions.ClipboardTextSerializer, + extensions.Commands, + extensions.Editable, + extensions.FocusEvents, + extensions.Tabindex, + Gapcursor, + + UniqueID.configure({ + // everything from bnBlock group (nodes that represent a BlockNote block should have an id) + types: ["blockContainer", "columnList", "column"], + setIdAttribute: this.options.setIdAttribute, + }), + HardBreak, + Text, + + // marks: + SuggestionAddMark, + SuggestionDeleteMark, + SuggestionModificationMark, + Link.extend({ + inclusive: false, + }).configure({ + defaultProtocol: DEFAULT_LINK_PROTOCOL, + // only call this once if we have multiple editors installed. Or fix https://github.com/ueberdosis/tiptap/issues/5450 + protocols: LINKIFY_INITIALIZED ? [] : VALID_LINK_PROTOCOLS, + }), + ...(Object.values(this.editor.schema.styleSpecs).map((styleSpec) => { + return styleSpec.implementation.mark.configure({ + editor: this.editor, + }); + }) as any[]), + + TextColorExtension, + + BackgroundColorExtension, + TextAlignmentExtension, + + // make sure escape blurs editor, so that we can tab to other elements in the host page (accessibility) + TiptapExtension.create({ + name: "OverrideEscape", + addKeyboardShortcuts: () => { + return { + Escape: () => { + // TODO should this be like this? + if (this.editor.getExtension(SuggestionMenuPlugin)?.shown()) { + // escape is handled by suggestionmenu + return false; + } + return this.editor._tiptapEditor.commands.blur(); + }, + }; + }, + }), + + // nodes + Doc, + BlockContainer.configure({ + editor: this.editor, + domAttributes: this.options.domAttributes, + }), + KeyboardShortcutsExtension.configure({ + editor: this.editor, + tabBehavior: this.options.tabBehavior, + }), + BlockGroup.configure({ + domAttributes: this.options.domAttributes, + }), + ...Object.values(this.editor.schema.inlineContentSpecs) + .filter((a) => a.config !== "link" && a.config !== "text") + .map((inlineContentSpec) => { + return inlineContentSpec.implementation!.node.configure({ + editor: this.editor, + }); + }), + + ...Object.values(this.editor.schema.blockSpecs).flatMap((blockSpec) => { + return [ + // the node extension implementations + ...("node" in blockSpec.implementation + ? [ + (blockSpec.implementation.node as Node).configure({ + editor: this.editor, + domAttributes: this.options.domAttributes, + }), + ] + : []), + ]; + }), + createCopyToClipboardExtension(this.editor), + createPasteFromClipboardExtension( + this.editor, + this.options.pasteHandler || + ((context: { + defaultPasteHandler: (context?: { + prioritizeMarkdownOverHTML?: boolean; + plainTextAsMarkdown?: boolean; + }) => boolean | undefined; + }) => context.defaultPasteHandler()), + ), + createDropFileExtension(this.editor), + ]; + + LINKIFY_INITIALIZED = true; + + return tiptapExtensions; + }; } diff --git a/packages/core/src/editor/managers/StateManager.ts b/packages/core/src/editor/managers/StateManager.ts index 6e9f2be42b..c4e9b4de0e 100644 --- a/packages/core/src/editor/managers/StateManager.ts +++ b/packages/core/src/editor/managers/StateManager.ts @@ -1,21 +1,10 @@ -import { redo, undo } from "@tiptap/pm/history"; import { Command, Transaction } from "prosemirror-state"; +import type { UndoPlugin } from "../../extensions/Collaboration/UndoPlugin.js"; +import type { HistoryExtension } from "../../extensions/History/HistoryExtension.js"; 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; - }, - ) {} + constructor(private editor: BlockNoteEditor) {} /** * Stores the currently active transaction, which is the accumulated transaction from all {@link dispatch} calls during a {@link transact} calls @@ -225,14 +214,43 @@ export class StateManager { /** * Undo the last action. */ - public undo() { - return this.exec(this.options?.undo ?? undo); + public undo(): boolean { + // Purposefully not using the UndoPlugin to not import y-prosemirror when not needed + const undoPlugin = this.editor.getExtension("yUndoPlugin") as ReturnType< + typeof UndoPlugin + >; + if (undoPlugin) { + return this.exec(undoPlugin.undoCommand); + } + + const historyPlugin = this.editor.getExtension("history") as ReturnType< + typeof HistoryExtension + >; + if (historyPlugin) { + return this.exec(historyPlugin.undoCommand); + } + + throw new Error("No undo plugin found"); } /** * Redo the last action. */ public redo() { - return this.exec(this.options?.redo ?? redo); + const undoPlugin = this.editor.getExtension("yUndoPlugin") as ReturnType< + typeof UndoPlugin + >; + if (undoPlugin) { + return this.exec(undoPlugin.redoCommand); + } + + const historyPlugin = this.editor.getExtension("history") as ReturnType< + typeof HistoryExtension + >; + if (historyPlugin) { + return this.exec(historyPlugin.redoCommand); + } + + throw new Error("No redo plugin found"); } } diff --git a/packages/core/src/extensions/BlockChange/BlockChangePlugin.ts b/packages/core/src/extensions/BlockChange/BlockChangePlugin.ts index 94dfdac263..6cfafa700d 100644 --- a/packages/core/src/extensions/BlockChange/BlockChangePlugin.ts +++ b/packages/core/src/extensions/BlockChange/BlockChangePlugin.ts @@ -3,25 +3,19 @@ import { BlocksChanged, getBlocksChangedByTransaction, } from "../../api/getBlocksChangedByTransaction.js"; -import { BlockNoteExtension } from "../../editor/BlockNoteExtension.js"; +import { createExtension } from "../../editor/BlockNoteExtension.js"; /** * This plugin can filter transactions before they are applied to the editor, but with a higher-level API than `filterTransaction` from prosemirror. */ -export class BlockChangePlugin extends BlockNoteExtension { - public static key() { - return "blockChange"; - } - - private beforeChangeCallbacks: ((context: { +export const BlockChangePlugin = createExtension((_editor, _options) => { + const beforeChangeCallbacks: ((context: { getChanges: () => BlocksChanged; tr: Transaction; }) => boolean | void)[] = []; - - constructor() { - super(); - - this.addProsemirrorPlugin( + return { + key: "blockChange", + plugins: [ new Plugin({ key: new PluginKey("blockChange"), filterTransaction: (tr) => { @@ -29,7 +23,7 @@ export class BlockChangePlugin extends BlockNoteExtension { | ReturnType | undefined = undefined; - return this.beforeChangeCallbacks.reduce((acc, cb) => { + return beforeChangeCallbacks.reduce((acc, cb) => { if (acc === false) { // We only care that we hit a `false` result, so we can stop iterating. return acc; @@ -49,21 +43,25 @@ export class BlockChangePlugin extends BlockNoteExtension { }, true); }, }), - ); - } + ], - public subscribe( - callback: (context: { - getChanges: () => BlocksChanged; - tr: Transaction; - }) => boolean | void, - ) { - this.beforeChangeCallbacks.push(callback); + /** + * Subscribe to the block change events. + */ + subscribe( + callback: (context: { + getChanges: () => BlocksChanged; + tr: Transaction; + }) => boolean | void, + ) { + beforeChangeCallbacks.push(callback); - return () => { - this.beforeChangeCallbacks = this.beforeChangeCallbacks.filter( - (cb) => cb !== callback, - ); - }; - } -} + return () => { + beforeChangeCallbacks.splice( + beforeChangeCallbacks.indexOf(callback), + 1, + ); + }; + }, + } as const; +}); diff --git a/packages/core/src/extensions/Collaboration/CursorPlugin.ts b/packages/core/src/extensions/Collaboration/CursorPlugin.ts index 5e2a45d116..55c81d33dc 100644 --- a/packages/core/src/extensions/Collaboration/CursorPlugin.ts +++ b/packages/core/src/extensions/Collaboration/CursorPlugin.ts @@ -1,7 +1,5 @@ import { defaultSelectionBuilder, yCursorPlugin } from "y-prosemirror"; -import { Awareness } from "y-protocols/awareness.js"; -import * as Y from "yjs"; -import { BlockNoteExtension } from "../../editor/BlockNoteExtension.js"; +import { createExtension } from "../../editor/BlockNoteExtension.js"; export type CollaborationUser = { name: string; @@ -9,181 +7,174 @@ export type CollaborationUser = { [key: string]: string; }; -export class CursorPlugin extends BlockNoteExtension { - public static key() { - return "yCursorPlugin"; - } - - private provider: { awareness: Awareness }; - private recentlyUpdatedCursors: Map< - number, - { element: HTMLElement; hideTimeout: NodeJS.Timeout | undefined } - >; - constructor( - private collaboration: { - fragment: Y.XmlFragment; - user: CollaborationUser; - provider: { awareness: Awareness }; - renderCursor?: (user: CollaborationUser) => HTMLElement; - showCursorLabels?: "always" | "activity"; - }, - ) { - super(); - this.provider = collaboration.provider; - this.recentlyUpdatedCursors = new Map(); - - this.provider.awareness.setLocalStateField("user", collaboration.user); - - if (collaboration.showCursorLabels !== "always") { - this.provider.awareness.on( - "change", - ({ - updated, - }: { - added: Array; - updated: Array; - removed: Array; - }) => { - for (const clientID of updated) { - const cursor = this.recentlyUpdatedCursors.get(clientID); - - if (cursor) { - cursor.element.setAttribute("data-active", ""); - - if (cursor.hideTimeout) { - clearTimeout(cursor.hideTimeout); - } - - this.recentlyUpdatedCursors.set(clientID, { - element: cursor.element, - hideTimeout: setTimeout(() => { - cursor.element.removeAttribute("data-active"); - }, 2000), - }); - } - } - }, - ); +/** + * Determine whether the foreground color should be white or black based on a provided background color + * Inspired by: https://stackoverflow.com/a/3943023 + */ +function isDarkColor(bgColor: string): boolean { + const color = bgColor.charAt(0) === "#" ? bgColor.substring(1, 7) : bgColor; + const r = parseInt(color.substring(0, 2), 16); // hexToR + const g = parseInt(color.substring(2, 4), 16); // hexToG + const b = parseInt(color.substring(4, 6), 16); // hexToB + const uicolors = [r / 255, g / 255, b / 255]; + const c = uicolors.map((col) => { + if (col <= 0.03928) { + return col / 12.92; } + return Math.pow((col + 0.055) / 1.055, 2.4); + }); + const L = 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; + return L <= 0.179; +} - this.addProsemirrorPlugin( - yCursorPlugin(this.provider.awareness, { - selectionBuilder: defaultSelectionBuilder, - cursorBuilder: this.renderCursor, - }), - ); - } +function defaultCursorRender(user: CollaborationUser) { + const cursorElement = document.createElement("span"); - public get priority() { - return 999; - } + cursorElement.classList.add("bn-collaboration-cursor__base"); - private renderCursor = (user: CollaborationUser, clientID: number) => { - let cursorData = this.recentlyUpdatedCursors.get(clientID); + const caretElement = document.createElement("span"); + caretElement.setAttribute("contentedEditable", "false"); + caretElement.classList.add("bn-collaboration-cursor__caret"); + caretElement.setAttribute( + "style", + `background-color: ${user.color}; color: ${ + isDarkColor(user.color) ? "white" : "black" + }`, + ); - if (!cursorData) { - const cursorElement = ( - this.collaboration.renderCursor ?? CursorPlugin.defaultCursorRender - )(user); + const labelElement = document.createElement("span"); - if (this.collaboration.showCursorLabels !== "always") { - cursorElement.addEventListener("mouseenter", () => { - const cursor = this.recentlyUpdatedCursors.get(clientID)!; - cursor.element.setAttribute("data-active", ""); + labelElement.classList.add("bn-collaboration-cursor__label"); + labelElement.setAttribute( + "style", + `background-color: ${user.color}; color: ${ + isDarkColor(user.color) ? "white" : "black" + }`, + ); + labelElement.insertBefore(document.createTextNode(user.name), null); - if (cursor.hideTimeout) { - clearTimeout(cursor.hideTimeout); - this.recentlyUpdatedCursors.set(clientID, { - element: cursor.element, - hideTimeout: undefined, - }); - } - }); - - cursorElement.addEventListener("mouseleave", () => { - const cursor = this.recentlyUpdatedCursors.get(clientID)!; - - this.recentlyUpdatedCursors.set(clientID, { - element: cursor.element, - hideTimeout: setTimeout(() => { - cursor.element.removeAttribute("data-active"); - }, 2000), - }); - }); - } + caretElement.insertBefore(labelElement, null); - cursorData = { - element: cursorElement, - hideTimeout: undefined, - }; + cursorElement.insertBefore(document.createTextNode("\u2060"), null); // Non-breaking space + cursorElement.insertBefore(caretElement, null); + cursorElement.insertBefore(document.createTextNode("\u2060"), null); // Non-breaking space - this.recentlyUpdatedCursors.set(clientID, cursorData); - } + return cursorElement; +} - return cursorData.element; - }; - - public updateUser = (user: { - name: string; - color: string; - [key: string]: string; - }) => { - this.provider.awareness.setLocalStateField("user", user); - }; - - /** - * Determine whether the foreground color should be white or black based on a provided background color - * Inspired by: https://stackoverflow.com/a/3943023 - * - */ - public static isDarkColor(bgColor: string): boolean { - const color = bgColor.charAt(0) === "#" ? bgColor.substring(1, 7) : bgColor; - const r = parseInt(color.substring(0, 2), 16); // hexToR - const g = parseInt(color.substring(2, 4), 16); // hexToG - const b = parseInt(color.substring(4, 6), 16); // hexToB - const uicolors = [r / 255, g / 255, b / 255]; - const c = uicolors.map((col) => { - if (col <= 0.03928) { - return col / 12.92; - } - return Math.pow((col + 0.055) / 1.055, 2.4); - }); - const L = 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; - return L <= 0.179; +export const CursorPlugin = createExtension((_editor, options) => { + const collaboration = options?.collaboration; + if (!collaboration) { + return; } - public static defaultCursorRender = (user: CollaborationUser) => { - const cursorElement = document.createElement("span"); + const recentlyUpdatedCursors = new Map(); - cursorElement.classList.add("bn-collaboration-cursor__base"); + if ( + "awareness" in collaboration.provider && + typeof collaboration.provider.awareness === "object" + ) { + if ( + "setLocalStateField" in collaboration.provider.awareness && + typeof collaboration.provider.awareness.setLocalStateField === "function" + ) { + collaboration.provider.awareness.setLocalStateField( + "user", + collaboration.user, + ); + } + if ( + "on" in collaboration.provider.awareness && + typeof collaboration.provider.awareness.on === "function" + ) { + if (collaboration.showCursorLabels !== "always") { + collaboration.provider.awareness.on( + "change", + ({ + updated, + }: { + added: Array; + updated: Array; + removed: Array; + }) => { + for (const clientID of updated) { + const cursor = recentlyUpdatedCursors.get(clientID); + + if (cursor) { + cursor.element.setAttribute("data-active", ""); + + if (cursor.hideTimeout) { + clearTimeout(cursor.hideTimeout); + } + + recentlyUpdatedCursors.set(clientID, { + element: cursor.element, + hideTimeout: setTimeout(() => { + cursor.element.removeAttribute("data-active"); + }, 2000), + }); + } + } + }, + ); + } + } + } - const caretElement = document.createElement("span"); - caretElement.setAttribute("contentedEditable", "false"); - caretElement.classList.add("bn-collaboration-cursor__caret"); - caretElement.setAttribute( - "style", - `background-color: ${user.color}; color: ${ - CursorPlugin.isDarkColor(user.color) ? "white" : "black" - }`, - ); + return { + key: "yCursorPlugin", + plugins: [ + yCursorPlugin(collaboration.provider.awareness, { + selectionBuilder: defaultSelectionBuilder, + cursorBuilder(user: CollaborationUser, clientID: number) { + let cursorData = recentlyUpdatedCursors.get(clientID); + + if (!cursorData) { + const cursorElement = ( + collaboration.renderCursor ?? defaultCursorRender + )(user); + + if (collaboration.showCursorLabels !== "always") { + cursorElement.addEventListener("mouseenter", () => { + const cursor = recentlyUpdatedCursors.get(clientID)!; + cursor.element.setAttribute("data-active", ""); + + if (cursor.hideTimeout) { + clearTimeout(cursor.hideTimeout); + recentlyUpdatedCursors.set(clientID, { + element: cursor.element, + hideTimeout: undefined, + }); + } + }); - const labelElement = document.createElement("span"); + cursorElement.addEventListener("mouseleave", () => { + const cursor = recentlyUpdatedCursors.get(clientID)!; - labelElement.classList.add("bn-collaboration-cursor__label"); - labelElement.setAttribute( - "style", - `background-color: ${user.color}; color: ${ - CursorPlugin.isDarkColor(user.color) ? "white" : "black" - }`, - ); - labelElement.insertBefore(document.createTextNode(user.name), null); + recentlyUpdatedCursors.set(clientID, { + element: cursor.element, + hideTimeout: setTimeout(() => { + cursor.element.removeAttribute("data-active"); + }, 2000), + }); + }); + } - caretElement.insertBefore(labelElement, null); + cursorData = { + element: cursorElement, + hideTimeout: undefined, + }; - cursorElement.insertBefore(document.createTextNode("\u2060"), null); // Non-breaking space - cursorElement.insertBefore(caretElement, null); - cursorElement.insertBefore(document.createTextNode("\u2060"), null); // Non-breaking space + recentlyUpdatedCursors.set(clientID, cursorData); + } - return cursorElement; - }; -} + return cursorData.element; + }, + }), + ], + dependsOn: ["default"], + updateUser(user: { name: string; color: string; [key: string]: string }) { + collaboration.provider.awareness.setLocalStateField("user", user); + }, + } as const; +}); diff --git a/packages/core/src/extensions/Collaboration/ForkYDocPlugin.test.ts b/packages/core/src/extensions/Collaboration/ForkYDocPlugin.test.ts index eca3ba26f2..b5fcedc3f8 100644 --- a/packages/core/src/extensions/Collaboration/ForkYDocPlugin.test.ts +++ b/packages/core/src/extensions/Collaboration/ForkYDocPlugin.test.ts @@ -2,6 +2,7 @@ import { expect, it } from "vitest"; import * as Y from "yjs"; import { Awareness } from "y-protocols/awareness"; import { BlockNoteEditor } from "../../index.js"; +import { ForkYDocPlugin } from "./ForkYDocPlugin.js"; /** * @vitest-environment jsdom @@ -36,7 +37,7 @@ it("can fork a document", async () => { "__snapshots__/fork-yjs-snap-editor.json", ); - editor.forkYDocPlugin!.fork(); + editor.getExtension(ForkYDocPlugin)!.fork(); editor.replaceBlocks(editor.document, [ { @@ -83,7 +84,7 @@ it("can merge a document", async () => { "__snapshots__/fork-yjs-snap-editor.json", ); - editor.forkYDocPlugin!.fork(); + editor.getExtension(ForkYDocPlugin)!.fork(); editor.replaceBlocks(editor.document, [ { @@ -99,7 +100,7 @@ it("can merge a document", async () => { "__snapshots__/fork-yjs-snap-editor-forked.json", ); - editor.forkYDocPlugin!.merge({ keepChanges: false }); + editor.getExtension(ForkYDocPlugin)!.merge({ keepChanges: false }); await expect(fragment.toJSON()).toMatchFileSnapshot( "__snapshots__/fork-yjs-snap.html", @@ -139,7 +140,7 @@ it("can fork an keep the changes to the original document", async () => { "__snapshots__/fork-yjs-snap-editor.json", ); - editor.forkYDocPlugin!.fork(); + editor.getExtension(ForkYDocPlugin)!.fork(); editor.replaceBlocks(editor.document, [ { @@ -155,7 +156,7 @@ it("can fork an keep the changes to the original document", async () => { "__snapshots__/fork-yjs-snap-editor-forked.json", ); - editor.forkYDocPlugin!.merge({ keepChanges: true }); + editor.getExtension(ForkYDocPlugin)!.merge({ keepChanges: true }); await expect(fragment.toJSON()).toMatchFileSnapshot( "__snapshots__/fork-yjs-snap-forked.html", diff --git a/packages/core/src/extensions/Collaboration/ForkYDocPlugin.ts b/packages/core/src/extensions/Collaboration/ForkYDocPlugin.ts index c6361557e7..ba48ae2fef 100644 --- a/packages/core/src/extensions/Collaboration/ForkYDocPlugin.ts +++ b/packages/core/src/extensions/Collaboration/ForkYDocPlugin.ts @@ -1,192 +1,164 @@ +import { yUndoPluginKey } from "y-prosemirror"; import * as Y from "yjs"; - import { - yCursorPluginKey, - ySyncPluginKey, - yUndoPluginKey, -} from "y-prosemirror"; + createExtension, + createStore, +} from "../../editor/BlockNoteExtension.js"; import { CursorPlugin } from "./CursorPlugin.js"; import { SyncPlugin } from "./SyncPlugin.js"; import { UndoPlugin } from "./UndoPlugin.js"; -import { - BlockNoteEditor, - BlockNoteEditorOptions, -} from "../../editor/BlockNoteEditor.js"; -import { BlockNoteExtension } from "../../editor/BlockNoteExtension.js"; - -export class ForkYDocPlugin extends BlockNoteExtension<{ - forked: boolean; -}> { - public static key() { - return "ForkYDocPlugin"; - } - - private editor: BlockNoteEditor; - private collaboration: BlockNoteEditorOptions["collaboration"]; - - constructor({ - editor, - collaboration, - }: { - editor: BlockNoteEditor; - collaboration: BlockNoteEditorOptions["collaboration"]; - }) { - super(editor); - this.editor = editor; - this.collaboration = collaboration; - } - - /** - * To find a fragment in another ydoc, we need to search for it. - */ - private findTypeInOtherYdoc>( - ytype: T, - otherYdoc: Y.Doc, - ): T { - const ydoc = ytype.doc!; - if (ytype._item === null) { - /** - * If is a root type, we need to find the root key in the original ydoc - * and use it to get the type in the other ydoc. - */ - const rootKey = Array.from(ydoc.share.keys()).find( - (key) => ydoc.share.get(key) === ytype, - ); - if (rootKey == null) { - throw new Error("type does not exist in other ydoc"); - } - return otherYdoc.get(rootKey, ytype.constructor as new () => T) as T; - } else { - /** - * If it is a sub type, we use the item id to find the history type. - */ - const ytypeItem = ytype._item; - const otherStructs = - otherYdoc.store.clients.get(ytypeItem.id.client) ?? []; - const itemIndex = Y.findIndexSS(otherStructs, ytypeItem.id.clock); - const otherItem = otherStructs[itemIndex] as Y.Item; - const otherContent = otherItem.content as Y.ContentType; - return otherContent.type as T; +/** + * To find a fragment in another ydoc, we need to search for it. + */ +function findTypeInOtherYdoc>( + ytype: T, + otherYdoc: Y.Doc, +): T { + const ydoc = ytype.doc!; + if (ytype._item === null) { + /** + * If is a root type, we need to find the root key in the original ydoc + * and use it to get the type in the other ydoc. + */ + const rootKey = Array.from(ydoc.share.keys()).find( + (key) => ydoc.share.get(key) === ytype, + ); + if (rootKey == null) { + throw new Error("type does not exist in other ydoc"); } + return otherYdoc.get(rootKey, ytype.constructor as new () => T) as T; + } else { + /** + * If it is a sub type, we use the item id to find the history type. + */ + const ytypeItem = ytype._item; + const otherStructs = otherYdoc.store.clients.get(ytypeItem.id.client) ?? []; + const itemIndex = Y.findIndexSS(otherStructs, ytypeItem.id.clock); + const otherItem = otherStructs[itemIndex] as Y.Item; + const otherContent = otherItem.content as Y.ContentType; + return otherContent.type as T; } +} - /** - * Whether the editor is editing a forked document, - * preserving a reference to the original document and the forked document. - */ - public get isForkedFromRemote() { - return this.forkedState !== undefined; +export const ForkYDocPlugin = createExtension((editor, options) => { + if (!options.collaboration) { + return; } + const store = createStore({ + isForked: false, + }); + /** * Stores whether the editor is editing a forked document, * preserving a reference to the original document and the forked document. */ - private forkedState: + const forkedState = createStore< | { originalFragment: Y.XmlFragment; undoStack: Y.UndoManager["undoStack"]; forkedFragment: Y.XmlFragment; } - | undefined; - - /** - * Fork the Y.js document from syncing to the remote, - * allowing modifications to the document without affecting the remote. - * These changes can later be rolled back or applied to the remote. - */ - public fork() { - if (this.isForkedFromRemote) { - return; - } - - const originalFragment = this.collaboration?.fragment; - - if (!originalFragment) { - throw new Error("No fragment to fork from"); - } - - const doc = new Y.Doc(); - // Copy the original document to a new Yjs document - Y.applyUpdate(doc, Y.encodeStateAsUpdate(originalFragment.doc!)); - - // Find the forked fragment in the new Yjs document - const forkedFragment = this.findTypeInOtherYdoc(originalFragment, doc); - - this.forkedState = { - undoStack: yUndoPluginKey.getState(this.editor.prosemirrorState)! - .undoManager.undoStack, - originalFragment, - forkedFragment, - }; - - // Need to reset all the yjs plugins - this.editor._tiptapEditor.unregisterPlugin([ - yCursorPluginKey, - yUndoPluginKey, - ySyncPluginKey, - ]); - // Register them again, based on the new forked fragment - this.editor._tiptapEditor.registerPlugin( - new SyncPlugin(forkedFragment).plugins[0], - ); - this.editor._tiptapEditor.registerPlugin( - new UndoPlugin({ editor: this.editor }).plugins[0], - ); - // No need to register the cursor plugin again, it's a local fork - this.emit("forked", true); - } + | undefined + >(undefined, { + onUpdate() { + store.setState({ isForked: store.state !== undefined }); + }, + }); + + return { + key: "yForkDoc", + store, + + /** + * Whether the editor is editing a forked document, + * preserving a reference to the original document and the forked document. + */ + get isForkedFromRemote(): boolean { + return forkedState.state !== undefined; + }, + + /** + * Fork the Y.js document from syncing to the remote, + * allowing modifications to the document without affecting the remote. + * These changes can later be rolled back or applied to the remote. + */ + fork() { + if (this.isForkedFromRemote) { + return; + } - /** - * Resume syncing the Y.js document to the remote - * If `keepChanges` is true, any changes that have been made to the forked document will be applied to the original document. - * Otherwise, the original document will be restored and the changes will be discarded. - */ - public merge({ keepChanges }: { keepChanges: boolean }) { - if (!this.forkedState) { - return; - } - // Remove the forked fragment's plugins - this.editor._tiptapEditor.unregisterPlugin(ySyncPluginKey); - this.editor._tiptapEditor.unregisterPlugin(yUndoPluginKey); - - const { originalFragment, forkedFragment, undoStack } = this.forkedState; - this.editor.extensions["ySyncPlugin"] = new SyncPlugin(originalFragment); - this.editor.extensions["yCursorPlugin"] = new CursorPlugin( - this.collaboration!, - ); - this.editor.extensions["yUndoPlugin"] = new UndoPlugin({ - editor: this.editor, - }); + const originalFragment = options.collaboration?.fragment; - // Register the plugins again, based on the original fragment - this.editor._tiptapEditor.registerPlugin( - this.editor.extensions["ySyncPlugin"].plugins[0], - ); - this.editor._tiptapEditor.registerPlugin( - this.editor.extensions["yCursorPlugin"].plugins[0], - ); - this.editor._tiptapEditor.registerPlugin( - this.editor.extensions["yUndoPlugin"].plugins[0], - ); + if (!originalFragment) { + throw new Error("No fragment to fork from"); + } - // Reset the undo stack to the original undo stack - yUndoPluginKey.getState( - this.editor.prosemirrorState, - )!.undoManager.undoStack = undoStack; - - if (keepChanges) { - // Apply any changes that have been made to the fork, onto the original doc - const update = Y.encodeStateAsUpdate( - forkedFragment.doc!, - Y.encodeStateVector(originalFragment.doc!), - ); - // Applying this change will add to the undo stack, allowing it to be undone normally - Y.applyUpdate(originalFragment.doc!, update, this.editor); - } - // Reset the forked state - this.forkedState = undefined; - this.emit("forked", false); - } -} + const doc = new Y.Doc(); + // Copy the original document to a new Yjs document + Y.applyUpdate(doc, Y.encodeStateAsUpdate(originalFragment.doc!)); + + // Find the forked fragment in the new Yjs document + const forkedFragment = findTypeInOtherYdoc(originalFragment, doc); + + forkedState.setState({ + undoStack: yUndoPluginKey.getState(editor.prosemirrorState)!.undoManager + .undoStack, + originalFragment, + forkedFragment, + }); + + // Need to reset all the yjs plugins + editor.removeExtension([UndoPlugin, CursorPlugin, SyncPlugin]); + const newOptions = { + ...options, + collaboration: { + ...options.collaboration!, + fragment: forkedFragment, + }, + }; + // Register them again, based on the new forked fragment + editor.registerExtension([ + SyncPlugin(editor, newOptions), + CursorPlugin(editor, newOptions), + ]); + // No need to register the cursor plugin again, it's a local fork + store.setState({ isForked: true }); + }, + + /** + * Resume syncing the Y.js document to the remote + * If `keepChanges` is true, any changes that have been made to the forked document will be applied to the original document. + * Otherwise, the original document will be restored and the changes will be discarded. + */ + merge({ keepChanges }: { keepChanges: boolean }) { + const currentState = forkedState.state; + if (!currentState) { + return; + } + // Remove the forked fragment's plugins + editor.removeExtension([SyncPlugin, CursorPlugin, UndoPlugin]); + + const { originalFragment, forkedFragment, undoStack } = currentState; + // Register the plugins again, based on the original fragment (which is still in the original options) + editor.registerExtension([SyncPlugin, CursorPlugin, UndoPlugin]); + + // Reset the undo stack to the original undo stack + yUndoPluginKey.getState(editor.prosemirrorState)!.undoManager.undoStack = + undoStack; + + if (keepChanges) { + // Apply any changes that have been made to the fork, onto the original doc + const update = Y.encodeStateAsUpdate( + forkedFragment.doc!, + Y.encodeStateVector(originalFragment.doc!), + ); + // Applying this change will add to the undo stack, allowing it to be undone normally + Y.applyUpdate(originalFragment.doc!, update, editor); + } + // Reset the forked state + forkedState.setState(undefined); + }, + } as const; +}); diff --git a/packages/core/src/extensions/Collaboration/SyncPlugin.ts b/packages/core/src/extensions/Collaboration/SyncPlugin.ts index 15ec5751c8..2dc0829a8d 100644 --- a/packages/core/src/extensions/Collaboration/SyncPlugin.ts +++ b/packages/core/src/extensions/Collaboration/SyncPlugin.ts @@ -1,18 +1,15 @@ import { ySyncPlugin } from "y-prosemirror"; -import type * as Y from "yjs"; -import { BlockNoteExtension } from "../../editor/BlockNoteExtension.js"; +import { createExtension } from "../../editor/BlockNoteExtension.js"; -export class SyncPlugin extends BlockNoteExtension { - public static key() { - return "ySyncPlugin"; +export const SyncPlugin = createExtension((_editor, options) => { + const fragment = options?.collaboration?.fragment; + if (!fragment) { + return; } - constructor(fragment: Y.XmlFragment) { - super(); - this.addProsemirrorPlugin(ySyncPlugin(fragment)); - } - - public get priority() { - return 1001; - } -} + return { + key: "ySyncPlugin", + plugins: [ySyncPlugin(fragment)], + dependsOn: ["yCursorPlugin", "yUndoPlugin"], + } as const; +}); diff --git a/packages/core/src/extensions/Collaboration/UndoPlugin.ts b/packages/core/src/extensions/Collaboration/UndoPlugin.ts index e0e697de08..b336eacbc1 100644 --- a/packages/core/src/extensions/Collaboration/UndoPlugin.ts +++ b/packages/core/src/extensions/Collaboration/UndoPlugin.ts @@ -1,18 +1,16 @@ -import { yUndoPlugin } from "y-prosemirror"; -import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; -import { BlockNoteExtension } from "../../editor/BlockNoteExtension.js"; +import { redoCommand, undoCommand, yUndoPlugin } from "y-prosemirror"; +import { createExtension } from "../../editor/BlockNoteExtension.js"; -export class UndoPlugin extends BlockNoteExtension { - public static key() { - return "yUndoPlugin"; +export const UndoPlugin = createExtension((editor, options) => { + if (!options.collaboration) { + return; } - constructor({ editor }: { editor: BlockNoteEditor }) { - super(); - this.addProsemirrorPlugin(yUndoPlugin({ trackedOrigins: [editor] })); - } - - public get priority() { - return 1000; - } -} + return { + key: "yUndoPlugin", + plugins: [yUndoPlugin({ trackedOrigins: [editor] })], + dependsOn: ["yCursorPlugin"], + undoCommand: undoCommand, + redoCommand: redoCommand, + } as const; +}); diff --git a/packages/core/src/extensions/Collaboration/schemaMigration/SchemaMigrationPlugin.ts b/packages/core/src/extensions/Collaboration/schemaMigration/SchemaMigrationPlugin.ts index 992ee4559f..90d49d2e57 100644 --- a/packages/core/src/extensions/Collaboration/schemaMigration/SchemaMigrationPlugin.ts +++ b/packages/core/src/extensions/Collaboration/schemaMigration/SchemaMigrationPlugin.ts @@ -2,7 +2,7 @@ import { Plugin, PluginKey } from "@tiptap/pm/state"; import { ySyncPluginKey } from "y-prosemirror"; import * as Y from "yjs"; -import { BlockNoteExtension } from "../../../editor/BlockNoteExtension.js"; +import { createExtension } from "../../../editor/BlockNoteExtension.js"; import migrationRules from "./migrationRules/index.js"; // This plugin allows us to update collaboration YDocs whenever BlockNote's @@ -11,22 +11,25 @@ import migrationRules from "./migrationRules/index.js"; // case things are found in the fragment that don't adhere to the editor schema // and need to be fixed. These fixes are defined as `MigrationRule`s within the // `migrationRules` directory. -export class SchemaMigrationPlugin extends BlockNoteExtension { - private migrationDone = false; +export const SchemaMigrationPlugin = createExtension((_editor, options) => { + const fragment = (options as any)?.collaboration?.fragment as + | Y.XmlFragment + | undefined; - public static key() { - return "schemaMigrationPlugin"; + if (!fragment) { + return; } - constructor(fragment: Y.XmlFragment) { - const pluginKey = new PluginKey(SchemaMigrationPlugin.key()); + let migrationDone = false; + const pluginKey = new PluginKey("schemaMigrationPlugin"); - super(); - this.addProsemirrorPlugin( + return { + key: "schemaMigrationPlugin", + plugins: [ new Plugin({ key: pluginKey, appendTransaction: (transactions, _oldState, newState) => { - if (this.migrationDone) { + if (migrationDone) { return undefined; } @@ -42,11 +45,10 @@ export class SchemaMigrationPlugin extends BlockNoteExtension { migrationRule(fragment, tr); } - this.migrationDone = true; - + migrationDone = true; return tr; }, }), - ); - } -} + ], + } as const; +}); diff --git a/packages/core/src/extensions/Comments/CommentsPlugin.ts b/packages/core/src/extensions/Comments/CommentsPlugin.ts index a5d1e24b6d..9681310dc7 100644 --- a/packages/core/src/extensions/Comments/CommentsPlugin.ts +++ b/packages/core/src/extensions/Comments/CommentsPlugin.ts @@ -8,10 +8,14 @@ import type { ThreadStore, User, } from "../../comments/index.js"; -import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; -import { BlockNoteExtension } from "../../editor/BlockNoteExtension.js"; +import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { + createExtension, + createStore, +} from "../../editor/BlockNoteExtension.js"; import { CustomBlockNoteSchema } from "../../schema/schema.js"; import { UserStore } from "./userstore/UserStore.js"; +import { CommentMark } from "./CommentMark.js"; const PLUGIN_KEY = new PluginKey(`blocknote-comments`); const SET_SELECTED_THREAD_ID = "SET_SELECTED_THREAD_ID"; @@ -57,306 +61,279 @@ function getUpdatedThreadPositions(doc: Node, markType: string) { return threadPositions; } -export class CommentsPlugin extends BlockNoteExtension { - public static key() { - return "comments"; - } - - public readonly userStore: UserStore; - - /** - * Whether a comment is currently being composed - */ - private pendingComment = false; +export const CommentsPlugin = createExtension( + (editor: BlockNoteEditor, options) => { + const commentsOptions = options?.comments as + | { + threadStore: ThreadStore; + resolveUsers?: (userIds: string[]) => Promise; + schema?: CustomBlockNoteSchema; + } + | undefined; + if (!commentsOptions) { + return undefined; + } - /** - * The currently selected thread id - */ - private selectedThreadId: string | undefined; + const markType = CommentMark.name; + const threadStore = commentsOptions.threadStore; + const resolveUsers = commentsOptions.resolveUsers; + const commentEditorSchema = commentsOptions.schema; - /** - * Store the positions of all threads in the document. - * this can be used later to implement a floating sidebar - */ - private threadPositions: Map = - new Map(); + if (!resolveUsers) { + // TODO check this? + throw new Error("resolveUsers is required for comments"); + } - private emitStateUpdate() { - this.emit("update", { - selectedThreadId: this.selectedThreadId, - pendingComment: this.pendingComment, - threadPositions: this.threadPositions, + const userStore = new UserStore(resolveUsers); + let pendingComment = false; + let selectedThreadId: string | undefined; + let threadPositions: Map = new Map(); + const store = createStore({ + pendingComment, + selectedThreadId, + threadPositions, }); - } - - /** - * when a thread is resolved or deleted, we need to update the marks to reflect the new state - */ - private updateMarksFromThreads = (threads: Map) => { - this.editor.transact((tr) => { - tr.doc.descendants((node, pos) => { - node.marks.forEach((mark) => { - if (mark.type.name === this.markType) { - const markType = mark.type; - const markThreadId = mark.attrs.threadId; - const thread = threads.get(markThreadId); - const isOrphan = !!(!thread || thread.resolved || thread.deletedAt); + const subscribers = new Set< + (state: { + pendingComment: boolean; + selectedThreadId: string | undefined; + threadPositions: Map; + }) => void + >(); + + const emitStateUpdate = () => { + const snapshot = { + pendingComment, + selectedThreadId, + threadPositions, + }; + store.setState(snapshot); + subscribers.forEach((cb) => cb(snapshot)); + }; - if (isOrphan !== mark.attrs.orphan) { - const trimmedFrom = Math.max(pos, 0); - const trimmedTo = Math.min( - pos + node.nodeSize, - tr.doc.content.size - 1, - tr.doc.content.size - 1, - ); - tr.removeMark(trimmedFrom, trimmedTo, mark); - tr.addMark( - trimmedFrom, - trimmedTo, - markType.create({ - ...mark.attrs, - orphan: isOrphan, - }), + const updateMarksFromThreads = (threads: Map) => { + editor.transact((tr) => { + tr.doc.descendants((node, pos) => { + node.marks.forEach((mark) => { + if (mark.type.name === markType) { + const markTypeInstance = mark.type; + const markThreadId = mark.attrs.threadId; + const thread = threads.get(markThreadId); + const isOrphan = !!( + !thread || + thread.resolved || + thread.deletedAt ); - if (isOrphan && this.selectedThreadId === markThreadId) { - // unselect - this.selectedThreadId = undefined; - this.emitStateUpdate(); + if (isOrphan !== mark.attrs.orphan) { + const trimmedFrom = Math.max(pos, 0); + const trimmedTo = Math.min( + pos + node.nodeSize, + tr.doc.content.size - 1, + tr.doc.content.size - 1, + ); + tr.removeMark(trimmedFrom, trimmedTo, mark); + tr.addMark( + trimmedFrom, + trimmedTo, + markTypeInstance.create({ + ...mark.attrs, + orphan: isOrphan, + }), + ); + + if (isOrphan && selectedThreadId === markThreadId) { + // unselect + selectedThreadId = undefined; + emitStateUpdate(); + } } } - } + }); }); }); - }); - }; - - constructor( - private readonly editor: BlockNoteEditor, - public readonly threadStore: ThreadStore, - private readonly markType: string, - public readonly resolveUsers: - | undefined - | ((userIds: string[]) => Promise), - public readonly commentEditorSchema?: CustomBlockNoteSchema, - ) { - super(); + }; - if (!resolveUsers) { - throw new Error("resolveUsers is required for comments"); - } - this.userStore = new UserStore(resolveUsers); + return { + key: "comments", + store, + plugins: [ + new Plugin({ + key: PLUGIN_KEY, + state: { + init() { + return { + decorations: DecorationSet.empty, + }; + }, + apply(tr, state) { + const action = tr.getMeta(PLUGIN_KEY); + + if (!tr.docChanged && !action) { + return state; + } - // Note: Plugins are currently not destroyed when the editor is destroyed. - // We should unsubscribe from the threadStore when the editor is destroyed. - this.threadStore.subscribe(this.updateMarksFromThreads); + // only update threadPositions if the doc changed + const newThreadPositions = tr.docChanged + ? getUpdatedThreadPositions(tr.doc, markType) + : threadPositions; - editor.onCreate(() => { - // Need to wait for TipTap editor state to be initialized - this.updateMarksFromThreads(this.threadStore.getThreads()); - editor.onSelectionChange(() => { - if (this.pendingComment) { - this.pendingComment = false; - this.emitStateUpdate(); - } - }); - }); + if (newThreadPositions.size > 0 || threadPositions.size > 0) { + // small optimization; don't emit event if threadPositions before / after were both empty + threadPositions = newThreadPositions; + emitStateUpdate(); + } - // eslint-disable-next-line @typescript-eslint/no-this-alias - const self = this; + // update decorations if doc or selected thread changed + const decorations = [] as any[]; + + if (selectedThreadId) { + const selectedThreadPosition = + newThreadPositions.get(selectedThreadId); + + if (selectedThreadPosition) { + decorations.push( + Decoration.inline( + selectedThreadPosition.from, + selectedThreadPosition.to, + { + class: "bn-thread-mark-selected", + }, + ), + ); + } + } - this.addProsemirrorPlugin( - new Plugin({ - key: PLUGIN_KEY, - state: { - init() { - return { - decorations: DecorationSet.empty, - }; + return { + decorations: DecorationSet.create(tr.doc, decorations), + }; + }, }, - apply(tr, state) { - const action = tr.getMeta(PLUGIN_KEY); - - if (!tr.docChanged && !action) { - return state; - } + props: { + decorations(state) { + return ( + PLUGIN_KEY.getState(state)?.decorations ?? DecorationSet.empty + ); + }, + handleClick: (view, pos, event) => { + if (event.button !== 0) { + return; + } - // only update threadPositions if the doc changed - const threadPositions = tr.docChanged - ? getUpdatedThreadPositions(tr.doc, self.markType) - : self.threadPositions; + const node = view.state.doc.nodeAt(pos); - if (threadPositions.size > 0 || self.threadPositions.size > 0) { - // small optimization; don't emit event if threadPositions before / after were both empty - self.threadPositions = threadPositions; - self.emitStateUpdate(); - } - - // update decorations if doc or selected thread changed - const decorations = []; + if (!node) { + // unselect + selectedThreadId = undefined; + emitStateUpdate(); + return; + } - if (self.selectedThreadId) { - const selectedThreadPosition = threadPositions.get( - self.selectedThreadId, + const commentMark = node.marks.find( + (mark) => + mark.type.name === markType && mark.attrs.orphan !== true, ); - if (selectedThreadPosition) { - decorations.push( - Decoration.inline( - selectedThreadPosition.from, - selectedThreadPosition.to, - { - class: "bn-thread-mark-selected", - }, - ), - ); + const threadId = commentMark?.attrs.threadId as + | string + | undefined; + if (threadId !== selectedThreadId) { + selectedThreadId = threadId; + emitStateUpdate(); } - } - - return { - decorations: DecorationSet.create(tr.doc, decorations), - }; - }, - }, - props: { - decorations(state) { - return ( - PLUGIN_KEY.getState(state)?.decorations ?? DecorationSet.empty - ); + }, }, - /** - * Handle click on a thread mark and mark it as selected - */ - handleClick: (view, pos, event) => { - if (event.button !== 0) { - return; - } - - const node = view.state.doc.nodeAt(pos); - - if (!node) { - self.selectThread(undefined); - return; + }), + ], + init() { + const unsubscribe = threadStore.subscribe(updateMarksFromThreads); + editor.onCreate(() => { + updateMarksFromThreads(threadStore.getThreads()); + editor.onSelectionChange(() => { + if (pendingComment) { + pendingComment = false; + emitStateUpdate(); } - - const commentMark = node.marks.find( - (mark) => - mark.type.name === markType && mark.attrs.orphan !== true, - ); - - const threadId = commentMark?.attrs.threadId as string | undefined; - self.selectThread(threadId, false); - }, - }, - }), - ); - } - - /** - * Subscribe to state updates - */ - public onUpdate( - callback: (state: { - pendingComment: boolean; - selectedThreadId: string | undefined; - threadPositions: Map; - }) => void, - ) { - return this.on("update", callback); - } - - /** - * Set the selected thread - */ - public selectThread(threadId: string | undefined, scrollToThread = true) { - if (this.selectedThreadId === threadId) { - return; - } - this.selectedThreadId = threadId; - this.emitStateUpdate(); - this.editor.transact((tr) => - tr.setMeta(PLUGIN_KEY, { - name: SET_SELECTED_THREAD_ID, - }), - ); - - if (threadId && scrollToThread) { - const selectedThreadPosition = this.threadPositions.get(threadId); - - if (!selectedThreadPosition) { - return; - } - - // When a new thread is selected, scrolls the page to its reference text in - // the editor. - ( - this.editor.prosemirrorView?.domAtPos(selectedThreadPosition.from) - .node as Element | undefined - )?.scrollIntoView({ - behavior: "smooth", - block: "center", - }); - } - } - - /** - * Start a pending comment (e.g.: when clicking the "Add comment" button) - */ - public startPendingComment() { - this.pendingComment = true; - this.emitStateUpdate(); - } - - /** - * Stop a pending comment (e.g.: user closes the comment composer) - */ - public stopPendingComment() { - this.pendingComment = false; - this.emitStateUpdate(); - } - - /** - * Create a thread at the current selection - */ - public async createThread(options: { - initialComment: { - body: CommentBody; - metadata?: any; - }; - metadata?: any; - }) { - const thread = await this.threadStore.createThread(options); - - if (this.threadStore.addThreadToDocument) { - // creating the mark is handled by the store - // this is useful if we don't have write-access to the document. - // We can then offload the responsibility of creating the mark to the server. - // (e.g.: RESTYjsThreadStore) - const view = this.editor.prosemirrorView!; - const pmSelection = view.state.selection; - - const ystate = ySyncPluginKey.getState(view.state); - - const selection = { - prosemirror: { - head: pmSelection.head, - anchor: pmSelection.anchor, - }, - yjs: ystate - ? getRelativeSelection(ystate.binding, view.state) - : undefined, // if we're not using yjs - }; - - await this.threadStore.addThreadToDocument({ - threadId: thread.id, - selection, - }); - } else { - // we create the mark directly in the document - this.editor._tiptapEditor.commands.setMark(this.markType, { - orphan: false, - threadId: thread.id, - }); - } - } -} + }); + }); + return () => unsubscribe(); + }, + onUpdate( + callback: (state: { + pendingComment: boolean; + selectedThreadId: string | undefined; + threadPositions: Map; + }) => void, + ) { + subscribers.add(callback); + return () => subscribers.delete(callback); + }, + selectThread(threadId: string | undefined, scrollToThread = true) { + if (selectedThreadId === threadId) { + return; + } + selectedThreadId = threadId; + emitStateUpdate(); + editor.transact((tr) => + tr.setMeta(PLUGIN_KEY, { + name: SET_SELECTED_THREAD_ID, + }), + ); + + if (threadId && scrollToThread) { + const selectedThreadPosition = threadPositions.get(threadId); + if (!selectedThreadPosition) { + return; + } + ( + editor.prosemirrorView?.domAtPos(selectedThreadPosition.from) + .node as Element | undefined + )?.scrollIntoView({ + behavior: "smooth", + block: "center", + }); + } + }, + startPendingComment() { + pendingComment = true; + emitStateUpdate(); + }, + stopPendingComment() { + pendingComment = false; + emitStateUpdate(); + }, + async createThread(options: { + initialComment: { body: CommentBody; metadata?: any }; + metadata?: any; + }) { + const thread = await threadStore.createThread(options); + if (threadStore.addThreadToDocument) { + const view = editor.prosemirrorView!; + const pmSelection = view.state.selection; + const ystate = ySyncPluginKey.getState(view.state); + const selection = { + prosemirror: { + head: pmSelection.head, + anchor: pmSelection.anchor, + }, + yjs: ystate + ? getRelativeSelection(ystate.binding, view.state) + : undefined, + }; + await threadStore.addThreadToDocument({ + threadId: thread.id, + selection, + }); + } else { + (editor as any)._tiptapEditor.commands.setMark(markType, { + orphan: false, + threadId: thread.id, + }); + } + }, + userStore, + commentEditorSchema, + tiptapExtensions: [CommentMark], + } as const; + }, +); diff --git a/packages/core/src/extensions/DropCursor/DropCursor.ts b/packages/core/src/extensions/DropCursor/DropCursor.ts new file mode 100644 index 0000000000..e7008a9a3f --- /dev/null +++ b/packages/core/src/extensions/DropCursor/DropCursor.ts @@ -0,0 +1,15 @@ +import { dropCursor } from "prosemirror-dropcursor"; +import { createExtension } from "../../editor/BlockNoteExtension.js"; + +export const DropCursor = createExtension((editor, options) => { + return { + key: "dropCursor", + plugins: [ + (options.dropCursor ?? dropCursor)({ + width: 5, + color: "#ddeeff", + editor: editor, + }), + ], + } as const; +}); diff --git a/packages/core/src/extensions/FilePanel/FilePanelPlugin.ts b/packages/core/src/extensions/FilePanel/FilePanelPlugin.ts index c5819a1c5b..158cc204be 100644 --- a/packages/core/src/extensions/FilePanel/FilePanelPlugin.ts +++ b/packages/core/src/extensions/FilePanel/FilePanelPlugin.ts @@ -1,206 +1,79 @@ -import { EditorState, Plugin, PluginKey, PluginView } from "prosemirror-state"; -import { EditorView } from "prosemirror-view"; - -import { ySyncPluginKey } from "y-prosemirror"; -import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; -import { BlockNoteExtension } from "../../editor/BlockNoteExtension.js"; -import { UiElementPosition } from "../../extensions-shared/UiElementPosition.js"; -import type { - BlockFromConfig, - InlineContentSchema, - StyleSchema, -} from "../../schema/index.js"; - -export type FilePanelState< - I extends InlineContentSchema, - S extends StyleSchema, -> = UiElementPosition & { - // TODO: This typing is not quite right (children should be from BSchema) - block: BlockFromConfig; -}; - -export class FilePanelView - implements PluginView -{ - public state?: FilePanelState; - public emitUpdate: () => void; - - constructor( - private readonly editor: BlockNoteEditor, I, S>, - private readonly pluginKey: PluginKey>, - private readonly pmView: EditorView, - emitUpdate: (state: FilePanelState) => void, - ) { - this.emitUpdate = () => { - if (!this.state) { - throw new Error("Attempting to update uninitialized file panel"); - } - - emitUpdate(this.state); - }; - - pmView.dom.addEventListener("mousedown", this.mouseDownHandler); - pmView.dom.addEventListener("dragstart", this.dragstartHandler); - - // Setting capture=true ensures that any parent container of the editor that - // gets scrolled will trigger the scroll event. Scroll events do not bubble - // and so won't propagate to the document by default. - pmView.root.addEventListener("scroll", this.scrollHandler, true); - } - - mouseDownHandler = () => { - if (this.state?.show) { - this.state.show = false; - this.emitUpdate(); - } - }; - - // For dragging the whole editor. - dragstartHandler = () => { - if (this.state?.show) { - this.state.show = false; - this.emitUpdate(); - } - }; - - scrollHandler = () => { - if (this.state?.show) { - const blockElement = this.pmView.root.querySelector( - `[data-node-type="blockContainer"][data-id="${this.state.block.id}"]`, - ); - if (!blockElement) { - return; - } - this.state.referencePos = blockElement.getBoundingClientRect(); - this.emitUpdate(); - } - }; - - update(view: EditorView, prevState: EditorState) { - const pluginState = this.pluginKey.getState(view.state); - const prevPluginState = this.pluginKey.getState(prevState); - - if (!this.state?.show && pluginState?.block && this.editor.isEditable) { - const blockElement = this.pmView.root.querySelector( - `[data-node-type="blockContainer"][data-id="${pluginState.block.id}"]`, - ); - if (!blockElement) { - return; - } - this.state = { - show: true, - referencePos: blockElement.getBoundingClientRect(), - block: pluginState.block, - }; - - this.emitUpdate(); - - return; - } - - const isOpening = pluginState?.block && !prevPluginState?.block; - const isClosing = !pluginState?.block && prevPluginState?.block; - if (isOpening && this.state && !this.state.show) { - this.state.show = true; - this.emitUpdate(); - } - if (isClosing && this.state?.show) { - this.state.show = false; - this.emitUpdate(); - } - } - - closeMenu = () => { - if (this.state?.show) { - this.state.show = false; - this.emitUpdate(); - } - }; - - destroy() { - this.pmView.dom.removeEventListener("mousedown", this.mouseDownHandler); - - this.pmView.dom.removeEventListener("dragstart", this.dragstartHandler); - - this.pmView.root.removeEventListener("scroll", this.scrollHandler, true); - } -} - -const filePanelPluginKey = new PluginKey>( - "FilePanelPlugin", -); - -export class FilePanelProsemirrorPlugin< - I extends InlineContentSchema, - S extends StyleSchema, -> extends BlockNoteExtension { - public static key() { - return "filePanel"; +import { + createExtension, + createStore, +} from "../../editor/BlockNoteExtension.js"; +import { Plugin } from "@tiptap/pm/state"; + +export const FilePanelPlugin = createExtension((editor) => { + const store = createStore({ + blockId: undefined as string | undefined, + }); + + function closeMenu() { + store.setState({ + blockId: undefined, + }); } - private view: FilePanelView | undefined; - - constructor(editor: BlockNoteEditor, I, S>) { - super(); - this.addProsemirrorPlugin( - new Plugin<{ - block: BlockFromConfig | undefined; - }>({ - key: filePanelPluginKey, - view: (editorView) => { - this.view = new FilePanelView( - editor, - filePanelPluginKey as any, - editorView, - (state) => { - this.emit("update", state); - }, - ); - return this.view; - }, + // reset the menu when the document changes + editor.onChange( + closeMenu, + // don't trigger the callback if the changes are caused by a remote user + false, + ); + + // reset the menu when the selection changes + editor.onSelectionChange(closeMenu); + + return { + key: "filePanel", + store, + closeMenu, + plugins: [ + // TODO annoying to have to do this here + new Plugin({ props: { handleKeyDown: (_view, event: KeyboardEvent) => { - if (event.key === "Escape" && this.shown) { - this.view?.closeMenu(); + if (event.key === "Escape" && store.state.blockId) { + closeMenu(); return true; } return false; }, }, - state: { - init: () => { - return { - block: undefined, - }; - }, - apply: (transaction, prev) => { - const state: FilePanelState | undefined = - transaction.getMeta(filePanelPluginKey); - - if (state) { - return state; - } - - if ( - !transaction.getMeta(ySyncPluginKey) && - (transaction.selectionSet || transaction.docChanged) - ) { - return { block: undefined }; - } - return prev; - }, - }, }), - ); - } - - public get shown() { - return this.view?.state?.show || false; - } - - public onUpdate(callback: (state: FilePanelState) => void) { - return this.on("update", callback); - } - - public closeMenu = () => this.view?.closeMenu(); -} + ], + showMenu(blockId: string) { + store.setState({ + blockId, + }); + }, + init({ dom, root, abortController }) { + dom.addEventListener("mousedown", closeMenu, { + signal: abortController.signal, + }); + dom.addEventListener("dragstart", closeMenu, { + signal: abortController.signal, + }); + + root.addEventListener( + "scroll", + () => { + const blockId = store.state.blockId; + if (blockId) { + // TODO does this need to be here? Doesn't floating ui handle this? + // Show the menu again, to update it's position + this.showMenu(blockId); + } + }, + { + // Setting capture=true ensures that any parent container of the editor that + // gets scrolled will trigger the scroll event. Scroll events do not bubble + // and so won't propagate to the document by default. + capture: true, + signal: abortController.signal, + }, + ); + }, + } as const; +}); diff --git a/packages/core/src/extensions/FormattingToolbar/FormattingToolbarPlugin.ts b/packages/core/src/extensions/FormattingToolbar/FormattingToolbarPlugin.ts index 5bfa0f5dc8..7d72087bd2 100644 --- a/packages/core/src/extensions/FormattingToolbar/FormattingToolbarPlugin.ts +++ b/packages/core/src/extensions/FormattingToolbar/FormattingToolbarPlugin.ts @@ -1,363 +1,75 @@ -import { isNodeSelection, isTextSelection, posToDOMRect } from "@tiptap/core"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; import { - EditorState, - Plugin, - PluginKey, - PluginView, - TextSelection, -} from "prosemirror-state"; -import { EditorView } from "prosemirror-view"; + createExtension, + createStore, +} from "../../editor/BlockNoteExtension.js"; -import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; -import { BlockNoteExtension } from "../../editor/BlockNoteExtension.js"; -import { UiElementPosition } from "../../extensions-shared/UiElementPosition.js"; -import { - BlockSchema, - InlineContentSchema, - StyleSchema, -} from "../../schema/index.js"; - -export type FormattingToolbarState = UiElementPosition; - -export class FormattingToolbarView implements PluginView { - public state?: FormattingToolbarState; - public emitUpdate: () => void; - - public preventHide = false; - public preventShow = false; - - public shouldShow: (props: { - view: EditorView; - state: EditorState; - from: number; - to: number; - }) => boolean = ({ view, state, from, to }) => { - const { doc, selection } = state; - const { empty } = selection; - - // Sometime check for `empty` is not enough. - // Doubleclick an empty paragraph returns a node size of 2. - // So we check also for an empty text size. - const isEmptyTextBlock = - !doc.textBetween(from, to).length && isTextSelection(state.selection); - - // Don't show toolbar inside code blocks - if ( - selection.$from.parent.type.spec.code || - (isNodeSelection(selection) && selection.node.type.spec.code) - ) { - return false; - } - - if (empty || isEmptyTextBlock) { - return false; - } - - const focusedElement = document.activeElement; - if (!this.isElementWithinEditorWrapper(focusedElement) && view.editable) { - // editable editors must have focus for the toolbar to show - return false; - } - return true; - }; - - constructor( - private readonly editor: BlockNoteEditor< - BlockSchema, - InlineContentSchema, - StyleSchema - >, - private readonly pmView: EditorView, - emitUpdate: (state: FormattingToolbarState) => void, - ) { - this.emitUpdate = () => { - if (!this.state) { - throw new Error( - "Attempting to update uninitialized formatting toolbar", - ); - } - - emitUpdate(this.state); - }; - - pmView.dom.addEventListener("mousedown", this.viewMousedownHandler); - pmView.root.addEventListener("mouseup", this.mouseupHandler); - pmView.dom.addEventListener("dragstart", this.dragHandler); - pmView.dom.addEventListener("dragover", this.dragHandler); - pmView.dom.addEventListener("blur", this.blurHandler); - - // Setting capture=true ensures that any parent container of the editor that - // gets scrolled will trigger the scroll event. Scroll events do not bubble - // and so won't propagate to the document by default. - pmView.root.addEventListener("scroll", this.scrollHandler, true); - } - - blurHandler = (event: FocusEvent) => { - if (this.preventHide) { - this.preventHide = false; - - return; - } - - const editorWrapper = this.pmView.dom.parentElement!; - - // Checks if the focus is moving to an element outside the editor. If it is, - // the toolbar is hidden. - if ( - // An element is clicked. - event && - event.relatedTarget && - // Element is inside the editor. - (editorWrapper === (event.relatedTarget as Node) || - editorWrapper.contains(event.relatedTarget as Node) || - (event.relatedTarget as HTMLElement).matches( - ".bn-ui-container, .bn-ui-container *", - )) - ) { - return; - } - - if (this.state?.show) { - this.state.show = false; - this.emitUpdate(); - } - }; - - isElementWithinEditorWrapper = (element: Node | null) => { - if (!element) { - return false; - } - const editorWrapper = this.pmView.dom.parentElement!; - if (!editorWrapper) { - return false; - } - - return editorWrapper.contains(element); - }; - - viewMousedownHandler = (e: MouseEvent) => { - if ( - !this.isElementWithinEditorWrapper(e.target as Node) || - e.button === 0 - ) { - this.preventShow = true; - } - }; - - mouseupHandler = () => { - if (this.preventShow) { - this.preventShow = false; - setTimeout(() => this.update(this.pmView)); - } - }; - - // For dragging the whole editor. - dragHandler = () => { - if (this.state?.show) { - this.state.show = false; - this.emitUpdate(); - } - }; - - scrollHandler = () => { - if (this.state?.show) { - this.state.referencePos = this.getSelectionBoundingBox(); - this.emitUpdate(); - } - }; - - update(view: EditorView, oldState?: EditorState) { - // Delays the update to handle edge case with drag and drop, where the view - // is blurred asynchronously and happens only after the state update. - // Wrapping in a setTimeout gives enough time to wait for the blur event to - // occur before updating the toolbar. - const { state, composing } = view; - const { doc, selection } = state; - const isSame = - oldState && oldState.doc.eq(doc) && oldState.selection.eq(selection); - - if (composing || isSame) { - return; - } - - // 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)); - - const shouldShow = this.shouldShow({ - view, - state, - from, - to, - }); - - // in jsdom, Range.prototype.getClientRects is not implemented, - // this would cause `getSelectionBoundingBox` to fail - // we can just ignore jsdom for now and not show the toolbar - const jsdom = typeof Range.prototype.getClientRects === "undefined"; - - // Checks if menu should be shown/updated. - if (!this.preventShow && (shouldShow || this.preventHide) && !jsdom) { - // Unlike other UI elements, we don't prevent the formatting toolbar from - // showing when the editor is not editable. This is because some buttons, - // e.g. the download file button, should still be accessible. Therefore, - // logic for hiding when the editor is non-editable is handled - // individually in each button. - const newReferencePos = this.getSelectionBoundingBox(); +export const FormattingToolbarExtension = createExtension((editor) => { + const store = createStore({ show: false }); - // Workaround to ensure the correct reference position when rendering - // React components. Without this, e.g. updating styles on React inline - // content causes the formatting toolbar to be in the wrong place. We - // know the component has not yet rendered if the reference position has - // zero dimensions. - if (newReferencePos.height === 0 && newReferencePos.width === 0) { - // Updates the reference position again following the render. - queueMicrotask(() => { - const nextState = { - show: true, - referencePos: this.getSelectionBoundingBox(), - }; - - this.state = nextState; - this.emitUpdate(); - - // For some reason, while the selection doesn't actually change and - // remains correct, it visually appears to be collapsed. This forces - // a ProseMirror view update, which fixes the issue. - view.dispatch( - view.state.tr.setSelection( - TextSelection.create( - view.state.doc, - view.state.selection.from + 1, - view.state.selection.to, - ), - ), - ); - // 2 separate `dispatch` calls are needed, else ProseMirror realizes - // that the transaction is a no-op and doesn't update the view. - view.dispatch( - view.state.tr.setSelection( - TextSelection.create( - view.state.doc, - view.state.selection.from - 1, - view.state.selection.to, - ), - ), - ); - }); - - return; - } - - const nextState = { - show: true, - referencePos: this.getSelectionBoundingBox(), - }; - - if ( - nextState.show !== this.state?.show || - nextState.referencePos.toJSON() !== this.state?.referencePos.toJSON() - ) { - this.state = nextState; - this.emitUpdate(); - } - - return; - } - - // Checks if menu should be hidden. - if ( - this.state?.show && - !this.preventHide && - (!shouldShow || this.preventShow || !this.editor.isEditable) - ) { - this.state.show = false; - this.emitUpdate(); - - return; - } - } - - destroy() { - this.pmView.dom.removeEventListener("mousedown", this.viewMousedownHandler); - this.pmView.root.removeEventListener("mouseup", this.mouseupHandler); - this.pmView.dom.removeEventListener("dragstart", this.dragHandler); - this.pmView.dom.removeEventListener("dragover", this.dragHandler); - this.pmView.dom.removeEventListener("blur", this.blurHandler); - - this.pmView.root.removeEventListener("scroll", this.scrollHandler, true); - } - - closeMenu = () => { - if (this.state?.show) { - this.state.show = false; - this.emitUpdate(); - } - }; - - getSelectionBoundingBox() { - const { state } = this.pmView; - const { selection } = state; - - // 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.pmView.nodeDOM(from) as HTMLElement; - if (node) { - return node.getBoundingClientRect(); - } - } - - return posToDOMRect(this.pmView, from, to); - } -} - -export const formattingToolbarPluginKey = new PluginKey( - "FormattingToolbarPlugin", -); - -export class FormattingToolbarProsemirrorPlugin extends BlockNoteExtension { - public static key() { - return "formattingToolbar"; - } - - private view: FormattingToolbarView | undefined; - - constructor(editor: BlockNoteEditor) { - super(); - this.addProsemirrorPlugin( + return { + key: "formattingToolbar", + store: store, + plugins: [ new Plugin({ - key: formattingToolbarPluginKey, - view: (editorView) => { - this.view = new FormattingToolbarView(editor, editorView, (state) => { - this.emit("update", state); - }); - return this.view; - }, + key: new PluginKey("formattingToolbar"), props: { - handleKeyDown: (_view, event: KeyboardEvent) => { - if (event.key === "Escape" && this.shown) { - this.view!.closeMenu(); + handleKeyDown: (_view, event) => { + if (event.key === "Escape" && store.state.show) { + store.setState({ show: false }); return true; } return false; }, }, }), - ); - } + ], + init({ dom }) { + const isElementWithinEditorWrapper = (element: Node | null) => { + if (!element) { + return false; + } + const editorWrapper = dom.parentElement; + if (!editorWrapper) { + return false; + } + + return editorWrapper.contains(element); + }; - public get shown() { - return this.view?.state?.show || false; - } + function onMouseDownHandler(e: MouseEvent) { + if (!isElementWithinEditorWrapper(e.target as Node) || e.button === 0) { + store.setState({ show: false }); + } + } - public onUpdate(callback: (state: FormattingToolbarState) => void) { - return this.on("update", callback); - } + function onMouseUpHandler() { + setTimeout(() => { + if (editor.prosemirrorState.selection.empty) { + store.setState({ show: false }); + } else { + store.setState({ show: true }); + } + }, 1); + } - public closeMenu = () => this.view!.closeMenu(); -} + function onDragHandler() { + if (store.state.show) { + store.setState({ show: false }); + } + } + + dom.addEventListener("mousedown", onMouseDownHandler); + dom.addEventListener("mouseup", onMouseUpHandler); + dom.addEventListener("dragstart", onDragHandler); + dom.addEventListener("dragover", onDragHandler); + + return () => { + dom.removeEventListener("mousedown", onMouseDownHandler); + dom.removeEventListener("mouseup", onMouseUpHandler); + dom.removeEventListener("dragstart", onDragHandler); + dom.removeEventListener("dragover", onDragHandler); + }; + }, + } as const; +}); diff --git a/packages/core/src/extensions/History/HistoryExtension.ts b/packages/core/src/extensions/History/HistoryExtension.ts new file mode 100644 index 0000000000..cc4fd28417 --- /dev/null +++ b/packages/core/src/extensions/History/HistoryExtension.ts @@ -0,0 +1,16 @@ +import { History } from "@tiptap/extension-history"; +import { createExtension } from "../../editor/BlockNoteExtension.js"; +import { redo, undo } from "@tiptap/pm/history"; + +export const HistoryExtension = createExtension((_editor, options) => { + if (options.collaboration) { + return; + } + + return { + key: "history", + tiptapExtensions: [History], + undoCommand: undo, + redoCommand: redo, + } as const; +}); diff --git a/packages/core/src/extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts b/packages/core/src/extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts index 94230143db..f9d4148ca6 100644 --- a/packages/core/src/extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts +++ b/packages/core/src/extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts @@ -587,9 +587,11 @@ export const KeyboardShortcutsExtension = Extension.create<{ Tab: () => { if ( this.options.tabBehavior !== "prefer-indent" && - (this.options.editor.formattingToolbar?.shown || - this.options.editor.linkToolbar?.shown || - this.options.editor.filePanel?.shown) + true + // TODO what?? + // (this.options.editor.formattingToolbar?.shown || + // this.options.editor.linkToolbar?.shown || + // this.options.editor.filePanel?.shown) ) { // don't handle tabs if a toolbar is shown, so we can tab into / out of it return false; @@ -600,9 +602,11 @@ export const KeyboardShortcutsExtension = Extension.create<{ "Shift-Tab": () => { if ( this.options.tabBehavior !== "prefer-indent" && - (this.options.editor.formattingToolbar?.shown || - this.options.editor.linkToolbar?.shown || - this.options.editor.filePanel?.shown) + true + // TODO what?? + // (this.options.editor.formattingToolbar?.shown || + // this.options.editor.linkToolbar?.shown || + // this.options.editor.filePanel?.shown) ) { // don't handle tabs if a toolbar is shown, so we can tab into / out of it return false; diff --git a/packages/core/src/extensions/LinkToolbar/LinkToolbar.ts b/packages/core/src/extensions/LinkToolbar/LinkToolbar.ts new file mode 100644 index 0000000000..3f38034ec3 --- /dev/null +++ b/packages/core/src/extensions/LinkToolbar/LinkToolbar.ts @@ -0,0 +1,101 @@ +import { getMarkRange, posToDOMRect } from "@tiptap/core"; +import { createExtension } from "../../editor/BlockNoteExtension.js"; +import { getPmSchema } from "../../api/pmUtil.js"; + +export const LinkToolbarPlugin = createExtension((editor) => { + return { + key: "linkToolbar", + + getLinkElementAtPos(pos: number) { + let currentNode = editor.prosemirrorView.nodeDOM(pos); + while (currentNode && currentNode.parentElement) { + if (currentNode.nodeName === "A") { + return currentNode as HTMLAnchorElement; + } + currentNode = currentNode.parentElement; + } + return null; + }, + + getLinkAtElement(element: HTMLElement) { + return editor.transact(() => { + const posAtElement = editor.prosemirrorView.posAtDOM(element, 0) + 1; + return this.getMarkAtPos(posAtElement, "link"); + }); + }, + + getLinkAtSelection() { + return editor.transact((tr) => { + const selection = tr.selection; + return this.getMarkAtPos(selection.anchor, "link"); + }); + }, + + getMarkAtPos(pos: number, markType: string) { + return editor.transact((tr) => { + const resolvedPos = tr.doc.resolve(pos); + const mark = resolvedPos + .marks() + .find((mark) => mark.type.name === markType); + + if (!mark) { + return; + } + + const markRange = getMarkRange(resolvedPos, mark.type); + if (!markRange) { + return; + } + + return { + range: markRange, + mark, + get text() { + return tr.doc.textBetween(markRange.from, markRange.to); + }, + get position() { + // to minimize re-renders, we convert to JSON, which is the same shape anyway + return posToDOMRect( + editor.prosemirrorView, + markRange.from, + markRange.to, + ).toJSON() as DOMRect; + }, + }; + }); + }, + + editLink(url: string, text: string) { + editor.transact((tr) => { + const pmSchema = getPmSchema(tr); + const range = this.getLinkAtSelection()?.range; + if (!range) { + return; + } + tr.insertText(text, range.from, range.to); + tr.addMark( + range.from, + range.from + text.length, + pmSchema.mark("link", { href: url }), + ); + }); + editor.prosemirrorView.focus(); + }, + + deleteLink() { + editor.transact((tr) => { + const pmSchema = getPmSchema(tr); + const range = this.getLinkAtSelection()?.range; + if (!range) { + return; + } + + tr.removeMark(range.from, range.to, pmSchema.mark("link")).setMeta( + "preventAutolink", + true, + ); + }); + editor.prosemirrorView.focus(); + }, + } as const; +}); diff --git a/packages/core/src/extensions/LinkToolbar/LinkToolbarPlugin.ts b/packages/core/src/extensions/LinkToolbar/LinkToolbarPlugin.ts deleted file mode 100644 index a6b38e7f6f..0000000000 --- a/packages/core/src/extensions/LinkToolbar/LinkToolbarPlugin.ts +++ /dev/null @@ -1,380 +0,0 @@ -import { getMarkRange, posToDOMRect, Range } from "@tiptap/core"; - -import { EditorView } from "@tiptap/pm/view"; -import { Mark } from "prosemirror-model"; -import { EditorState, Plugin, PluginKey, PluginView } from "prosemirror-state"; - -import { getPmSchema } from "../../api/pmUtil.js"; -import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; -import { BlockNoteExtension } from "../../editor/BlockNoteExtension.js"; -import { UiElementPosition } from "../../extensions-shared/UiElementPosition.js"; -import { - BlockSchema, - InlineContentSchema, - StyleSchema, -} from "../../schema/index.js"; - -export type LinkToolbarState = UiElementPosition & { - // The hovered link's URL, and the text it's displayed with in the - // editor. - url: string; - text: string; -}; - -class LinkToolbarView implements PluginView { - public state?: LinkToolbarState; - public emitUpdate: () => void; - - menuUpdateTimer: ReturnType | undefined; - startMenuUpdateTimer: () => void; - stopMenuUpdateTimer: () => void; - - mouseHoveredLinkMark: Mark | undefined; - mouseHoveredLinkMarkRange: Range | undefined; - - keyboardHoveredLinkMark: Mark | undefined; - keyboardHoveredLinkMarkRange: Range | undefined; - - linkMark: Mark | undefined; - linkMarkRange: Range | undefined; - - constructor( - private readonly editor: BlockNoteEditor, - private readonly pmView: EditorView, - emitUpdate: (state: LinkToolbarState) => void, - ) { - this.emitUpdate = () => { - if (!this.state) { - throw new Error("Attempting to update uninitialized link toolbar"); - } - - emitUpdate(this.state); - }; - - this.startMenuUpdateTimer = () => { - this.menuUpdateTimer = setTimeout(() => { - this.update(this.pmView, undefined, true); - }, 250); - }; - - this.stopMenuUpdateTimer = () => { - if (this.menuUpdateTimer) { - clearTimeout(this.menuUpdateTimer); - this.menuUpdateTimer = undefined; - } - - return false; - }; - - this.pmView.dom.addEventListener("mouseover", this.mouseOverHandler); - this.pmView.root.addEventListener( - "click", - this.clickHandler as EventListener, - true, - ); - - // Setting capture=true ensures that any parent container of the editor that - // gets scrolled will trigger the scroll event. Scroll events do not bubble - // and so won't propagate to the document by default. - this.pmView.root.addEventListener("scroll", this.scrollHandler, true); - } - - mouseOverHandler = (event: MouseEvent) => { - // Resets the link mark currently hovered by the mouse cursor. - this.mouseHoveredLinkMark = undefined; - this.mouseHoveredLinkMarkRange = undefined; - - this.stopMenuUpdateTimer(); - - if ( - event.target instanceof HTMLAnchorElement && - event.target.nodeName === "A" - ) { - // Finds link mark at the hovered element's position to update mouseHoveredLinkMark and - // mouseHoveredLinkMarkRange. - const hoveredLinkElement = event.target; - const posInHoveredLinkMark = - this.pmView.posAtDOM(hoveredLinkElement, 0) + 1; - const resolvedPosInHoveredLinkMark = - this.pmView.state.doc.resolve(posInHoveredLinkMark); - const marksAtPos = resolvedPosInHoveredLinkMark.marks(); - - for (const mark of marksAtPos) { - if ( - mark.type.name === this.pmView.state.schema.mark("link").type.name - ) { - this.mouseHoveredLinkMark = mark; - this.mouseHoveredLinkMarkRange = - getMarkRange(resolvedPosInHoveredLinkMark, mark.type, mark.attrs) || - undefined; - - break; - } - } - } - - this.startMenuUpdateTimer(); - - return false; - }; - - clickHandler = (event: MouseEvent) => { - const editorWrapper = this.pmView.dom.parentElement!; - - if ( - // Toolbar is open. - this.linkMark && - // An element is clicked. - event && - event.target && - // The clicked element is not the editor. - !( - editorWrapper === (event.target as Node) || - editorWrapper.contains(event.target as Node) - ) - ) { - if (this.state?.show) { - this.state.show = false; - this.emitUpdate(); - } - } - }; - - scrollHandler = () => { - if (this.linkMark !== undefined) { - if (this.state?.show) { - this.state.referencePos = posToDOMRect( - this.pmView, - this.linkMarkRange!.from, - this.linkMarkRange!.to, - ); - this.emitUpdate(); - } - } - }; - - editLink(url: string, text: string) { - this.editor.transact((tr) => { - const pmSchema = getPmSchema(tr); - tr.insertText(text, this.linkMarkRange!.from, this.linkMarkRange!.to); - tr.addMark( - this.linkMarkRange!.from, - this.linkMarkRange!.from + text.length, - pmSchema.mark("link", { href: url }), - ); - }); - this.pmView.focus(); - - if (this.state?.show) { - this.state.show = false; - this.emitUpdate(); - } - } - - deleteLink() { - this.editor.transact((tr) => - tr - .removeMark( - this.linkMarkRange!.from, - this.linkMarkRange!.to, - this.linkMark!.type, - ) - .setMeta("preventAutolink", true), - ); - this.pmView.focus(); - - if (this.state?.show) { - this.state.show = false; - this.emitUpdate(); - } - } - - update(view: EditorView, oldState?: EditorState, fromMouseOver = false) { - const { state } = view; - - const isSame = - oldState && - oldState.selection.from === state.selection.from && - oldState.selection.to === state.selection.to; - - if (isSame || !this.pmView.hasFocus()) { - return; - } - - // Saves the currently hovered link mark before it's updated. - const prevLinkMark = this.linkMark; - - // Resets the currently hovered link mark. - this.linkMark = undefined; - this.linkMarkRange = undefined; - - // Resets the link mark currently hovered by the keyboard cursor. - this.keyboardHoveredLinkMark = undefined; - this.keyboardHoveredLinkMarkRange = undefined; - - // Finds link mark at the editor selection's position to update keyboardHoveredLinkMark and - // keyboardHoveredLinkMarkRange. - if (this.pmView.state.selection.empty) { - const marksAtPos = this.pmView.state.selection.$from.marks(); - - for (const mark of marksAtPos) { - if ( - mark.type.name === this.pmView.state.schema.mark("link").type.name - ) { - this.keyboardHoveredLinkMark = mark; - this.keyboardHoveredLinkMarkRange = - getMarkRange( - this.pmView.state.selection.$from, - mark.type, - mark.attrs, - ) || undefined; - - break; - } - } - } - - if (this.mouseHoveredLinkMark && fromMouseOver) { - this.linkMark = this.mouseHoveredLinkMark; - this.linkMarkRange = this.mouseHoveredLinkMarkRange; - } - - // Keyboard cursor position takes precedence over mouse hovered link. - if (this.keyboardHoveredLinkMark) { - this.linkMark = this.keyboardHoveredLinkMark; - this.linkMarkRange = this.keyboardHoveredLinkMarkRange; - } - - if (this.linkMark && this.editor.isEditable) { - this.state = { - show: true, - referencePos: posToDOMRect( - this.pmView, - this.linkMarkRange!.from, - this.linkMarkRange!.to, - ), - url: this.linkMark!.attrs.href, - text: this.pmView.state.doc.textBetween( - this.linkMarkRange!.from, - this.linkMarkRange!.to, - ), - }; - this.emitUpdate(); - - return; - } - - // Hides menu. - if ( - this.state?.show && - prevLinkMark && - (!this.linkMark || !this.editor.isEditable) - ) { - this.state.show = false; - this.emitUpdate(); - - return; - } - } - - closeMenu = () => { - if (this.state?.show) { - this.state.show = false; - this.emitUpdate(); - } - }; - - destroy() { - this.pmView.dom.removeEventListener("mouseover", this.mouseOverHandler); - this.pmView.root.removeEventListener("scroll", this.scrollHandler, true); - this.pmView.root.removeEventListener( - "click", - this.clickHandler as EventListener, - true, - ); - } -} - -export const linkToolbarPluginKey = new PluginKey("LinkToolbarPlugin"); - -export class LinkToolbarProsemirrorPlugin< - BSchema extends BlockSchema, - I extends InlineContentSchema, - S extends StyleSchema, -> extends BlockNoteExtension { - public static key() { - return "linkToolbar"; - } - - private view: LinkToolbarView | undefined; - - constructor(editor: BlockNoteEditor) { - super(); - this.addProsemirrorPlugin( - new Plugin({ - key: linkToolbarPluginKey, - view: (editorView) => { - this.view = new LinkToolbarView(editor, editorView, (state) => { - this.emit("update", state); - }); - return this.view; - }, - props: { - handleKeyDown: (_view, event: KeyboardEvent) => { - if (event.key === "Escape" && this.shown) { - this.view!.closeMenu(); - return true; - } - return false; - }, - }, - }), - ); - } - - public onUpdate(callback: (state: LinkToolbarState) => void) { - return this.on("update", callback); - } - - /** - * Edit the currently hovered link. - */ - public editLink = (url: string, text: string) => { - this.view!.editLink(url, text); - }; - - /** - * Delete the currently hovered link. - */ - public deleteLink = () => { - this.view!.deleteLink(); - }; - - /** - * When hovering on/off links using the mouse cursor, the link toolbar will - * open & close with a delay. - * - * This function starts the delay timer, and should be used for when the mouse - * cursor enters the link toolbar. - */ - public startHideTimer = () => { - this.view!.startMenuUpdateTimer(); - }; - - /** - * When hovering on/off links using the mouse cursor, the link toolbar will - * open & close with a delay. - * - * This function stops the delay timer, and should be used for when the mouse - * cursor exits the link toolbar. - */ - public stopHideTimer = () => { - this.view!.stopMenuUpdateTimer(); - }; - - public get shown() { - return this.view?.state?.show || false; - } - - public closeMenu = () => this.view!.closeMenu(); -} diff --git a/packages/core/src/extensions/NodeSelectionKeyboard/NodeSelectionKeyboardPlugin.ts b/packages/core/src/extensions/NodeSelectionKeyboard/NodeSelectionKeyboardPlugin.ts index 0b1428b637..1616172f20 100644 --- a/packages/core/src/extensions/NodeSelectionKeyboard/NodeSelectionKeyboardPlugin.ts +++ b/packages/core/src/extensions/NodeSelectionKeyboard/NodeSelectionKeyboardPlugin.ts @@ -1,5 +1,5 @@ import { Plugin, PluginKey, TextSelection } from "prosemirror-state"; -import { BlockNoteExtension } from "../../editor/BlockNoteExtension.js"; +import { createExtension } from "../../editor/BlockNoteExtension.js"; const PLUGIN_KEY = new PluginKey("node-selection-keyboard"); // By default, typing with a node selection active will cause ProseMirror to @@ -16,60 +16,59 @@ const PLUGIN_KEY = new PluginKey("node-selection-keyboard"); // While a more elegant solution would probably process transactions instead of // keystrokes, this brings us most of the way to Notion's UX without much added // complexity. -export class NodeSelectionKeyboardPlugin extends BlockNoteExtension { - public static key() { - return "nodeSelectionKeyboard"; - } +export const NodeSelectionKeyboardPlugin = createExtension( + () => + ({ + key: "nodeSelectionKeyboard", + plugins: [ + new Plugin({ + key: PLUGIN_KEY, + props: { + handleKeyDown: (view, event) => { + // Checks for node selection + if ("node" in view.state.selection) { + // Checks if key press uses ctrl/meta modifier + if (event.ctrlKey || event.metaKey) { + return false; + } + // Checks if key press is alphanumeric + if (event.key.length === 1) { + event.preventDefault(); - constructor() { - super(); - this.addProsemirrorPlugin( - new Plugin({ - key: PLUGIN_KEY, - props: { - handleKeyDown: (view, event) => { - // Checks for node selection - if ("node" in view.state.selection) { - // Checks if key press uses ctrl/meta modifier - if (event.ctrlKey || event.metaKey) { - return false; - } - // Checks if key press is alphanumeric - if (event.key.length === 1) { - event.preventDefault(); - - return true; - } - // Checks if key press is Enter - if ( - event.key === "Enter" && - !event.shiftKey && - !event.altKey && - !event.ctrlKey && - !event.metaKey - ) { - const tr = view.state.tr; - view.dispatch( - tr - .insert( - view.state.tr.selection.$to.after(), - view.state.schema.nodes["paragraph"].createChecked(), - ) - .setSelection( - new TextSelection( - tr.doc.resolve(view.state.tr.selection.$to.after() + 1), + return true; + } + // Checks if key press is Enter + if ( + event.key === "Enter" && + !event.shiftKey && + !event.altKey && + !event.ctrlKey && + !event.metaKey + ) { + const tr = view.state.tr; + view.dispatch( + tr + .insert( + view.state.tr.selection.$to.after(), + view.state.schema.nodes["paragraph"].createChecked(), + ) + .setSelection( + new TextSelection( + tr.doc.resolve( + view.state.tr.selection.$to.after() + 1, + ), + ), ), - ), - ); + ); - return true; + return true; + } } - } - return false; + return false; + }, }, - }, - }), - ); - } -} + }), + ], + }) as const, +); diff --git a/packages/core/src/extensions/Placeholder/PlaceholderPlugin.ts b/packages/core/src/extensions/Placeholder/PlaceholderPlugin.ts index b2bdc46858..a04ff36b0f 100644 --- a/packages/core/src/extensions/Placeholder/PlaceholderPlugin.ts +++ b/packages/core/src/extensions/Placeholder/PlaceholderPlugin.ts @@ -1,25 +1,16 @@ import { Plugin, PluginKey } from "prosemirror-state"; import { Decoration, DecorationSet } from "prosemirror-view"; import { v4 } from "uuid"; -import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; -import { BlockNoteExtension } from "../../editor/BlockNoteExtension.js"; +import { createExtension } from "../../editor/BlockNoteExtension.js"; const PLUGIN_KEY = new PluginKey(`blocknote-placeholder`); -export class PlaceholderPlugin extends BlockNoteExtension { - public static key() { - return "placeholder"; - } - - constructor( - editor: BlockNoteEditor, - placeholders: Record< - string | "default" | "emptyDocument", - string | undefined - >, - ) { - super(); - this.addProsemirrorPlugin( +export const PlaceholderPlugin = createExtension((editor, options) => { + // TODO defaults? + const placeholders = options.placeholders; + return { + key: "placeholder", + plugins: [ new Plugin({ key: PLUGIN_KEY, view: (view) => { @@ -142,6 +133,6 @@ export class PlaceholderPlugin extends BlockNoteExtension { }, }, }), - ); - } -} + ], + } as const; +}); diff --git a/packages/core/src/extensions/PreviousBlockType/PreviousBlockTypePlugin.ts b/packages/core/src/extensions/PreviousBlockType/PreviousBlockTypePlugin.ts index 587c005aab..d06b8620fc 100644 --- a/packages/core/src/extensions/PreviousBlockType/PreviousBlockTypePlugin.ts +++ b/packages/core/src/extensions/PreviousBlockType/PreviousBlockTypePlugin.ts @@ -1,7 +1,7 @@ import { findChildren } from "@tiptap/core"; import { Plugin, PluginKey } from "prosemirror-state"; import { Decoration, DecorationSet } from "prosemirror-view"; -import { BlockNoteExtension } from "../../editor/BlockNoteExtension.js"; +import { createExtension } from "../../editor/BlockNoteExtension.js"; const PLUGIN_KEY = new PluginKey(`previous-blocks`); @@ -24,15 +24,14 @@ const nodeAttributes: Record = { * * Solution: When attributes change on a node, this plugin sets a data-* attribute with the "previous" value. This way we can still use CSS transitions. (See block.module.css) */ -export class PreviousBlockTypePlugin extends BlockNoteExtension { - public static key() { - return "previousBlockType"; +export const PreviousBlockTypePlugin = createExtension((_editor, options) => { + if (options.animations === false) { + return; } - - constructor() { - super(); - let timeout: ReturnType; - this.addProsemirrorPlugin( + let timeout: ReturnType; + return { + key: "previousBlockType", + plugins: [ new Plugin({ key: PLUGIN_KEY, view(_editorView) { @@ -207,6 +206,6 @@ export class PreviousBlockTypePlugin extends BlockNoteExtension { }, }, }), - ); - } -} + ], + } as const; +}); diff --git a/packages/core/src/extensions/ShowSelection/ShowSelectionPlugin.ts b/packages/core/src/extensions/ShowSelection/ShowSelectionPlugin.ts index 0cf7ed2716..423e534a7c 100644 --- a/packages/core/src/extensions/ShowSelection/ShowSelectionPlugin.ts +++ b/packages/core/src/extensions/ShowSelection/ShowSelectionPlugin.ts @@ -1,7 +1,10 @@ import { Plugin, PluginKey } from "prosemirror-state"; import { Decoration, DecorationSet } from "prosemirror-view"; import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; -import { BlockNoteExtension } from "../../editor/BlockNoteExtension.js"; +import { + createExtension, + createStore, +} from "../../editor/BlockNoteExtension.js"; const PLUGIN_KEY = new PluginKey(`blocknote-show-selection`); @@ -10,48 +13,36 @@ const PLUGIN_KEY = new PluginKey(`blocknote-show-selection`); * This can be used to highlight the current selection in the UI even when the * text editor is not focused. */ -export class ShowSelectionPlugin extends BlockNoteExtension { - public static key() { - return "showSelection"; - } - - private enabled = false; - - public constructor(private readonly editor: BlockNoteEditor) { - super(); - this.addProsemirrorPlugin( - new Plugin({ - key: PLUGIN_KEY, - props: { - decorations: (state) => { - const { doc, selection } = state; - - if (!this.enabled) { - return DecorationSet.empty; - } - - const dec = Decoration.inline(selection.from, selection.to, { - "data-show-selection": "true", - }); - - return DecorationSet.create(doc, [dec]); - }, +export const ShowSelectionPlugin = createExtension( + (editor: BlockNoteEditor, _options) => { + const store = createStore( + { enabled: false }, + { + onUpdate() { + editor.transact((tr) => tr.setMeta(PLUGIN_KEY, {})); }, - }), + }, ); - } - - public setEnabled(enabled: boolean) { - if (this.enabled === enabled) { - return; - } - - this.enabled = enabled; - - this.editor.transact((tr) => tr.setMeta(PLUGIN_KEY, {})); - } - - public getEnabled() { - return this.enabled; - } -} + return { + key: "showSelection", + store, + plugins: [ + new Plugin({ + key: PLUGIN_KEY, + props: { + decorations: (state) => { + const { doc, selection } = state; + if (!store.state.enabled) { + return DecorationSet.empty; + } + const dec = Decoration.inline(selection.from, selection.to, { + "data-show-selection": "true", + }); + return DecorationSet.create(doc, [dec]); + }, + }, + }), + ], + } as const; + }, +); diff --git a/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts b/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts index 0ba69b9893..e5d1298d59 100644 --- a/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts +++ b/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts @@ -10,7 +10,10 @@ import { EditorView } from "@tiptap/pm/view"; import { Block } from "../../blocks/defaultBlocks.js"; import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; -import { BlockNoteExtension } from "../../editor/BlockNoteExtension.js"; +import { + createExtension, + createStore, +} from "../../editor/BlockNoteExtension.js"; import { UiElementPosition } from "../../extensions-shared/UiElementPosition.js"; import { BlockSchema, @@ -682,83 +685,72 @@ export class SideMenuView< export const sideMenuPluginKey = new PluginKey("SideMenuPlugin"); -export class SideMenuProsemirrorPlugin< - BSchema extends BlockSchema, - I extends InlineContentSchema, - S extends StyleSchema, -> extends BlockNoteExtension { - public static key() { - return "sideMenu"; - } - - public view: SideMenuView | undefined; +export const SideMenuProsemirrorPlugin = createExtension((editor) => { + let view: SideMenuView | undefined; + const store = createStore | undefined>( + undefined, + ); - constructor(private readonly editor: BlockNoteEditor) { - super(); - this.addProsemirrorPlugin( + return { + key: "sideMenu", + store, + plugins: [ new Plugin({ key: sideMenuPluginKey, view: (editorView) => { - this.view = new SideMenuView(editor, editorView, (state) => { - this.emit("update", state); + view = new SideMenuView(editor, editorView, (state) => { + store.setState(state); }); - return this.view; + return view; }, }), - ); - } - - public onUpdate(callback: (state: SideMenuState) => void) { - return this.on("update", callback); - } - - /** - * Handles drag & drop events for blocks. - */ - blockDragStart = ( - event: { - dataTransfer: DataTransfer | null; - clientY: number; + ], + + /** + * Handles drag & drop events for blocks. + */ + blockDragStart( + event: { dataTransfer: DataTransfer | null; clientY: number }, + block: Block, + ) { + if (view) { + view.isDragOrigin = true; + } + dragStart(event, block, editor); }, - block: Block, - ) => { - if (this.view) { - this.view.isDragOrigin = true; - } - dragStart(event, block, this.editor); - }; + /** + * Handles drag & drop events for blocks. + */ + blockDragEnd() { + unsetDragImage(editor.prosemirrorView.root); + if (view) { + view.isDragOrigin = false; + } - /** - * Handles drag & drop events for blocks. - */ - blockDragEnd = () => { - unsetDragImage(this.editor.prosemirrorView.root); + editor.blur(); + }, - if (this.view) { - this.view.isDragOrigin = false; - } + /** + * Freezes the side menu. When frozen, the side menu will stay + * attached to the same block regardless of which block is hovered by the + * mouse cursor. + */ + freezeMenu() { + view!.menuFrozen = true; + view!.state!.show = true; + view!.emitUpdate(view!.state!); + }, - this.editor.blur(); - }; - /** - * Freezes the side menu. When frozen, the side menu will stay - * attached to the same block regardless of which block is hovered by the - * mouse cursor. - */ - freezeMenu = () => { - this.view!.menuFrozen = true; - this.view!.state!.show = true; - this.view!.emitUpdate(this.view!.state!); - }; - /** - * Unfreezes the side menu. When frozen, the side menu will stay - * attached to the same block regardless of which block is hovered by the - * mouse cursor. - */ - unfreezeMenu = () => { - this.view!.menuFrozen = false; - this.view!.state!.show = false; - this.view!.emitUpdate(this.view!.state!); - }; -} + /** + * Unfreezes the side menu. When frozen, the side menu will stay + * attached to the same block regardless of which block is hovered by the + * mouse cursor. + */ + unfreezeMenu() { + view!.menuFrozen = false; + view!.state!.show = false; + view!.emitUpdate(view!.state!); + }, + } as const; +}); diff --git a/packages/core/src/extensions/SuggestionMenu/SuggestionPlugin.ts b/packages/core/src/extensions/SuggestionMenu/SuggestionPlugin.ts index 0c4bb7f4a6..ef3eb5d097 100644 --- a/packages/core/src/extensions/SuggestionMenu/SuggestionPlugin.ts +++ b/packages/core/src/extensions/SuggestionMenu/SuggestionPlugin.ts @@ -3,14 +3,12 @@ import { EditorState, Plugin, PluginKey } from "prosemirror-state"; import { Decoration, DecorationSet, EditorView } from "prosemirror-view"; import { trackPosition } from "../../api/positionMapping.js"; -import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; -import { BlockNoteExtension } from "../../editor/BlockNoteExtension.js"; -import { UiElementPosition } from "../../extensions-shared/UiElementPosition.js"; import { - BlockSchema, - InlineContentSchema, - StyleSchema, -} from "../../schema/index.js"; + createExtension, + createStore, +} from "../../editor/BlockNoteExtension.js"; +import { UiElementPosition } from "../../extensions-shared/UiElementPosition.js"; +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; const findBlock = findParentNode((node) => node.type.name === "blockContainer"); @@ -19,18 +17,14 @@ export type SuggestionMenuState = UiElementPosition & { ignoreQueryLength?: boolean; }; -class SuggestionMenuView< - BSchema extends BlockSchema, - I extends InlineContentSchema, - S extends StyleSchema, -> { +class SuggestionMenuView { public state?: SuggestionMenuState; public emitUpdate: (triggerCharacter: string) => void; private rootEl?: Document | ShadowRoot; pluginState: SuggestionPluginState; constructor( - private readonly editor: BlockNoteEditor, + private readonly editor: BlockNoteEditor, emitUpdate: (menuName: string, state: SuggestionMenuState) => void, view: EditorView, ) { @@ -163,34 +157,68 @@ const suggestionMenuPluginKey = new PluginKey("SuggestionMenuPlugin"); * - This version hides some unnecessary complexity from the user of the plugin. * - This version handles key events differently */ -export class SuggestionMenuProseMirrorPlugin< - BSchema extends BlockSchema, - I extends InlineContentSchema, - S extends StyleSchema, -> extends BlockNoteExtension { - public static key() { - return "suggestionMenu"; - } - - private view: SuggestionMenuView | undefined; - private triggerCharacters: string[] = []; +export const SuggestionMenuPlugin = createExtension((editor) => { + const triggerCharacters: string[] = []; + let view: SuggestionMenuView | undefined = undefined; + const store = createStore< + (SuggestionMenuState & { triggerCharacter: string }) | undefined + >(undefined); + return { + key: "suggestionMenu", + store, + addTriggerCharacter: (triggerCharacter: string) => { + triggerCharacters.push(triggerCharacter); + }, + removeTriggerCharacter: (triggerCharacter: string) => { + triggerCharacters.splice(triggerCharacters.indexOf(triggerCharacter), 1); + }, + closeMenu: () => { + view?.closeMenu(); + }, + clearQuery: () => { + view?.clearQuery(); + }, + shown: () => { + return view?.state?.show || false; + }, + openSuggestionMenu: ( + triggerCharacter: string, + pluginState?: { + deleteTriggerCharacter?: boolean; + ignoreQueryLength?: boolean; + }, + ) => { + if (editor.headless) { + return; + } - constructor(editor: BlockNoteEditor) { - super(); - const triggerCharacters = this.triggerCharacters; - this.addProsemirrorPlugin( + editor.focus(); + + editor.transact((tr) => { + if (pluginState?.deleteTriggerCharacter) { + tr.insertText(triggerCharacter); + } + tr.scrollIntoView().setMeta(suggestionMenuPluginKey, { + triggerCharacter: triggerCharacter, + deleteTriggerCharacter: pluginState?.deleteTriggerCharacter || false, + ignoreQueryLength: pluginState?.ignoreQueryLength || false, + }); + }); + }, + // TODO this whole plugin needs to be refactored (but I've done the minimal) + plugins: [ new Plugin({ key: suggestionMenuPluginKey, - view: (view) => { - this.view = new SuggestionMenuView( + view: (v) => { + view = new SuggestionMenuView( editor, (triggerCharacter, state) => { - this.emit(`update ${triggerCharacter}`, state); + store.setState({ ...state, triggerCharacter }); }, - view, + v, ); - return this.view; + return view; }, state: { @@ -225,7 +253,7 @@ export class SuggestionMenuProseMirrorPlugin< ) { if (prev) { // Close the previous menu if it exists - this.closeMenu(); + view?.closeMenu(); } const trackedPosition = trackPosition( editor, @@ -360,44 +388,6 @@ export class SuggestionMenuProseMirrorPlugin< }, }, }), - ); - } - - public onUpdate( - triggerCharacter: string, - callback: (state: SuggestionMenuState) => void, - ) { - if (!this.triggerCharacters.includes(triggerCharacter)) { - this.addTriggerCharacter(triggerCharacter); - } - // TODO: be able to remove the triggerCharacter - return this.on(`update ${triggerCharacter}`, callback); - } - - addTriggerCharacter = (triggerCharacter: string) => { - this.triggerCharacters.push(triggerCharacter); - }; - - // TODO: Should this be called automatically when listeners are removed? - removeTriggerCharacter = (triggerCharacter: string) => { - this.triggerCharacters = this.triggerCharacters.filter( - (c) => c !== triggerCharacter, - ); - }; - - closeMenu = () => this.view!.closeMenu(); - - clearQuery = () => this.view!.clearQuery(); - - public get shown() { - return this.view?.state?.show || false; - } -} - -export function createSuggestionMenu< - BSchema extends BlockSchema, - I extends InlineContentSchema, - S extends StyleSchema, ->(editor: BlockNoteEditor, triggerCharacter: string) { - editor.suggestionMenus.addTriggerCharacter(triggerCharacter); -} + ], + } as const; +}); diff --git a/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts b/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts index 921d181d1a..b5289476dd 100644 --- a/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts +++ b/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts @@ -10,6 +10,8 @@ import { } from "../../schema/index.js"; import { formatKeyboardShortcut } from "../../util/browser.js"; import { DefaultSuggestionItem } from "./DefaultSuggestionItem.js"; +import { FilePanelPlugin } from "../FilePanel/FilePanelPlugin.js"; +import { SuggestionMenuPlugin } from "./SuggestionPlugin.js"; // Sets the editor's text cursor position to the next content editable block, // so either a block with inline content or a table. The last block is always a @@ -257,11 +259,7 @@ export function getDefaultSlashMenuItems< }); // Immediately open the file toolbar - editor.transact((tr) => - tr.setMeta(editor.filePanel!.plugins[0], { - block: insertedBlock, - }), - ); + editor.getExtension(FilePanelPlugin)?.showMenu(insertedBlock.id); }, key: "image", ...editor.dictionary.slash_menu.image, @@ -276,11 +274,7 @@ export function getDefaultSlashMenuItems< }); // Immediately open the file toolbar - editor.transact((tr) => - tr.setMeta(editor.filePanel!.plugins[0], { - block: insertedBlock, - }), - ); + editor.getExtension(FilePanelPlugin)?.showMenu(insertedBlock.id); }, key: "video", ...editor.dictionary.slash_menu.video, @@ -295,11 +289,7 @@ export function getDefaultSlashMenuItems< }); // Immediately open the file toolbar - editor.transact((tr) => - tr.setMeta(editor.filePanel!.plugins[0], { - block: insertedBlock, - }), - ); + editor.getExtension(FilePanelPlugin)?.showMenu(insertedBlock.id); }, key: "audio", ...editor.dictionary.slash_menu.audio, @@ -314,11 +304,7 @@ export function getDefaultSlashMenuItems< }); // Immediately open the file toolbar - editor.transact((tr) => - tr.setMeta(editor.filePanel!.plugins[0], { - block: insertedBlock, - }), - ); + editor.getExtension(FilePanelPlugin)?.showMenu(insertedBlock.id); }, key: "file", ...editor.dictionary.slash_menu.file, @@ -385,7 +371,7 @@ export function getDefaultSlashMenuItems< items.push({ onItemClick: () => { - editor.openSuggestionMenu(":", { + editor.getExtension(SuggestionMenuPlugin)?.openSuggestionMenu(":", { deleteTriggerCharacter: true, ignoreQueryLength: true, }); diff --git a/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts b/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts index f90fe9e95b..6831d3f424 100644 --- a/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts +++ b/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts @@ -32,29 +32,27 @@ import { } from "../../blocks/defaultBlockTypeGuards.js"; import { DefaultBlockSchema } from "../../blocks/defaultBlocks.js"; import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; -import { BlockNoteExtension } from "../../editor/BlockNoteExtension.js"; +import { + createExtension, + createStore, +} from "../../editor/BlockNoteExtension.js"; import { BlockFromConfigNoChildren, BlockSchemaWithBlock, - InlineContentSchema, - StyleSchema, } from "../../schema/index.js"; import { getDraggableBlockFromElement } from "../getDraggableBlockFromElement.js"; let dragImageElement: HTMLElement | undefined; // TODO consider switching this to jotai, it is a bit messy and noisy -export type TableHandlesState< - I extends InlineContentSchema, - S extends StyleSchema, -> = { +export type TableHandlesState = { show: boolean; showAddOrRemoveRowsButton: boolean; showAddOrRemoveColumnsButton: boolean; referencePosCell: DOMRect | undefined; referencePosTable: DOMRect; - block: BlockFromConfigNoChildren; + block: BlockFromConfigNoChildren; colIndex: number | undefined; rowIndex: number | undefined; @@ -144,12 +142,8 @@ function hideElements(selector: string, rootEl: Document | ShadowRoot) { } } -export class TableHandlesView< - I extends InlineContentSchema, - S extends StyleSchema, -> implements PluginView -{ - public state?: TableHandlesState; +export class TableHandlesView implements PluginView { + public state?: TableHandlesState; public emitUpdate: () => void; public tableId: string | undefined; @@ -165,11 +159,11 @@ export class TableHandlesView< constructor( private readonly editor: BlockNoteEditor< BlockSchemaWithBlock<"table", DefaultBlockSchema["table"]>, - I, - S + any, + any >, private readonly pmView: EditorView, - emitUpdate: (state: TableHandlesState) => void, + emitUpdate: (state: TableHandlesState) => void, ) { this.emitUpdate = () => { if (!this.state) { @@ -260,7 +254,7 @@ export class TableHandlesView< this.tableElement = blockEl.node; let tableBlock: - | BlockFromConfigNoChildren + | BlockFromConfigNoChildren | undefined; const pmNodeInfo = this.editor.transact((tr) => @@ -614,57 +608,47 @@ export class TableHandlesView< export const tableHandlesPluginKey = new PluginKey("TableHandlesPlugin"); -export class TableHandlesProsemirrorPlugin< - I extends InlineContentSchema, - S extends StyleSchema, -> extends BlockNoteExtension { - public static key() { - return "tableHandles"; - } +export const TableHandlesPlugin = createExtension((editor) => { + let view: TableHandlesView | undefined = undefined; - private view: TableHandlesView | undefined; + const store = createStore(undefined); - constructor( - private readonly editor: BlockNoteEditor< - BlockSchemaWithBlock<"table", DefaultBlockSchema["table"]>, - I, - S - >, - ) { - super(); - this.addProsemirrorPlugin( + return { + key: "tableHandles", + store, + plugins: [ new Plugin({ key: tableHandlesPluginKey, view: (editorView) => { - this.view = new TableHandlesView(editor, editorView, (state) => { - this.emit("update", state); + view = new TableHandlesView(editor as any, editorView, (state) => { + store.setState(state); }); - return this.view; + return view; }, // We use decorations to render the drop cursor when dragging a table row // or column. The decorations are updated in the `dragOverHandler` method. props: { decorations: (state) => { if ( - this.view === undefined || - this.view.state === undefined || - this.view.state.draggingState === undefined || - this.view.tablePos === undefined + view === undefined || + view.state === undefined || + view.state.draggingState === undefined || + view.tablePos === undefined ) { return; } const newIndex = - this.view.state.draggingState.draggedCellOrientation === "row" - ? this.view.state.rowIndex - : this.view.state.colIndex; + view.state.draggingState.draggedCellOrientation === "row" + ? view.state.rowIndex + : view.state.colIndex; if (newIndex === undefined) { return; } const decorations: Decoration[] = []; - const { block, draggingState } = this.view.state; + const { block, draggingState } = view.state; const { originalIndex, draggedCellOrientation } = draggingState; // Return empty decorations if: @@ -684,13 +668,11 @@ export class TableHandlesProsemirrorPlugin< } // Gets the table to show the drop cursor in. - const tableResolvedPos = state.doc.resolve(this.view.tablePos + 1); + const tableResolvedPos = state.doc.resolve(view.tablePos + 1); - if ( - this.view.state.draggingState.draggedCellOrientation === "row" - ) { + if (view.state.draggingState.draggedCellOrientation === "row") { const cellsInRow = getCellsAtRowHandle( - this.view.state.block, + view.state.block, newIndex, ); @@ -736,7 +718,7 @@ export class TableHandlesProsemirrorPlugin< }); } else { const cellsInColumn = getCellsAtColumnHandle( - this.view.state.block, + view.state.block, newIndex, ); @@ -788,423 +770,415 @@ export class TableHandlesProsemirrorPlugin< }, }, }), - ); - } - - public onUpdate(callback: (state: TableHandlesState) => void) { - return this.on("update", callback); - } - - /** - * Callback that should be set on the `dragStart` event for whichever element - * is used as the column drag handle. - */ - colDragStart = (event: { - dataTransfer: DataTransfer | null; - clientX: number; - }) => { - if ( - this.view!.state === undefined || - this.view!.state.colIndex === undefined - ) { - throw new Error( - "Attempted to drag table column, but no table block was hovered prior.", - ); - } - - this.view!.state.draggingState = { - draggedCellOrientation: "col", - originalIndex: this.view!.state.colIndex, - mousePos: event.clientX, - }; - this.view!.emitUpdate(); - - this.editor.transact((tr) => - tr.setMeta(tableHandlesPluginKey, { - draggedCellOrientation: - this.view!.state!.draggingState!.draggedCellOrientation, - originalIndex: this.view!.state!.colIndex, - newIndex: this.view!.state!.colIndex, - tablePos: this.view!.tablePos, - }), - ); - - if (this.editor.headless) { - return; - } - - setHiddenDragImage(this.editor.prosemirrorView.root); - event.dataTransfer!.setDragImage(dragImageElement!, 0, 0); - event.dataTransfer!.effectAllowed = "move"; - }; + ], + + /** + * Callback that should be set on the `dragStart` event for whichever element + * is used as the column drag handle. + */ + colDragStart(event: { + dataTransfer: DataTransfer | null; + clientX: number; + }) { + if ( + view === undefined || + view.state === undefined || + view.state.colIndex === undefined + ) { + throw new Error( + "Attempted to drag table column, but no table block was hovered prior.", + ); + } - /** - * Callback that should be set on the `dragStart` event for whichever element - * is used as the row drag handle. - */ - rowDragStart = (event: { - dataTransfer: DataTransfer | null; - clientY: number; - }) => { - if ( - this.view!.state === undefined || - this.view!.state.rowIndex === undefined - ) { - throw new Error( - "Attempted to drag table row, but no table block was hovered prior.", + view.state.draggingState = { + draggedCellOrientation: "col", + originalIndex: view.state.colIndex, + mousePos: event.clientX, + }; + view.emitUpdate(); + + editor.transact((tr) => + tr.setMeta(tableHandlesPluginKey, { + draggedCellOrientation: + view!.state!.draggingState!.draggedCellOrientation, + originalIndex: view!.state!.colIndex, + newIndex: view!.state!.colIndex, + tablePos: view!.tablePos, + }), ); - } - - this.view!.state.draggingState = { - draggedCellOrientation: "row", - originalIndex: this.view!.state.rowIndex, - mousePos: event.clientY, - }; - this.view!.emitUpdate(); - - this.editor.transact((tr) => - tr.setMeta(tableHandlesPluginKey, { - draggedCellOrientation: - this.view!.state!.draggingState!.draggedCellOrientation, - originalIndex: this.view!.state!.rowIndex, - newIndex: this.view!.state!.rowIndex, - tablePos: this.view!.tablePos, - }), - ); - if (this.editor.headless) { - return; - } + if (editor.headless) { + return; + } - setHiddenDragImage(this.editor.prosemirrorView.root); - event.dataTransfer!.setDragImage(dragImageElement!, 0, 0); - event.dataTransfer!.effectAllowed = "copyMove"; - }; + setHiddenDragImage(editor.prosemirrorView.root); + event.dataTransfer!.setDragImage(dragImageElement!, 0, 0); + event.dataTransfer!.effectAllowed = "move"; + }, + + /** + * Callback that should be set on the `dragStart` event for whichever element + * is used as the row drag handle. + */ + rowDragStart(event: { + dataTransfer: DataTransfer | null; + clientY: number; + }) { + if (view!.state === undefined || view!.state.rowIndex === undefined) { + throw new Error( + "Attempted to drag table row, but no table block was hovered prior.", + ); + } - /** - * Callback that should be set on the `dragEnd` event for both the element - * used as the row drag handle, and the one used as the column drag handle. - */ - dragEnd = () => { - if (this.view!.state === undefined) { - throw new Error( - "Attempted to drag table row, but no table block was hovered prior.", + view!.state.draggingState = { + draggedCellOrientation: "row", + originalIndex: view!.state.rowIndex, + mousePos: event.clientY, + }; + view!.emitUpdate(); + + editor.transact((tr) => + tr.setMeta(tableHandlesPluginKey, { + draggedCellOrientation: + view!.state!.draggingState!.draggedCellOrientation, + originalIndex: view!.state!.rowIndex, + newIndex: view!.state!.rowIndex, + tablePos: view!.tablePos, + }), ); - } - this.view!.state.draggingState = undefined; - this.view!.emitUpdate(); - - this.editor.transact((tr) => tr.setMeta(tableHandlesPluginKey, null)); - - if (this.editor.headless) { - return; - } - - unsetHiddenDragImage(this.editor.prosemirrorView.root); - }; - - /** - * Freezes the drag handles. When frozen, they will stay attached to the same - * cell regardless of which cell is hovered by the mouse cursor. - */ - freezeHandles = () => { - this.view!.menuFrozen = true; - }; - - /** - * Unfreezes the drag handles. When frozen, they will stay attached to the - * same cell regardless of which cell is hovered by the mouse cursor. - */ - unfreezeHandles = () => { - this.view!.menuFrozen = false; - }; + if (editor.headless) { + return; + } - getCellsAtRowHandle = ( - block: BlockFromConfigNoChildren, - relativeRowIndex: RelativeCellIndices["row"], - ) => { - return getCellsAtRowHandle(block, relativeRowIndex); - }; + setHiddenDragImage(editor.prosemirrorView.root); + event.dataTransfer!.setDragImage(dragImageElement!, 0, 0); + event.dataTransfer!.effectAllowed = "copyMove"; + }, + + /** + * Callback that should be set on the `dragEnd` event for both the element + * used as the row drag handle, and the one used as the column drag handle. + */ + dragEnd() { + if (view!.state === undefined) { + throw new Error( + "Attempted to drag table row, but no table block was hovered prior.", + ); + } - /** - * Get all the cells in a column of the table block. - */ - getCellsAtColumnHandle = ( - block: BlockFromConfigNoChildren, - relativeColumnIndex: RelativeCellIndices["col"], - ) => { - return getCellsAtColumnHandle(block, relativeColumnIndex); - }; + view!.state.draggingState = undefined; + view!.emitUpdate(); - /** - * Sets the selection to the given cell or a range of cells. - * @returns The new state after the selection has been set. - */ - private setCellSelection = ( - state: EditorState, - relativeStartCell: RelativeCellIndices, - relativeEndCell: RelativeCellIndices = relativeStartCell, - ) => { - const view = this.view; - - if (!view) { - throw new Error("Table handles view not initialized"); - } + editor.transact((tr) => tr.setMeta(tableHandlesPluginKey, null)); - const tableResolvedPos = state.doc.resolve(view.tablePos! + 1); - const startRowResolvedPos = state.doc.resolve( - tableResolvedPos.posAtIndex(relativeStartCell.row) + 1, - ); - const startCellResolvedPos = state.doc.resolve( - // No need for +1, since CellSelection expects the position before the cell - startRowResolvedPos.posAtIndex(relativeStartCell.col), - ); - const endRowResolvedPos = state.doc.resolve( - tableResolvedPos.posAtIndex(relativeEndCell.row) + 1, - ); - const endCellResolvedPos = state.doc.resolve( - // No need for +1, since CellSelection expects the position before the cell - endRowResolvedPos.posAtIndex(relativeEndCell.col), - ); + if (editor.headless) { + return; + } - // Begin a new transaction to set the selection - const tr = state.tr; + unsetHiddenDragImage(editor.prosemirrorView.root); + }, + + /** + * Freezes the drag handles. When frozen, they will stay attached to the same + * cell regardless of which cell is hovered by the mouse cursor. + */ + freezeHandles() { + view!.menuFrozen = true; + }, + + /** + * Unfreezes the drag handles. When frozen, they will stay attached to the + * same cell regardless of which cell is hovered by the mouse cursor. + */ + unfreezeHandles() { + view!.menuFrozen = false; + }, + + getCellsAtRowHandle( + block: BlockFromConfigNoChildren, + relativeRowIndex: RelativeCellIndices["row"], + ) { + return getCellsAtRowHandle(block, relativeRowIndex); + }, + + /** + * Get all the cells in a column of the table block. + */ + getCellsAtColumnHandle( + block: BlockFromConfigNoChildren, + relativeColumnIndex: RelativeCellIndices["col"], + ) { + return getCellsAtColumnHandle(block, relativeColumnIndex); + }, + + /** + * Sets the selection to the given cell or a range of cells. + * @returns The new state after the selection has been set. + */ + setCellSelection( + state: EditorState, + relativeStartCell: RelativeCellIndices, + relativeEndCell: RelativeCellIndices = relativeStartCell, + ) { + if (!view) { + throw new Error("Table handles view not initialized"); + } - // Set the selection to the given cell or a range of cells - tr.setSelection( - new CellSelection(startCellResolvedPos, endCellResolvedPos), - ); + const tableResolvedPos = state.doc.resolve(view.tablePos! + 1); + const startRowResolvedPos = state.doc.resolve( + tableResolvedPos.posAtIndex(relativeStartCell.row) + 1, + ); + const startCellResolvedPos = state.doc.resolve( + // No need for +1, since CellSelection expects the position before the cell + startRowResolvedPos.posAtIndex(relativeStartCell.col), + ); + const endRowResolvedPos = state.doc.resolve( + tableResolvedPos.posAtIndex(relativeEndCell.row) + 1, + ); + const endCellResolvedPos = state.doc.resolve( + // No need for +1, since CellSelection expects the position before the cell + endRowResolvedPos.posAtIndex(relativeEndCell.col), + ); - // Quickly apply the transaction to get the new state to update the selection before splitting the cell - return state.apply(tr); - }; + // Begin a new transaction to set the selection + const tr = state.tr; - /** - * Adds a row or column to the table using prosemirror-table commands - */ - addRowOrColumn = ( - index: RelativeCellIndices["row"] | RelativeCellIndices["col"], - direction: - | { orientation: "row"; side: "above" | "below" } - | { orientation: "column"; side: "left" | "right" }, - ) => { - this.editor.exec((beforeState, dispatch) => { - const state = this.setCellSelection( - beforeState, - direction.orientation === "row" - ? { row: index, col: 0 } - : { row: 0, col: index }, + // Set the selection to the given cell or a range of cells + tr.setSelection( + new CellSelection(startCellResolvedPos, endCellResolvedPos), ); - if (direction.orientation === "row") { - if (direction.side === "above") { - return addRowBefore(state, dispatch); + // Quickly apply the transaction to get the new state to update the selection before splitting the cell + return state.apply(tr); + }, + + /** + * Adds a row or column to the table using prosemirror-table commands + */ + addRowOrColumn( + index: RelativeCellIndices["row"] | RelativeCellIndices["col"], + direction: + | { orientation: "row"; side: "above" | "below" } + | { orientation: "column"; side: "left" | "right" }, + ) { + editor.exec((beforeState, dispatch) => { + const state = this.setCellSelection( + beforeState, + direction.orientation === "row" + ? { row: index, col: 0 } + : { row: 0, col: index }, + ); + + if (direction.orientation === "row") { + if (direction.side === "above") { + return addRowBefore(state, dispatch); + } else { + return addRowAfter(state, dispatch); + } } else { - return addRowAfter(state, dispatch); + if (direction.side === "left") { + return addColumnBefore(state, dispatch); + } else { + return addColumnAfter(state, dispatch); + } } + }); + }, + + /** + * Removes a row or column from the table using prosemirror-table commands + */ + removeRowOrColumn( + index: RelativeCellIndices["row"] | RelativeCellIndices["col"], + direction: "row" | "column", + ) { + if (direction === "row") { + return editor.exec((beforeState, dispatch) => { + const state = this.setCellSelection(beforeState, { + row: index, + col: 0, + }); + return deleteRow(state, dispatch); + }); } else { - if (direction.side === "left") { - return addColumnBefore(state, dispatch); - } else { - return addColumnAfter(state, dispatch); - } - } - }); - }; - - /** - * Removes a row or column from the table using prosemirror-table commands - */ - removeRowOrColumn = ( - index: RelativeCellIndices["row"] | RelativeCellIndices["col"], - direction: "row" | "column", - ) => { - if (direction === "row") { - return this.editor.exec((beforeState, dispatch) => { - const state = this.setCellSelection(beforeState, { - row: index, - col: 0, + return editor.exec((beforeState, dispatch) => { + const state = this.setCellSelection(beforeState, { + row: 0, + col: index, + }); + return deleteColumn(state, dispatch); }); - return deleteRow(state, dispatch); + } + }, + + /** + * Merges the cells in the table block. + */ + mergeCells(cellsToMerge?: { + relativeStartCell: RelativeCellIndices; + relativeEndCell: RelativeCellIndices; + }) { + return editor.exec((beforeState, dispatch) => { + const state = cellsToMerge + ? this.setCellSelection( + beforeState, + cellsToMerge.relativeStartCell, + cellsToMerge.relativeEndCell, + ) + : beforeState; + + return mergeCells(state, dispatch); }); - } else { - return this.editor.exec((beforeState, dispatch) => { - const state = this.setCellSelection(beforeState, { - row: 0, - col: index, - }); - return deleteColumn(state, dispatch); + }, + + /** + * Splits the cell in the table block. + * If no cell is provided, the current cell selected will be split. + */ + splitCell(relativeCellToSplit?: RelativeCellIndices) { + return editor.exec((beforeState, dispatch) => { + const state = relativeCellToSplit + ? this.setCellSelection(beforeState, relativeCellToSplit) + : beforeState; + + return splitCell(state, dispatch); }); - } - }; - - /** - * Merges the cells in the table block. - */ - mergeCells = (cellsToMerge?: { - relativeStartCell: RelativeCellIndices; - relativeEndCell: RelativeCellIndices; - }) => { - return this.editor.exec((beforeState, dispatch) => { - const state = cellsToMerge - ? this.setCellSelection( - beforeState, - cellsToMerge.relativeStartCell, - cellsToMerge.relativeEndCell, - ) - : beforeState; - - return mergeCells(state, dispatch); - }); - }; - - /** - * Splits the cell in the table block. - * If no cell is provided, the current cell selected will be split. - */ - splitCell = (relativeCellToSplit?: RelativeCellIndices) => { - return this.editor.exec((beforeState, dispatch) => { - const state = relativeCellToSplit - ? this.setCellSelection(beforeState, relativeCellToSplit) - : beforeState; - - return splitCell(state, dispatch); - }); - }; + }, + + /** + * Gets the start and end cells of the current cell selection. + * @returns The start and end cells of the current cell selection. + */ + getCellSelection(): + | undefined + | { + from: RelativeCellIndices; + to: RelativeCellIndices; + /** + * All of the cells that are within the selected range. + */ + cells: RelativeCellIndices[]; + } { + // Based on the current selection, find the table cells that are within the selected range + + return editor.transact((tr) => { + const selection = tr.selection; + + let $fromCell = selection.$from; + let $toCell = selection.$to; + if (isTableCellSelection(selection)) { + // When the selection is a table cell selection, we can find the + // from and to cells by iterating over the ranges in the selection + const { ranges } = selection; + ranges.forEach((range) => { + $fromCell = range.$from.min($fromCell ?? range.$from); + $toCell = range.$to.max($toCell ?? range.$to); + }); + } else { + // When the selection is a normal text selection + // Assumes we are within a tableParagraph + // And find the from and to cells by resolving the positions + $fromCell = tr.doc.resolve( + selection.$from.pos - selection.$from.parentOffset - 1, + ); + $toCell = tr.doc.resolve( + selection.$to.pos - selection.$to.parentOffset - 1, + ); + + // Opt-out when the selection is not pointing into cells + if ($fromCell.pos === 0 || $toCell.pos === 0) { + return undefined; + } + } - /** - * Gets the start and end cells of the current cell selection. - * @returns The start and end cells of the current cell selection. - */ - getCellSelection = (): - | undefined - | { - from: RelativeCellIndices; - to: RelativeCellIndices; - /** - * All of the cells that are within the selected range. - */ - cells: RelativeCellIndices[]; - } => { - // Based on the current selection, find the table cells that are within the selected range - - return this.editor.transact((tr) => { - const selection = tr.selection; - - let $fromCell = selection.$from; - let $toCell = selection.$to; - if (isTableCellSelection(selection)) { - // When the selection is a table cell selection, we can find the - // from and to cells by iterating over the ranges in the selection - const { ranges } = selection; - ranges.forEach((range) => { - $fromCell = range.$from.min($fromCell ?? range.$from); - $toCell = range.$to.max($toCell ?? range.$to); - }); - } else { - // When the selection is a normal text selection - // Assumes we are within a tableParagraph - // And find the from and to cells by resolving the positions - $fromCell = tr.doc.resolve( - selection.$from.pos - selection.$from.parentOffset - 1, - ); - $toCell = tr.doc.resolve( - selection.$to.pos - selection.$to.parentOffset - 1, + // Find the row and table that the from and to cells are in + const $fromRow = tr.doc.resolve( + $fromCell.pos - $fromCell.parentOffset - 1, ); + const $toRow = tr.doc.resolve($toCell.pos - $toCell.parentOffset - 1); + + // Find the table + const $table = tr.doc.resolve($fromRow.pos - $fromRow.parentOffset - 1); + + // Find the column and row indices of the from and to cells + const fromColIndex = $fromCell.index($fromRow.depth); + const fromRowIndex = $fromRow.index($table.depth); + const toColIndex = $toCell.index($toRow.depth); + const toRowIndex = $toRow.index($table.depth); + + const cells: RelativeCellIndices[] = []; + for (let row = fromRowIndex; row <= toRowIndex; row++) { + for (let col = fromColIndex; col <= toColIndex; col++) { + cells.push({ row, col }); + } + } - // Opt-out when the selection is not pointing into cells - if ($fromCell.pos === 0 || $toCell.pos === 0) { + return { + from: { + row: fromRowIndex, + col: fromColIndex, + }, + to: { + row: toRowIndex, + col: toColIndex, + }, + cells, + }; + }); + }, + + /** + * Gets the direction of the merge based on the current cell selection. + * + * Returns undefined when there is no cell selection, or the selection is not within a table. + */ + getMergeDirection( + block: + | BlockFromConfigNoChildren + | undefined, + ) { + return editor.transact((tr) => { + const isSelectingTableCells = isTableCellSelection(tr.selection) + ? tr.selection + : undefined; + + if ( + !isSelectingTableCells || + !block || + // Only offer the merge button if there is more than one cell selected. + isSelectingTableCells.ranges.length <= 1 + ) { return undefined; } - } - // Find the row and table that the from and to cells are in - const $fromRow = tr.doc.resolve( - $fromCell.pos - $fromCell.parentOffset - 1, - ); - const $toRow = tr.doc.resolve($toCell.pos - $toCell.parentOffset - 1); + const cellSelection = this.getCellSelection(); - // Find the table - const $table = tr.doc.resolve($fromRow.pos - $fromRow.parentOffset - 1); - - // Find the column and row indices of the from and to cells - const fromColIndex = $fromCell.index($fromRow.depth); - const fromRowIndex = $fromRow.index($table.depth); - const toColIndex = $toCell.index($toRow.depth); - const toRowIndex = $toRow.index($table.depth); - - const cells: RelativeCellIndices[] = []; - for (let row = fromRowIndex; row <= toRowIndex; row++) { - for (let col = fromColIndex; col <= toColIndex; col++) { - cells.push({ row, col }); + if (!cellSelection) { + return undefined; } - } - - return { - from: { - row: fromRowIndex, - col: fromColIndex, - }, - to: { - row: toRowIndex, - col: toColIndex, - }, - cells, - }; - }); - }; - /** - * Gets the direction of the merge based on the current cell selection. - * - * Returns undefined when there is no cell selection, or the selection is not within a table. - */ - getMergeDirection = ( - block: - | BlockFromConfigNoChildren - | undefined, - ) => { - return this.editor.transact((tr) => { - const isSelectingTableCells = isTableCellSelection(tr.selection) - ? tr.selection - : undefined; - - if ( - !isSelectingTableCells || - !block || - // Only offer the merge button if there is more than one cell selected. - isSelectingTableCells.ranges.length <= 1 - ) { - return undefined; - } - - const cellSelection = this.getCellSelection(); - - if (!cellSelection) { - return undefined; - } - - if (areInSameColumn(cellSelection.from, cellSelection.to, block)) { - return "vertical"; - } + if (areInSameColumn(cellSelection.from, cellSelection.to, block)) { + return "vertical"; + } - return "horizontal"; - }); - }; + return "horizontal"; + }); + }, - cropEmptyRowsOrColumns = ( - block: BlockFromConfigNoChildren, - removeEmpty: "columns" | "rows", - ) => { - return cropEmptyRowsOrColumns(block, removeEmpty); - }; + cropEmptyRowsOrColumns( + block: BlockFromConfigNoChildren, + removeEmpty: "columns" | "rows", + ) { + return cropEmptyRowsOrColumns(block, removeEmpty); + }, - addRowsOrColumns = ( - block: BlockFromConfigNoChildren, - addType: "columns" | "rows", - numToAdd: number, - ) => { - return addRowsOrColumns(block, addType, numToAdd); - }; -} + addRowsOrColumns( + block: BlockFromConfigNoChildren, + addType: "columns" | "rows", + numToAdd: number, + ) { + return addRowsOrColumns(block, addType, numToAdd); + }, + } as const; +}); diff --git a/packages/core/src/extensions/TrailingNode/TrailingNodeExtension.ts b/packages/core/src/extensions/TrailingNode/TrailingNodeExtension.ts index ea70567d68..0de4ae421b 100644 --- a/packages/core/src/extensions/TrailingNode/TrailingNodeExtension.ts +++ b/packages/core/src/extensions/TrailingNode/TrailingNodeExtension.ts @@ -1,5 +1,5 @@ -import { Extension } from "@tiptap/core"; import { Plugin, PluginKey } from "prosemirror-state"; +import { createExtension } from "../../editor/BlockNoteExtension.js"; // based on https://github.com/ueberdosis/tiptap/blob/40a9404c94c7fef7900610c195536384781ae101/demos/src/Experiments/TrailingNode/Vue/trailing-node.ts @@ -8,24 +8,18 @@ import { Plugin, PluginKey } from "prosemirror-state"; * - https://github.com/ueberdosis/tiptap/blob/v1/packages/tiptap-extensions/src/extensions/TrailingNode.js * - https://github.com/remirror/remirror/blob/e0f1bec4a1e8073ce8f5500d62193e52321155b9/packages/prosemirror-trailing-node/src/trailing-node-plugin.ts */ - -export interface TrailingNodeOptions { - node: string; -} +const plugin = new PluginKey("trailingNode"); /** * Add a trailing node to the document so the user can always click at the bottom of the document and start typing */ -export const TrailingNode = Extension.create({ - name: "trailingNode", - - addProseMirrorPlugins() { - const plugin = new PluginKey(this.name); - // const disabledNodes = Object.entries(this.editor.schema.nodes) - // .map(([, value]) => value) - // .filter((node) => this.options.notAfter.includes(node.name)); - - return [ +export const TrailingNode = createExtension((_editor, options) => { + if (options.trailingBlock === false) { + return; + } + return { + key: "trailingNode", + plugins: [ new Plugin({ key: plugin, appendTransaction: (_, __, state) => { @@ -80,6 +74,6 @@ export const TrailingNode = Extension.create({ }, }, }), - ]; - }, + ], + } as const; }); diff --git a/packages/core/src/extensions/UI_PLUGINS.md b/packages/core/src/extensions/UI_PLUGINS.md new file mode 100644 index 0000000000..db68389bdc --- /dev/null +++ b/packages/core/src/extensions/UI_PLUGINS.md @@ -0,0 +1,36 @@ +# UI Plugins to refactor + +These are the plugins that are the main target for refactoring. The goal is to reduce complexity by only storing the minimal state needed to render the UI element, & everything else to be derived from that state or moved into the UI layer (React). The main reason for needing to store any state at all is to allow different parts of the UI to be updated independently, like one menu being able to open another menu. + +- FilePanel + - `blockId`: the id of the block that the file panel is associated with +- FormattingToolbar + - `show`: whether the formatting toolbar is shown + - `.getReferencePos()`: based on the bounding box of the selection +- LinkToolbar + - State-driven only React now +- SideMenu + - `show`: whether the side menu is shown +- TableHandles + - decorations + - `draggingState`: the state of the dragging operation +- SuggestionMenu + - decorations + - `query`: the current query string for the suggestion menu + +## Plan + +- Move most plugin state from plugin views into react + - If that is not possible, move into an extension which has a tanstack store +- Migrate things to use `useEditorState` which is a hook with a better pattern for selecting the correct parts of the editor state that we are interested in +- Move plugins managing menus into floating UI hooks & React + - If it is a UI state, it should be in React + - Examples: menu position, menu open/close, etc. + - If it is an editor state, or accessible across plugins, it should be in an extension + - Examples: active blocks, exposing methods, etc. + + diff --git a/packages/core/src/extensions/index.ts b/packages/core/src/extensions/index.ts new file mode 100644 index 0000000000..a50d79aadc --- /dev/null +++ b/packages/core/src/extensions/index.ts @@ -0,0 +1,43 @@ +import { BlockChangePlugin } from "./BlockChange/BlockChangePlugin.js"; +import { CommentsPlugin } from "./Comments/CommentsPlugin.js"; +import { CursorPlugin } from "./Collaboration/CursorPlugin.js"; +import { DropCursor } from "./DropCursor/DropCursor.js"; +import { FilePanelPlugin } from "./FilePanel/FilePanelPlugin.js"; +import { ForkYDocPlugin } from "./Collaboration/ForkYDocPlugin.js"; +import { FormattingToolbarExtension } from "./FormattingToolbar/FormattingToolbarPlugin.js"; +import { HistoryExtension } from "./History/HistoryExtension.js"; +import { LinkToolbarPlugin } from "./LinkToolbar/LinkToolbar.js"; +import { NodeSelectionKeyboardPlugin } from "./NodeSelectionKeyboard/NodeSelectionKeyboardPlugin.js"; +import { PlaceholderPlugin } from "./Placeholder/PlaceholderPlugin.js"; +import { PreviousBlockTypePlugin } from "./PreviousBlockType/PreviousBlockTypePlugin.js"; +import { SchemaMigrationPlugin } from "./Collaboration/schemaMigration/SchemaMigrationPlugin.js"; +import { ShowSelectionPlugin } from "./ShowSelection/ShowSelectionPlugin.js"; +import { SideMenuProsemirrorPlugin } from "./SideMenu/SideMenuPlugin.js"; +import { SuggestionMenuPlugin } from "./SuggestionMenu/SuggestionPlugin.js"; +import { SyncPlugin } from "./Collaboration/SyncPlugin.js"; +import { TableHandlesPlugin } from "./TableHandles/TableHandlesPlugin.js"; +import { TrailingNode } from "./TrailingNode/TrailingNodeExtension.js"; +import { UndoPlugin } from "./Collaboration/UndoPlugin.js"; + +export const DEFAULT_EXTENSIONS = [ + BlockChangePlugin, + CommentsPlugin, + CursorPlugin, + DropCursor, + FilePanelPlugin, + ForkYDocPlugin, + FormattingToolbarExtension, + HistoryExtension, + LinkToolbarPlugin, + NodeSelectionKeyboardPlugin, + PlaceholderPlugin, + PreviousBlockTypePlugin, + SchemaMigrationPlugin, + ShowSelectionPlugin, + SideMenuProsemirrorPlugin, + SuggestionMenuPlugin, + SyncPlugin, + TableHandlesPlugin, + TrailingNode, + UndoPlugin, +] as const; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 11fe0e5460..1c795a8da7 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -10,14 +10,14 @@ export * from "./api/pmUtil.js"; export * from "./blocks/index.js"; export * from "./editor/BlockNoteEditor.js"; export * from "./editor/BlockNoteExtension.js"; -export * from "./editor/BlockNoteExtensions.js"; export * from "./editor/defaultColors.js"; export * from "./editor/selectionTypes.js"; export * from "./exporter/index.js"; export * from "./extensions-shared/UiElementPosition.js"; +export * from "./extensions/Comments/CommentsPlugin.js"; export * from "./extensions/FilePanel/FilePanelPlugin.js"; export * from "./extensions/FormattingToolbar/FormattingToolbarPlugin.js"; -export * from "./extensions/LinkToolbar/LinkToolbarPlugin.js"; +export * from "./extensions/LinkToolbar/LinkToolbar.js"; export * from "./extensions/LinkToolbar/protocols.js"; export * from "./extensions/SideMenu/SideMenuPlugin.js"; export * from "./extensions/SuggestionMenu/DefaultGridSuggestionItem.js"; diff --git a/packages/core/src/schema/blocks/createSpec.ts b/packages/core/src/schema/blocks/createSpec.ts index 936584264f..45382528f6 100644 --- a/packages/core/src/schema/blocks/createSpec.ts +++ b/packages/core/src/schema/blocks/createSpec.ts @@ -2,7 +2,10 @@ import { Editor, Node } from "@tiptap/core"; import { DOMParser, Fragment, TagParseRule } from "@tiptap/pm/model"; import { NodeView } from "@tiptap/pm/view"; import { mergeParagraphs } from "../../blocks/defaultBlockHelpers.js"; -import { BlockNoteExtension } from "../../editor/BlockNoteExtension.js"; +import { + Extension, + ExtensionFactory, +} from "../../editor/BlockNoteExtension.js"; import { PropSchema } from "../propTypes.js"; import { getBlockFromPos, @@ -131,7 +134,7 @@ export function addNodeAndExtensionsToSpec< >( blockConfig: BlockConfig, blockImplementation: BlockImplementation, - extensions?: BlockNoteExtension[], + extensions?: (ExtensionFactory | Extension)[], priority?: number, ): LooseBlockSpec { const node = @@ -296,10 +299,10 @@ export function createBlockSpec< options: Partial, ) => BlockImplementation), extensionsOrCreator?: - | BlockNoteExtension[] + | ExtensionFactory[] | (TOptions extends undefined - ? () => BlockNoteExtension[] - : (options: Partial) => BlockNoteExtension[]), + ? () => ExtensionFactory[] + : (options: Partial) => ExtensionFactory[]), ): (options?: Partial) => BlockSpec; export function createBlockSpec< const TName extends string, @@ -329,10 +332,10 @@ export function createBlockSpec< BlockConf["content"] >), extensionsOrCreator?: - | BlockNoteExtension[] + | ExtensionFactory[] | (TOptions extends undefined - ? () => BlockNoteExtension[] - : (options: Partial) => BlockNoteExtension[]), + ? () => ExtensionFactory[] + : (options: Partial) => ExtensionFactory[]), ): ( options?: Partial, ) => BlockSpec< @@ -359,10 +362,10 @@ export function createBlockSpec< options: Partial, ) => BlockImplementation), extensionsOrCreator?: - | BlockNoteExtension[] + | ExtensionFactory[] | (TOptions extends undefined - ? () => BlockNoteExtension[] - : (options: Partial) => BlockNoteExtension[]), + ? () => ExtensionFactory[] + : (options: Partial) => ExtensionFactory[]), ): (options?: Partial) => BlockSpec { return (options = {} as TOptions) => { const blockConfig = diff --git a/packages/core/src/schema/blocks/internal.ts b/packages/core/src/schema/blocks/internal.ts index 3cfb268d6f..9fda423cd7 100644 --- a/packages/core/src/schema/blocks/internal.ts +++ b/packages/core/src/schema/blocks/internal.ts @@ -1,7 +1,7 @@ import { Attribute, Attributes, Editor, Node } from "@tiptap/core"; import { defaultBlockToHTML } from "../../blocks/defaultBlockHelpers.js"; import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; -import { BlockNoteExtension } from "../../editor/BlockNoteExtension.js"; +import type { ExtensionFactory } from "../../editor/BlockNoteExtension.js"; import { mergeCSSClasses } from "../../util/browser.js"; import { camelToDataKebab } from "../../util/string.js"; import { InlineContentSchema } from "../inlineContent/types.js"; @@ -203,7 +203,7 @@ export function createBlockSpecFromTiptapNode< >( config: T, propSchema: P, - extensions?: BlockNoteExtension[], + extensions?: ExtensionFactory[], ): LooseBlockSpec { return { config: { diff --git a/packages/core/src/schema/blocks/types.ts b/packages/core/src/schema/blocks/types.ts index 87ec01ab1b..96e2a5984a 100644 --- a/packages/core/src/schema/blocks/types.ts +++ b/packages/core/src/schema/blocks/types.ts @@ -4,7 +4,10 @@ import type { Node, NodeViewRendererProps } from "@tiptap/core"; import type { Fragment, Schema } from "prosemirror-model"; import type { ViewMutationRecord } from "prosemirror-view"; import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; -import type { BlockNoteExtension } from "../../editor/BlockNoteExtension.js"; +import type { + Extension, + ExtensionFactory, +} from "../../editor/BlockNoteExtension.js"; import type { InlineContent, InlineContentSchema, @@ -98,7 +101,7 @@ export type BlockSpec< > = { config: BlockConfig; implementation: BlockImplementation; - extensions?: BlockNoteExtension[]; + extensions?: (Extension | ExtensionFactory)[]; }; /** @@ -145,7 +148,7 @@ export type LooseBlockSpec< node: Node; }; - extensions?: BlockNoteExtension[]; + extensions?: (Extension | ExtensionFactory)[]; }; // Utility type. For a given object block schema, ensures that the key of each @@ -197,7 +200,7 @@ export type BlockSpecs = { } | undefined; }; - extensions?: BlockNoteExtension[]; + extensions?: BlockSpec["extensions"]; }; }; @@ -218,7 +221,7 @@ export type BlockSpecsFromSchema = { BS[K]["propSchema"], BS[K]["content"] >; - extensions?: BlockNoteExtension[]; + extensions?: (Extension | ExtensionFactory)[]; }; }; diff --git a/packages/core/src/schema/schema.ts b/packages/core/src/schema/schema.ts index 92d0a7ab88..a7a04e93dc 100644 --- a/packages/core/src/schema/schema.ts +++ b/packages/core/src/schema/schema.ts @@ -1,5 +1,5 @@ import { BlockNoteEditor } from "../editor/BlockNoteEditor.js"; -import { createDependencyGraph, toposortReverse } from "../util/topo-sort.js"; +import { sortByDependencies } from "../util/topo-sort.js"; import { BlockNoDefaults, BlockSchema, @@ -80,41 +80,16 @@ export class CustomBlockNoteSchema< } private init() { - const dag = createDependencyGraph(); - const defaultSet = new Set(); - dag.set("default", defaultSet); - - for (const [key, specDef] of Object.entries({ - ...this.opts.blockSpecs, - ...this.opts.inlineContentSpecs, - ...this.opts.styleSpecs, - })) { - if (specDef.implementation?.runsBefore) { - dag.set(key, new Set(specDef.implementation.runsBefore)); - } else { - defaultSet.add(key); - } - } - const sortedSpecs = toposortReverse(dag); - const defaultIndex = sortedSpecs.findIndex((set) => set.has("default")); - - /** - * The priority of a block is described relative to the "default" block (an arbitrary block which can be used as the reference) - * - * Since blocks are topologically sorted, we can see what their relative position is to the "default" block - * Each layer away from the default block is 10 priority points (arbitrarily chosen) - * The default block is fixed at 101 (1 point higher than any tiptap extension, giving priority to custom blocks than any defaults) - * - * This is a bit of a hack, but it's a simple way to ensure that custom blocks are always rendered with higher priority than default blocks - * and that custom blocks are rendered in the order they are defined in the schema - */ - const getPriority = (key: string) => { - const index = sortedSpecs.findIndex((set) => set.has(key)); - // the default index should map to 101 - // one before the default index is 91 - // one after is 111 - return 91 + (index + defaultIndex) * 10; - }; + const getPriority = sortByDependencies( + Object.entries({ + ...this.opts.blockSpecs, + ...this.opts.inlineContentSpecs, + ...this.opts.styleSpecs, + }).map(([key, val]) => ({ + key: key, + runsBefore: val.implementation?.runsBefore ?? [], + })), + ); const blockSpecs = Object.fromEntries( Object.entries(this.opts.blockSpecs).map(([key, blockSpec]) => { diff --git a/packages/core/src/util/topo-sort.ts b/packages/core/src/util/topo-sort.ts index fb09fc2075..ea0f7fa891 100644 --- a/packages/core/src/util/topo-sort.ts +++ b/packages/core/src/util/topo-sort.ts @@ -158,3 +158,49 @@ export function hasDependency( const dependents = graph.get(from); return dependents ? dependents.has(to) : false; } + +/** + * Sorts a list of items by their dependencies + * @returns A function which can retrieve the priority of an item + */ +export function sortByDependencies( + items: { key: string; runsBefore?: ReadonlyArray }[], +) { + const dag = createDependencyGraph(); + + for (const item of items) { + if (Array.isArray(item.runsBefore) && item.runsBefore.length > 0) { + item.runsBefore.forEach((runBefore) => { + addDependency(dag, item.key, runBefore); + }); + } else { + addDependency(dag, "default", item.key); + } + } + const sortedSpecs = toposortReverse(dag); + const defaultIndex = sortedSpecs.findIndex((set) => set.has("default")); + + /** + * The priority of an item is described relative to the "default" (an arbitrary string which can be used as the reference) + * + * Since items are topologically sorted, we can see what their relative position is to the "default" + * Each layer away from the default is 10 priority points (arbitrarily chosen) + * The default is fixed at 101 (1 point higher than any tiptap extension, giving priority to custom blocks than any defaults) + * + * This is a bit of a hack, but it's a simple way to ensure that custom items are always rendered with higher priority than default items + * and that custom items are rendered in the order they are defined in the list + */ + + /** + * Retrieves the priority of an item based on its position in the topologically sorted list + * @param key - The key of the item to get the priority of + * @returns The priority of the item + */ + return (key: string) => { + const index = sortedSpecs.findIndex((set) => set.has(key)); + // the default index should map to 101 + // one before the default index is 91 + // one after is 111 + return 91 + (index + defaultIndex) * 10; + }; +} diff --git a/packages/react/package.json b/packages/react/package.json index eac8cc3dc7..8c94850628 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -61,12 +61,17 @@ "@blocknote/core": "0.41.1", "@emoji-mart/data": "^1.2.1", "@floating-ui/react": "^0.27.16", + "@floating-ui/utils": "0.2.10", + "@tanstack/react-store": "0.7.7", "@tiptap/core": "^3.7.2", "@tiptap/pm": "^3.7.2", "@tiptap/react": "^3.7.2", + "@types/use-sync-external-store": "1.5.0", "emoji-mart": "^5.6.0", + "fast-deep-equal": "^3.1.3", "lodash.merge": "^4.6.2", - "react-icons": "^5.5.0" + "react-icons": "^5.5.0", + "use-sync-external-store": "1.6.0" }, "devDependencies": { "@types/emoji-mart": "^3.0.14", diff --git a/packages/react/src/blocks/ToggleWrapper/ToggleWrapper.tsx b/packages/react/src/blocks/ToggleWrapper/ToggleWrapper.tsx index 82f29d638f..f688b37922 100644 --- a/packages/react/src/blocks/ToggleWrapper/ToggleWrapper.tsx +++ b/packages/react/src/blocks/ToggleWrapper/ToggleWrapper.tsx @@ -1,12 +1,13 @@ import { Block, + blockHasType, defaultToggledState, UnreachableCaseError, } from "@blocknote/core"; -import { ReactNode, useReducer, useState } from "react"; +import { ReactNode, useReducer } from "react"; import { ReactCustomBlockRenderProps } from "../../schema/ReactBlockSpec.js"; -import { useEditorChange } from "../../hooks/useEditorChange.js"; +import { useEditorState } from "../../hooks/useEditorState.js"; const showChildrenReducer = ( showChildren: boolean, @@ -51,7 +52,6 @@ export const ToggleWrapper = ( showChildrenReducer, (toggledState || defaultToggledState).get(block), ); - const [childCount, setChildCount] = useState(block.children.length); const handleToggle = (block: Block) => { (toggledState || defaultToggledState).set( @@ -77,28 +77,34 @@ export const ToggleWrapper = ( }); }; - useEditorChange(() => { - if ("isToggleable" in block.props && !block.props.isToggleable) { - return; - } - - const newBlock = editor.getBlock(block)!; - const newChildCount = newBlock.children.length ?? 0; - - if (newChildCount > childCount) { - // If a child block is added while children are hidden, show children. - if (!showChildren) { - handleChildAdded(newBlock); + const childCount = useEditorState({ + editor, + selector: ({ editor }) => { + if ( + !blockHasType(block, editor, block.type, { isToggleable: "boolean" }) && + !block.props.isToggleable + ) { + return 0; } - } else if (newChildCount === 0 && newChildCount < childCount) { - // If the last child block is removed while children are shown, hide - // children. - if (showChildren) { - handleLastChildRemoved(newBlock); + + const newBlock = editor.getBlock(block)!; + const newChildCount = newBlock.children.length || 0; + + if (newChildCount > childCount) { + // If a child block is added while children are hidden, show children. + if (!showChildren) { + handleChildAdded(newBlock); + } + } else if (newChildCount === 0 && newChildCount < childCount) { + // If the last child block is removed while children are shown, hide + // children. + if (showChildren) { + handleLastChildRemoved(newBlock); + } } - } - setChildCount(newChildCount); + return newChildCount; + }, }); if ("isToggleable" in block.props && !block.props.isToggleable) { diff --git a/packages/react/src/components/Comments/CommentEditor.tsx b/packages/react/src/components/Comments/CommentEditor.tsx index 4b22eb6c5c..6030cb528e 100644 --- a/packages/react/src/components/Comments/CommentEditor.tsx +++ b/packages/react/src/components/Comments/CommentEditor.tsx @@ -1,7 +1,7 @@ import { BlockNoteEditor } from "@blocknote/core"; import { FC, useCallback, useEffect, useState } from "react"; import { useComponentsContext } from "../../editor/ComponentsContext.js"; -import { useEditorChange } from "../../hooks/useEditorChange.js"; +import { useEditorState } from "../../hooks/useEditorState.js"; /** * The CommentEditor component displays an editor for creating or editing a comment. @@ -23,14 +23,13 @@ export const CommentEditor = (props: { editor: BlockNoteEditor; }) => { const [isFocused, setIsFocused] = useState(false); - const [isEmpty, setIsEmpty] = useState(props.editor.isEmpty); + const isEmpty = useEditorState({ + editor: props.editor, + selector: ({ editor }) => editor.isEmpty, + }); const components = useComponentsContext()!; - useEditorChange(() => { - setIsEmpty(props.editor.isEmpty); - }, props.editor); - const onFocus = useCallback(() => { setIsFocused(true); }, []); diff --git a/packages/react/src/components/FilePanel/DefaultTabs/EmbedTab.tsx b/packages/react/src/components/FilePanel/DefaultTabs/EmbedTab.tsx index db07823b75..3d8090bc20 100644 --- a/packages/react/src/components/FilePanel/DefaultTabs/EmbedTab.tsx +++ b/packages/react/src/components/FilePanel/DefaultTabs/EmbedTab.tsx @@ -19,15 +19,15 @@ export const EmbedTab = < I extends InlineContentSchema = DefaultInlineContentSchema, S extends StyleSchema = DefaultStyleSchema, >( - props: FilePanelProps, + props: FilePanelProps, ) => { const Components = useComponentsContext()!; const dict = useDictionary(); - const { block } = props; - const editor = useBlockNoteEditor(); + const block = editor.getBlock(props.blockId)!; + const [currentURL, setCurrentURL] = useState(""); const handleURLChange = useCallback( @@ -41,7 +41,7 @@ export const EmbedTab = < (event: KeyboardEvent) => { if (event.key === "Enter") { event.preventDefault(); - editor.updateBlock(block, { + editor.updateBlock(block.id, { props: { name: filenameFromURL(currentURL), url: currentURL, @@ -49,17 +49,17 @@ export const EmbedTab = < }); } }, - [editor, block, currentURL], + [editor, block.id, currentURL], ); const handleURLClick = useCallback(() => { - editor.updateBlock(block, { + editor.updateBlock(block.id, { props: { name: filenameFromURL(currentURL), url: currentURL, } as any, }); - }, [editor, block, currentURL]); + }, [editor, block.id, currentURL]); return ( diff --git a/packages/react/src/components/FilePanel/DefaultTabs/UploadTab.tsx b/packages/react/src/components/FilePanel/DefaultTabs/UploadTab.tsx index 23847eb572..64d5a1c74f 100644 --- a/packages/react/src/components/FilePanel/DefaultTabs/UploadTab.tsx +++ b/packages/react/src/components/FilePanel/DefaultTabs/UploadTab.tsx @@ -18,17 +18,19 @@ export const UploadTab = < I extends InlineContentSchema = DefaultInlineContentSchema, S extends StyleSchema = DefaultStyleSchema, >( - props: FilePanelProps & { + props: FilePanelProps & { setLoading: (loading: boolean) => void; }, ) => { const Components = useComponentsContext()!; const dict = useDictionary(); - const { block, setLoading } = props; + const { setLoading } = props; const editor = useBlockNoteEditor(); + const block = editor.getBlock(props.blockId)!; + const [uploadFailed, setUploadFailed] = useState(false); useEffect(() => { @@ -50,7 +52,7 @@ export const UploadTab = < if (editor.uploadFile !== undefined) { try { - let updateData = await editor.uploadFile(file, block.id); + let updateData = await editor.uploadFile(file, props.blockId); if (typeof updateData === "string") { // received a url updateData = { @@ -60,7 +62,7 @@ export const UploadTab = < }, }; } - editor.updateBlock(block, updateData); + editor.updateBlock(props.blockId, updateData); } catch (e) { setUploadFailed(true); } finally { @@ -71,7 +73,7 @@ export const UploadTab = < upload(file); }, - [block, editor, setLoading], + [props.blockId, editor, setLoading], ); const spec = editor.schema.blockSpecs[block.type]; diff --git a/packages/react/src/components/FilePanel/FilePanel.tsx b/packages/react/src/components/FilePanel/FilePanel.tsx index 270e749dcd..9365571025 100644 --- a/packages/react/src/components/FilePanel/FilePanel.tsx +++ b/packages/react/src/components/FilePanel/FilePanel.tsx @@ -31,8 +31,7 @@ export const FilePanel = < I extends InlineContentSchema = DefaultInlineContentSchema, S extends StyleSchema = DefaultStyleSchema, >( - props: FilePanelProps & - Partial>, + props: FilePanelProps & Partial>, ) => { const Components = useComponentsContext()!; const dict = useDictionary(); @@ -46,13 +45,15 @@ export const FilePanel = < ? [ { name: dict.file_panel.upload.title, - tabPanel: , + tabPanel: ( + + ), }, ] : []), { name: dict.file_panel.embed.title, - tabPanel: , + tabPanel: , }, ]; diff --git a/packages/react/src/components/FilePanel/FilePanelController.tsx b/packages/react/src/components/FilePanel/FilePanelController.tsx index 7fb98b2bab..182288f340 100644 --- a/packages/react/src/components/FilePanel/FilePanelController.tsx +++ b/packages/react/src/components/FilePanel/FilePanelController.tsx @@ -1,68 +1,42 @@ -import { - BlockSchema, - DefaultBlockSchema, - DefaultInlineContentSchema, - DefaultStyleSchema, - InlineContentSchema, - StyleSchema, -} from "@blocknote/core"; +import { FilePanelPlugin } from "@blocknote/core"; import { UseFloatingOptions, flip, offset } from "@floating-ui/react"; -import { FC } from "react"; +import { FC, useCallback, useMemo } from "react"; -import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; -import { useUIElementPositioning } from "../../hooks/useUIElementPositioning.js"; -import { useUIPluginState } from "../../hooks/useUIPluginState.js"; import { FilePanel } from "./FilePanel.js"; import { FilePanelProps } from "./FilePanelProps.js"; +import { BlockPopover } from "../Popovers/BlockPopover.js"; +import { usePlugin, usePluginState } from "../../hooks/usePlugin.js"; -export const FilePanelController = < - B extends BlockSchema = DefaultBlockSchema, - I extends InlineContentSchema = DefaultInlineContentSchema, - S extends StyleSchema = DefaultStyleSchema, ->(props: { - filePanel?: FC>; - floatingOptions?: Partial; +export const FilePanelController = (props: { + filePanel?: FC; + floatingUIOptions?: UseFloatingOptions; }) => { - const editor = useBlockNoteEditor(); - - if (!editor.filePanel) { - throw new Error( - "FileToolbarController can only be used when BlockNote editor schema contains file block", - ); - } + const filePanel = usePlugin(FilePanelPlugin); + const state = usePluginState(FilePanelPlugin, { + selector: (state) => { + return { + blockId: state.blockId || filePanel.store.prevState.blockId, + show: state.blockId !== undefined, + }; + }, + }); - const state = useUIPluginState( - editor.filePanel.onUpdate.bind(editor.filePanel), - ); + const getBlockId = useCallback(() => state.blockId, [state.blockId]); - const { isMounted, ref, style, getFloatingProps } = useUIElementPositioning( - state?.show || false, - state?.referencePos || null, - 5000, - { - placement: "bottom", + const floatingUIOptions = useMemo( + () => ({ + open: state.show, middleware: [offset(10), flip()], - onOpenChange: (open) => { - if (!open) { - editor.filePanel!.closeMenu(); - editor.focus(); - } - }, - ...props.floatingOptions, - }, + ...props.floatingUIOptions, + }), + [props.floatingUIOptions, state.show], ); - if (!isMounted || !state) { - return null; - } - - const { show, referencePos, ...data } = state; - const Component = props.filePanel || FilePanel; return ( -
- -
+ + {state.blockId && } + ); }; diff --git a/packages/react/src/components/FilePanel/FilePanelProps.ts b/packages/react/src/components/FilePanel/FilePanelProps.ts index ac1420e011..b953fb75f1 100644 --- a/packages/react/src/components/FilePanel/FilePanelProps.ts +++ b/packages/react/src/components/FilePanel/FilePanelProps.ts @@ -1,13 +1,3 @@ -import { - DefaultInlineContentSchema, - DefaultStyleSchema, - FilePanelState, - InlineContentSchema, - StyleSchema, - UiElementPosition, -} from "@blocknote/core"; - -export type FilePanelProps< - I extends InlineContentSchema = DefaultInlineContentSchema, - S extends StyleSchema = DefaultStyleSchema, -> = Omit, keyof UiElementPosition>; +export type FilePanelProps = { + blockId: string; +}; diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/BasicTextStyleButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/BasicTextStyleButton.tsx index 8311c79ce0..f2ae1e9ac6 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/BasicTextStyleButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/BasicTextStyleButton.tsx @@ -5,7 +5,7 @@ import { StyleSchema, formatKeyboardShortcut, } from "@blocknote/core"; -import { useMemo, useState } from "react"; +import { useCallback } from "react"; import { IconType } from "react-icons"; import { RiBold, @@ -17,8 +17,7 @@ import { import { useComponentsContext } from "../../../editor/ComponentsContext.js"; import { useBlockNoteEditor } from "../../../hooks/useBlockNoteEditor.js"; -import { useEditorContentOrSelectionChange } from "../../../hooks/useEditorContentOrSelectionChange.js"; -import { useSelectedBlocks } from "../../../hooks/useSelectedBlocks.js"; +import { useEditorState } from "../../../hooks/useEditorState.js"; import { useDictionary } from "../../../i18n/dictionary.js"; type BasicTextStyle = "bold" | "italic" | "underline" | "strike" | "code"; @@ -63,45 +62,40 @@ export const BasicTextStyleButton =