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 6150537b4c..4eea7da553 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -81,6 +81,7 @@ "dependencies": { "@emoji-mart/data": "^1.2.1", "@shikijs/types": "3.13.0", + "@tanstack/store": "0.7.7", "@tiptap/core": "^3.4.3", "@tiptap/extension-bold": "^3", "@tiptap/extension-code": "^3", diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index 7745cee1d9..2a6b7c67ca 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -74,6 +74,7 @@ import { updateBlockTr } from "../api/blockManipulation/commands/updateBlock/upd import { getBlockInfoFromTransaction } from "../api/getBlockInfoFromPos.js"; import { blockToNode } from "../api/nodeConversions/blockToNode.js"; import "../style.css"; +import { ExtensionFactory } from "./managers/extensions/types.js"; /** * A factory function that returns a BlockNoteExtension @@ -1026,15 +1027,52 @@ export class BlockNoteEditor< ext: { new (...args: any[]): T } & typeof BlockNoteExtension, key = ext.key(), ): T { - return this._extensionManager.extension(ext, key); + return this._extensionManager.getExtension(key) as any; } + /** + * Add an extension to the editor + * @param extension The extension to add + * @returns The extension instance + */ + public addExtension( + extension: ReturnType | ExtensionFactory, + ) { + return this._extensionManager.addExtension(extension); + } + + public getExtension< + T extends ExtensionFactory | ReturnType | string, + >( + extension: T, + ): + | (T extends ExtensionFactory + ? ReturnType + : T extends ReturnType + ? T + : T extends string + ? ReturnType + : never) + | undefined { + return this._extensionManager.getExtension(extension); + } /** * Mount the editor to a DOM element. * * @warning Not needed to call manually when using React, use BlockNoteView to take care of mounting */ public mount = (element: HTMLElement) => { + const extensions = this._extensionManager.getExtensions().values(); + // TODO can do something similar for input rules + // extensions.filter(e => e.instance.inputRules) + + const state = this._tiptapEditor.state.reconfigure({ + plugins: this._tiptapEditor.state.plugins.concat( + extensions.flatMap((e) => e.instance.plugins ?? []).toArray(), + ), + }); + this._tiptapEditor.view.updateState(state); + // TODO: Fix typing for this in a TipTap PR this._tiptapEditor.mount({ mount: element } as any); }; @@ -1573,6 +1611,17 @@ export class BlockNoteEditor< ); } + 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. * diff --git a/packages/core/src/editor/managers/ExtensionManager.ts b/packages/core/src/editor/managers/ExtensionManager.ts index 4d35b68c17..447211d276 100644 --- a/packages/core/src/editor/managers/ExtensionManager.ts +++ b/packages/core/src/editor/managers/ExtensionManager.ts @@ -7,41 +7,173 @@ import { SuggestionMenuProseMirrorPlugin } from "../../extensions/SuggestionMenu import { TableHandlesProsemirrorPlugin } from "../../extensions/TableHandles/TableHandlesPlugin.js"; import { BlockNoteExtension } from "../BlockNoteExtension.js"; import { BlockNoteEditor } from "../BlockNoteEditor.js"; +import { Extension, ExtensionFactory } from "./extensions/types.js"; export class ExtensionManager { - constructor(private editor: BlockNoteEditor) {} + private extensions: Map< + string, + { + instance: Extension; + unmount: () => void; + abortController: AbortController; + } + > = new Map(); + private extensionFactories: WeakMap = + new WeakMap(); + constructor(private editor: BlockNoteEditor) { + editor.onMount(() => { + for (const extension of this.extensions.values()) { + if (extension.instance.init) { + const unmountCallback = extension.instance.init({ + dom: editor.prosemirrorView.dom, + root: editor.prosemirrorView.root, + abortController: extension.abortController, + }); + extension.unmount = () => { + unmountCallback?.(); + extension.abortController.abort(); + }; + } + } + }); + + editor.onUnmount(() => { + for (const extension of this.extensions.values()) { + if (extension.unmount) { + extension.unmount(); + } + } + }); + } /** - * 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 + * Get all extensions */ - 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`); + public getExtensions() { + return this.extensions; + } + + /** + * Add an extension to the editor after initialization + */ + public addExtension( + extension: T, + ): T extends ExtensionFactory ? ReturnType : T { + if ( + typeof extension === "function" && + this.extensionFactories.has(extension) + ) { + return this.extensionFactories.get(extension) as any; } - return extension; + + if ( + typeof extension === "object" && + "key" in extension && + this.extensions.has(extension.key) + ) { + return this.extensions.get(extension.key) as any; + } + + const abortController = new AbortController(); + let instance: Extension; + if (typeof extension === "function") { + instance = extension(this.editor); + this.extensionFactories.set(extension, instance); + } else { + instance = extension; + } + + let unmountCallback: undefined | (() => void) = undefined; + + this.extensions.set(instance.key, { + instance, + unmount: () => { + unmountCallback?.(); + abortController.abort(); + }, + abortController, + }); + + for (const plugin of instance.plugins || []) { + this.editor._tiptapEditor.registerPlugin(plugin); + } + + if ("inputRules" in instance) { + // TODO do we need to add new input rules to the editor? + // And other things? + } + + if (!this.editor.headless && instance.init) { + unmountCallback = + instance.init({ + dom: this.editor.prosemirrorView.dom, + root: this.editor.prosemirrorView.root, + abortController, + }) || undefined; + } + + return instance as any; } /** - * Get all extensions + * Remove an extension from the editor + * @param extension - The extension to remove + * @returns The extension that was removed */ - public getExtensions() { - return this.editor.extensions; + public removeExtension( + extension: T, + ): undefined { + let extensionKey: string | undefined; + if (typeof extension === "string") { + extensionKey = extension; + } else if (typeof extension === "function") { + extensionKey = this.extensionFactories.get(extension)?.key; + } else { + extensionKey = extension.key; + } + if (!extensionKey) { + return undefined; + } + const extensionToDelete = this.extensions.get(extensionKey); + if (extensionToDelete) { + if (extensionToDelete.unmount) { + extensionToDelete.unmount(); + } + this.extensions.delete(extensionKey); + } } /** - * Get a specific extension by key + * Get a specific extension by it's instance */ - public getExtension(key: string) { - return this.editor.extensions[key]; + public getExtension( + extension: T, + ): + | (T extends ExtensionFactory + ? ReturnType + : T extends Extension + ? T + : T extends string + ? Extension + : never) + | undefined { + if (typeof extension === "string") { + if (!this.extensions.has(extension)) { + return undefined; + } + return this.extensions.get(extension) as any; + } else if (typeof extension === "function") { + if (!this.extensionFactories.has(extension)) { + return undefined; + } + return this.extensionFactories.get(extension) as any; + } else if (typeof extension === "object" && "key" in extension) { + if (!this.extensions.has(extension.key)) { + return undefined; + } + return this.extensions.get(extension.key) as any; + } + throw new Error(`Invalid extension type: ${typeof extension}`); } /** @@ -51,6 +183,25 @@ export class ExtensionManager { return key in this.editor.extensions; } + /** + * Shorthand to get a typed extension from the editor, by + * just passing in the extension class. + * + * @param ext - The extension class to get + * @param key - optional, the key of the extension in the extensions object (defaults to the extension name) + * @returns The extension instance + */ + public extension( + ext: { new (...args: any[]): T } & typeof BlockNoteExtension, + key = ext.key(), + ): T { + const extension = this.editor.extensions[key] as T; + if (!extension) { + throw new Error(`Extension ${key} not found`); + } + return extension; + } + // Plugin getters - these provide access to the core BlockNote plugins /** diff --git a/packages/core/src/editor/managers/extensions/types.ts b/packages/core/src/editor/managers/extensions/types.ts new file mode 100644 index 0000000000..5206c7419d --- /dev/null +++ b/packages/core/src/editor/managers/extensions/types.ts @@ -0,0 +1,140 @@ +import { Store } from "@tanstack/store"; +import { AnyExtension } from "@tiptap/core"; +import { BlockNoteEditor } from "../../BlockNoteEditor.js"; +import { PartialBlockNoDefaults } from "../../../schema/index.js"; +import { Plugin } from "prosemirror-state"; + +/** + * This function is called when the extension is destroyed. + */ +type OnDestroy = () => void; + +/** + * Describes a BlockNote extension. + */ +export interface Extension { + /** + * The unique identifier for the extension. + */ + key: Key; + + /** + * Triggered when the extension is mounted to the editor. + */ + 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; + + /** + * The store for the extension. + */ + store?: Store; + + /** + * Declares what {@link Extension}s that this extension depends on. + */ + dependsOn?: string[]; + + /** + * 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. + */ + inputRules?: InputRule[]; + + /** + * A mapping of a keyboard shortcut to a function that will be called when the shortcut is pressed + * + * The keys are in the format: + * - Key names may be strings like `Shift-Ctrl-Enter`—a key identifier prefixed with zero or more modifiers + * - Key identifiers are based on the strings that can appear in KeyEvent.key + * - Use lowercase letters to refer to letter keys (or uppercase letters if you want shift to be held) + * - You may use `Space` as an alias for the " " name + * - Modifiers can be given in any order: `Shift-` (or `s-`), `Alt-` (or `a-`), `Ctrl-` (or `c-` or `Control-`) and `Cmd-` (or `m-` or `Meta-`) + * - For characters that are created by holding shift, the Shift- prefix is implied, and should not be added explicitly + * - You can use Mod- as a shorthand for Cmd- on Mac and Ctrl- on other platforms + * + * @example + * ```typescript + * keyboardShortcuts: { + * "Mod-Enter": (ctx) => { return true; }, + * "Shift-Ctrl-Space": (ctx) => { return true; }, + * "a": (ctx) => { return true; }, + * "Space": (ctx) => { return true; } + * } + * ``` + */ + keyboardShortcuts?: Record< + string, + (ctx: { editor: BlockNoteEditor }) => boolean + >; + + /** + * Add additional prosemirror plugins to the editor. + */ + plugins?: Plugin[]; + + /** + * Add additional tiptap extensions to the editor. + */ + tiptapExtensions?: AnyExtension[]; +} + +/** + * 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 + */ + find: RegExp; + /** + * The function to call when the input rule is matched + * @returns undefined if the input rule should not be triggered, or an object with the type and props to update the block + */ + replace: (props: { + /** + * The result of the regex match + */ + match: RegExpMatchArray; + // TODO this will be a Point, when we have the Location API + /** + * The range of the text that was matched + */ + range: { from: number; to: number }; + /** + * The editor instance + */ + editor: BlockNoteEditor; + }) => undefined | PartialBlockNoDefaults; +}; + +export type ExtensionFactory = ( + editor: BlockNoteEditor, +) => Extension; + +/** + * Helper function to create a BlockNote extension. + */ +export function createExtension< + Key extends string = string, + State = any, + T extends ExtensionFactory = ExtensionFactory, +>(plugin: T): T { + return plugin; +} + +export function createStore(initialState: T): Store { + return new Store(initialState); +} diff --git a/packages/core/src/extensions/FilePanel/Extension.ts b/packages/core/src/extensions/FilePanel/Extension.ts new file mode 100644 index 0000000000..785f263d19 --- /dev/null +++ b/packages/core/src/extensions/FilePanel/Extension.ts @@ -0,0 +1,97 @@ +import { Derived } from "@tanstack/store"; +import { + createExtension, + createStore, +} from "../../editor/managers/extensions/types.js"; +import { Plugin } from "@tiptap/pm/state"; + +export const FilePanelExtension = createExtension((editor) => { + const store = createStore({ + blockId: undefined as string | undefined, + referencePos: null as DOMRect | null, + }); + + function closeMenu() { + store.setState({ + blockId: undefined, + referencePos: null, + }); + } + + // reset the menu when the document changes (non-remote) + editor.onChange((_e, { getChanges }) => { + if (getChanges().some((change) => change.source.type === "yjs-remote")) { + return; + } + // If the changes are not from remote, we should close the menu + closeMenu(); + }); + + // reset the menu when the selection changes + editor.onSelectionChange(closeMenu); + + const isShown = new Derived({ + fn: () => !!store.state.blockId, + deps: [store], + }); + + isShown.mount(); + + return { + key: "filePanel", + store, + isShown, + closeMenu, + plugins: [ + // TODO annoying to have to do this here + new Plugin({ + props: { + handleKeyDown: (_view, event: KeyboardEvent) => { + if (event.key === "Escape" && isShown.state) { + closeMenu(); + return true; + } + return false; + }, + }, + }), + ], + showMenu(blockId: string) { + const referencePos = editor.getBlockClientRect(blockId); + if (!referencePos) { + // TODO should we do something here? Wait a tick? + return; + } + store.setState({ + blockId, + referencePos, + }); + }, + 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) { + // 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, + }, + ); + }, + }; +}); diff --git a/packages/core/src/extensions/FormattingToolbar/Extension.ts b/packages/core/src/extensions/FormattingToolbar/Extension.ts new file mode 100644 index 0000000000..98f1194323 --- /dev/null +++ b/packages/core/src/extensions/FormattingToolbar/Extension.ts @@ -0,0 +1,113 @@ +import { isNodeSelection, posToDOMRect } from "@tiptap/core"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { + createExtension, + createStore, +} from "../../editor/managers/extensions/types.js"; + +export const FormattingToolbarExtension = createExtension((editor) => { + const store = createStore({ + show: false, + referencePos: null as DOMRect | null, + }); + + let preventShow = false; + + return { + key: "formattingToolbar", + store: store, + plugins: [ + new Plugin({ + key: new PluginKey("formattingToolbar"), + props: { + handleKeyDown: (_view, event) => { + if (event.key === "Escape" && store.state.show) { + store.setState({ show: false, referencePos: null }); + return true; + } + return false; + }, + }, + }), + ], + // TODO should go into core, perhaps `editor.getSelection().getBoundingBox()` + getSelectionBoundingBox() { + const { selection } = editor.prosemirrorState; + + // support for CellSelections + const { ranges } = selection; + const from = Math.min(...ranges.map((range) => range.$from.pos)); + const to = Math.max(...ranges.map((range) => range.$to.pos)); + + if (isNodeSelection(selection)) { + const node = editor.prosemirrorView.nodeDOM(from) as HTMLElement; + if (node) { + return node.getBoundingClientRect(); + } + } + + return posToDOMRect(editor.prosemirrorView, from, to); + }, + init({ dom }) { + const isElementWithinEditorWrapper = (element: Node | null) => { + if (!element) { + return false; + } + const editorWrapper = dom.parentElement; + if (!editorWrapper) { + return false; + } + + return editorWrapper.contains(element); + }; + + function onMouseDownHandler(e: MouseEvent) { + if (!isElementWithinEditorWrapper(e.target as Node) || e.button === 0) { + preventShow = true; + } + } + + function onMouseUpHandler() { + if (preventShow) { + preventShow = false; + setTimeout(() => + store.setState((prev) => ({ + ...prev, + show: true, + referencePos: null, + })), + ); + } + } + + function onDragHandler() { + if (store.state.show) { + store.setState({ show: false, referencePos: null }); + } + } + + const onScrollHandler = () => { + if (store.state.show) { + store.setState((prev) => ({ + ...prev, + referencePos: this.getSelectionBoundingBox(), + })); + } + }; + + dom.addEventListener("mousedown", onMouseDownHandler); + dom.addEventListener("mouseup", onMouseUpHandler); + dom.addEventListener("dragstart", onDragHandler); + dom.addEventListener("dragover", onDragHandler); + dom.addEventListener("scroll", onScrollHandler); + + return () => { + dom.removeEventListener("mousedown", onMouseDownHandler); + dom.removeEventListener("mouseup", onMouseUpHandler); + dom.removeEventListener("dragstart", onDragHandler); + dom.removeEventListener("dragover", onDragHandler); + dom.removeEventListener("scroll", onScrollHandler); + }; + }, + }; +}); diff --git a/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts b/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts index 921d181d1a..d18913b1db 100644 --- a/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts +++ b/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts @@ -10,6 +10,7 @@ import { } from "../../schema/index.js"; import { formatKeyboardShortcut } from "../../util/browser.js"; import { DefaultSuggestionItem } from "./DefaultSuggestionItem.js"; +import { FilePanelExtension } from "../FilePanel/Extension.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 +258,7 @@ export function getDefaultSlashMenuItems< }); // Immediately open the file toolbar - editor.transact((tr) => - tr.setMeta(editor.filePanel!.plugins[0], { - block: insertedBlock, - }), - ); + editor.getExtension(FilePanelExtension)?.showMenu(insertedBlock.id); }, key: "image", ...editor.dictionary.slash_menu.image, @@ -276,11 +273,7 @@ export function getDefaultSlashMenuItems< }); // Immediately open the file toolbar - editor.transact((tr) => - tr.setMeta(editor.filePanel!.plugins[0], { - block: insertedBlock, - }), - ); + editor.getExtension(FilePanelExtension)?.showMenu(insertedBlock.id); }, key: "video", ...editor.dictionary.slash_menu.video, @@ -295,11 +288,7 @@ export function getDefaultSlashMenuItems< }); // Immediately open the file toolbar - editor.transact((tr) => - tr.setMeta(editor.filePanel!.plugins[0], { - block: insertedBlock, - }), - ); + editor.getExtension(FilePanelExtension)?.showMenu(insertedBlock.id); }, key: "audio", ...editor.dictionary.slash_menu.audio, @@ -314,11 +303,7 @@ export function getDefaultSlashMenuItems< }); // Immediately open the file toolbar - editor.transact((tr) => - tr.setMeta(editor.filePanel!.plugins[0], { - block: insertedBlock, - }), - ); + editor.getExtension(FilePanelExtension)?.showMenu(insertedBlock.id); }, key: "file", ...editor.dictionary.slash_menu.file, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 11fe0e5460..3e8a2371fe 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -33,6 +33,8 @@ export * from "./util/combineByGroup.js"; export * from "./util/string.js"; export * from "./util/table.js"; export * from "./util/typescript.js"; +export * from "./editor/managers/extensions/types.js"; +export * from "./extensions/FilePanel/Extension.js"; export type { CodeBlockOptions } from "./blocks/Code/block.js"; export { assertEmpty, UnreachableCaseError } from "./util/typescript.js"; diff --git a/packages/react/package.json b/packages/react/package.json index 3431f570ee..640f596c14 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -61,6 +61,7 @@ "@blocknote/core": "0.41.1", "@emoji-mart/data": "^1.2.1", "@floating-ui/react": "^0.27.16", + "@tanstack/react-store": "0.7.7", "@tiptap/core": "^3.4.3", "@tiptap/pm": "^3.4.3", "@tiptap/react": "^3.4.3", 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..e690aad44c 100644 --- a/packages/react/src/components/FilePanel/FilePanelController.tsx +++ b/packages/react/src/components/FilePanel/FilePanelController.tsx @@ -3,6 +3,7 @@ import { DefaultBlockSchema, DefaultInlineContentSchema, DefaultStyleSchema, + FilePanelExtension, InlineContentSchema, StyleSchema, } from "@blocknote/core"; @@ -11,16 +12,16 @@ import { FC } 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 { usePluginState } from "../../hooks/usePlugin.js"; export const FilePanelController = < B extends BlockSchema = DefaultBlockSchema, I extends InlineContentSchema = DefaultInlineContentSchema, S extends StyleSchema = DefaultStyleSchema, >(props: { - filePanel?: FC>; + filePanel?: FC; floatingOptions?: Partial; }) => { const editor = useBlockNoteEditor(); @@ -31,12 +32,10 @@ export const FilePanelController = < ); } - const state = useUIPluginState( - editor.filePanel.onUpdate.bind(editor.filePanel), - ); + const state = usePluginState(FilePanelExtension); const { isMounted, ref, style, getFloatingProps } = useUIElementPositioning( - state?.show || false, + !!state?.blockId, state?.referencePos || null, 5000, { @@ -56,13 +55,11 @@ export const FilePanelController = < 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/FileReplaceButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileReplaceButton.tsx index ed68593186..a804b899a3 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileReplaceButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileReplaceButton.tsx @@ -65,7 +65,7 @@ export const FileReplaceButton = () => { className={"bn-popover-content bn-panel-popover"} variant={"panel-popover"} > - + ); diff --git a/packages/react/src/hooks/usePlugin.ts b/packages/react/src/hooks/usePlugin.ts new file mode 100644 index 0000000000..698ffcf494 --- /dev/null +++ b/packages/react/src/hooks/usePlugin.ts @@ -0,0 +1,52 @@ +import { + BlockNoteEditor, + createStore, + Extension, + ExtensionFactory, +} from "@blocknote/core"; +import { useStore } from "@tanstack/react-store"; +import { useMemo } from "react"; +import { useBlockNoteEditor } from "./useBlockNoteEditor.js"; + +type Store = ReturnType>; + +/** + * Use an extension instance + */ +export function usePlugin( + plugin: ExtensionFactory | Extension, + ctx?: { editor?: BlockNoteEditor }, +) { + // eslint-disable-next-line react-hooks/rules-of-hooks + const editor = ctx?.editor ?? useBlockNoteEditor(); + + const instance = useMemo(() => editor.addExtension(plugin), [editor, plugin]); + + return instance; +} + +type ExtractStore = T extends Store ? U : never; + +/** + * Use the state of an extension + */ +export function usePluginState< + T extends ExtensionFactory | Extension, + TExtension = T extends ExtensionFactory ? ReturnType : T, + TStore = TExtension extends { store: Store } + ? TExtension["store"] + : never, + TSelected = NoInfer>, +>( + plugin: T, + ctx?: { + editor?: BlockNoteEditor; + selector?: (state: NoInfer>) => TSelected; + }, +): TSelected { + const { store } = usePlugin(plugin, ctx); + if (!store) { + throw new Error("Store not found", { cause: { plugin } }); + } + return useStore, TSelected>(store, ctx?.selector as any); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 01a96da9da..db0c4f9ebd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4414,6 +4414,9 @@ importers: '@shikijs/types': specifier: 3.13.0 version: 3.13.0 + '@tanstack/store': + specifier: 0.7.7 + version: 0.7.7 '@tiptap/core': specifier: https://pkg.pr.new/@tiptap/core@5e777c9 version: https://pkg.pr.new/@tiptap/core@5e777c9(@tiptap/pm@3.4.3) @@ -4663,6 +4666,9 @@ importers: '@floating-ui/react': specifier: ^0.27.16 version: 0.27.16(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@tanstack/react-store': + specifier: 0.7.7 + version: 0.7.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@tiptap/core': specifier: https://pkg.pr.new/@tiptap/core@5e777c9 version: https://pkg.pr.new/@tiptap/core@5e777c9(@tiptap/pm@3.4.3) @@ -10027,12 +10033,21 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 + '@tanstack/react-store@0.7.7': + resolution: {integrity: sha512-qqT0ufegFRDGSof9D/VqaZgjNgp4tRPHZIJq2+QIHkMUtHjaJ0lYrrXjeIUJvjnTbgPfSD1XgOMEt0lmANn6Zg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/react-virtual@3.13.12': resolution: {integrity: sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/store@0.7.7': + resolution: {integrity: sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ==} + '@tanstack/virtual-core@3.13.12': resolution: {integrity: sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==} @@ -21086,12 +21101,21 @@ snapshots: tailwindcss: 4.1.12 vite: 6.3.5(@types/node@22.15.2)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.19.3)(yaml@2.7.0) + '@tanstack/react-store@0.7.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@tanstack/store': 0.7.7 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + use-sync-external-store: 1.6.0(react@19.1.0) + '@tanstack/react-virtual@3.13.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@tanstack/virtual-core': 3.13.12 react: 19.1.0 react-dom: 19.1.0(react@19.1.0) + '@tanstack/store@0.7.7': {} + '@tanstack/virtual-core@3.13.12': {} '@testing-library/dom@10.4.0': @@ -27422,7 +27446,7 @@ snapshots: dependencies: dequal: 2.0.3 react: 19.1.0 - use-sync-external-store: 1.5.0(react@19.1.0) + use-sync-external-store: 1.6.0(react@19.1.0) symbol-tree@3.2.4: {}