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: {}