Skip to content

Conversation

@nperez0111
Copy link
Contributor

@nperez0111 nperez0111 commented Oct 31, 2025

Problem

Currently, there is not much consistency in how the editor's feature set is extended, both internally and externally. Things like UI extensions (formatting toolbar, side menu, etc) are baked directly into the editor, while the trailing block extension gets added as a TipTap extension, and the placeholder extension is a ProseMirror Plugin.

The BlockNote extension API was somewhat haphazardly created out of the need for a unified API to add features to the editor, such as keyboard shortcuts, input rules, and exposing the ProseMirror plugin API for more advanced use cases. We migrated this in piecemeal, supporting ProseMirror plugins, TipTap extensions & a stripped down BlockNote extension API, but we need more!

Solution

BlockNote Extensions API Refactor

We've made big improvements to the BlockNote extension API, making extensions much more scalable. In addition to all previous functionality (like adding keyboard extensions), extensions can now:

  • Have a proper life cycle, i.e. initialise themselves, add event listeners, and cleanup.
  • Have a state that other parts of the app/UI can read from and write to.
  • Expose methods which other parts of the app/UI can call.
  • Be added & removed from the editor even after initialisation.
  • Be configured with different options.

Pretty much all existing editor features have been converted into BlockNote extensions, and use the same API to be created, as well as added to/removed from the editor. This is a great change to the codebase quality, as the editor is a lot more modular thanks to this change, and there is now a clear way to extend the editor's functionality.

UI Extension Refactor

Additionally, the UI extensions have been partially rewritten (currently includes the formatting toolbar, link toolbar, and file panel) to take advantage of the new extension functionality. The states they keep have been minimised as much as possible, and many of their responsibilities have been moved to the React layer. This massively cuts down on the overall code and makes them much more understandable.

Speaking of the React layer, alongside refactoring the UI extensions in the editor core, the React components and hooks have also been overhauled. FloatingUI now handles much of what was previously done by the extensions, including:

  • Positioning the element in the viewport.
  • Updating the element position from user interaction (scrolling, resizing the editor, etc).
  • Dismissing the element from user interaction (clicking outside the editor, pressing escape, etc).
  • Ensuring that the element transitions in/out correctly.

Alongside that, a bunch of helper components and hooks have been created to streamline the code.

In-Depth Changes

Extensions API

Creating Extensions

To create a BlockNote extension, you use the createExtension function. It works similarly to the old createBlockNoteExtension function, and you can still add an extension key, keyboard shortcuts, input rules, TipTap extensions, and ProseMirror plugins similarly to before. Also like before, you can add your own custom properties to the extension:

const YourExtension = createExtension({
  key: "your-extension",
  
  keyboardShortcuts: {
    "Mod+Shift+1": ({ editor }) => { ... }
  },
  inputRules: [
    {
      find: new RegExp(`^[-+*]\\s$`),
      replace: ({ match, range, editor }) => { ... }
    }
  ],
  tiptapExtensions: [
    Extension.create({ ... })
  ],
  prosemirrorPlugins: [
    new Plugin({ ... })
  ],
  
  customString: "...",
  customObject: { ... },
  customFunction: (...) => { ... },
  
  ...
});

You can also now give your extensions configurable options by passing a function to createExtension instead of an object:

const YourExtension = createExtension(({ editor, options }) => ({
  key: "your-extension",
  
  ...
}));

Additionally, there are now three additional properties that you can give your extensions:

store: A TanStack store which holds the extension's state. For example, a table of contents extension may keep a state containing a list of heading blocks in the editor, as well as their text content. This state can also be accessed and modified by other extensions.

mount: A callback which runs when the editor gets mounted to the DOM. You can use it to setup event listeners and update the state.

runsBefore: An array which holds keys of other BlockNote extensions that the extension depends on. This ensures that an extension's state will be initialised before other extensions try to access it.

const YourExtension = createExtension(({editor, options}) => {
  const store = createStore({ ... })
  
  return {
    key: "your-extension",
    
    ...
    
    store,
    mount: ({ dom, root, signal }) => {
      const onMouseDown = () => {
        ...
        store.setState((state) => ({ ... }))
        ...
      };
      
      const onMouseUp = () => {
        ...
        store.setState((state) => ({ ... }))
        ...
      };
      
      const onChange = () => {
        ...
        store.setState((state) => ({ ... }))
        ...
      };
    
      dom.addEventListener("mousedown", onMouseDown);
      root.addEventListener("mouseup", onMouseUp);

      const destroyOnChange = editor.onChange(onChange);
      
      signal.addEventListener("abort", () => {
        dom.removeEventListener("mousedown", onMouseDown);
        root.removeEventListener("mouseup", onMouseUp);
        
        destroyOnChange();
      });
    },
    runsBefore: [ ... ]
  }
});

Adding/Removing Extensions

You can still pass your BlockNote extensions to the extensions option in useCreateBlockNote:

const editor = useCreateBlockNote({
  extensions: [YourExtension]
});

However, you can now also dynamically add/remove extensions after the editor is initialised using editor.registerExtension and editor.unregisterExtension:

editor.registerExtension(YourExtension);

editor.unregisterExtension("your-extension");

Accessing Extensions

You can access extensions and their states using editor.getExtension:

const yourExtension = editor.getExtension(YourExtension);

React Components & Hooks

Editor Hooks

A universal useEditorState hook has been added to listen to changes in the editor state. It replaces existing hooks which read parts of the editor state, such as useActiveStyles and useSelectedBlocks.

const { ... } = useEditorState({ selector: (editor) => {
  ...
  
  return { ... }
}});

The selector allows you return specific properties from the editor state, or derive new properties from it. Whenever it's called (i.e. when the editor content of selection changes), it compares the new result with the old one. Only if the new result is different does it trigger a re-render. This works not only when the result is a primitive type (boolean, number, string, etc), but also if it's a plain object, i.e. an object without function properties.

For example, here's how you would redefine the old useActiveStyles hook:

const { bold, italic, ...rest } = useEditorState({
    selector: ({ editor }) => editor.getActiveStyles(),
});

This will only trigger a re-render if any of the active styles have actually changed.

The useEditorState hook takes several other options:

const { ... } = useEditorState({
  selector: (editor) => { ... },
  editor: ...,
  equalityFn: (oldResult, newResult) => ...,
  on: ...
});

editor: The BlockNote editor to use. Only needed if used outside a .bn-container element.

equalityFn: Function to check whether the new result of calling the selector is different to the old result, in order to trigger a re-render. Uses deep equals by default, and only really needed of the returned object contains functions, or other niche cases where deep equals may throw false positives.

on: Determines when the selector is called. Calls on both editor selection and content ("all") changes by default, but can be changed to only listen for content ("change") or selection ("selection") changes.

runsBefore: An array which holds keys of other BlockNote extensions that the extension depends on. This ensures that an extension's state will be initialised before other extensions try to access it.

Extension Hooks

When creating custom blocks or UI elements like menus & toolbars, it's likely you'll want to access editor extensions and their states. To do this, the useExtension and useExtensionState hooks have been created.

The useExtension hook lets you access the properties of an extension in the editor, except:

  • keyboardShortcuts
  • inputRules
  • tiptapExtensions
  • prosemirrorPlugins
  • runsBefore
const yourExtension = useExtension(YourExtension);

const { key, store, mount, customString, customObject, customFunction } = yourExtension;

It also has the same editor option as useEditorState:

const yourExtension = useExtension(YourExtension, { editor: ... });

The useExtensionState hook lets you access the state of an extension in the editor:

const yourExtensionState = useExtensionState(YourExtension);

It also has the same editor and selector options as useEditorState:

const yourExtensionState = useExtensionState(YourExtension, { editor: ..., selector: (state) => { ... }});

Popover Components

The old useUIElementPositioning has been removed in favour of components which are easier to use and understand.

The BlockPopover component is used to create a popover around a specific block, and is used by elements like the side menu as well as the file panel:

<BlockPopover blockId={...} {...floatingUIOptions}>...</BlockPopover>

The PositionPopover component is used to create a popover around a ProseMirror position range, and is used by elements like the formatting toolbar:

<PositionPopover position={{ from: ..., to: ... }} {...floatingUIOptions}>...</PositionPopover>

The GenericPopover component is used to create a popover around DOM element or FloatingUI virtual element, and is used by both the BlockPopover as well as the PositionPopover:

<GenericPopover reference={...} {...floatingUIOptions}>...</GenericPopover>

All 3 components take floatingUIOptions, which is an object that combines props from all the FloatingUI hooks used in GenericPopover, e.g. useFloating and useDismiss.

Breaking Changes

@blocknote/core

  • Existing extensions using the BlockNoteExtension class are no longer supported. Use the createExtension function instead.
  • Extension-specific imports have been moved from @blocknote/core to @blocknote/core/extensions.
  • Comment-specific imports have been moved from @blocknote/core to @blocknote/core/commnents.
  • Extensions are no longer listed as properties of BlockNoteEditor, e.g. editor.formattingToolbar. Use the getExtension method instead, e.g. editor.getExtension(FormattingToolbarExtension).
  • editor.openSuggestionMenu has been removed. Use editor.getExtension(SuggestionMenu).openSuggestionMenu instead.
  • editor.getForceSelectionVisible/editor.setForceSelectionVisible have been removed. Use editor.getExtension(ShowSelectionExtension).store.state.enabled/editor.getExtension(ShowSelectionExtension).showSelection instead.
  • editor.onCreate has been removed. Use editor.onMount instead.
  • insertOrUpdateBlock has been renamed to insertOrUpdateBlockForSlashMenu.
  • editor.updateCollaborationUserInfo has been removed.

@blocknote/react

  • useEditorContentOrSelectionChange has been removed. Use useEditorState instead.
  • useUIPluginState has been removed. Use useExtension or useExtensionState instead.
  • useUIElementPositioning has been removed. Use BlockPopover, PositionPopover, or GenericPopover instead.
  • useEditorForceUpdate has been removed.

@blocknote/xl-ai

  • BlockPositioner has been removed. Use BlockPopover instead.

Testing

While this PR brings many changes to UI elements and their extensions, our UI testing setup is sadly not up to the standard we need to ensure nothing breaks when it gets merged. We could expand our suite of Playwright E2E tests, but decided not to as these have historically slow, flaky, and hard to debug. At some point, we will need to look at implementing a solid component testing setup to prevent UI regressions.

In the meantime though, we have compiled a thorough list of test cases for each default UI element, and manually ensured that each of them pass. The list can be found here:

Notion Testing Doc

The only exception are the table handles, which have very minor regressions.

@vercel
Copy link

vercel bot commented Oct 31, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Preview Updated (UTC)
blocknote Ready Ready Preview Nov 28, 2025 5:02pm
blocknote-website Error Error Nov 28, 2025 5:02pm

Comment on lines 592 to 594
// (this.options.editor.formattingToolbar?.shown ||
// this.options.editor.linkToolbar?.shown ||
// this.options.editor.filePanel?.shown)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally this would not have to be aware of every menu that could be open. I'm not sure if this means that there should be a single "menu" extension which stores the current state of whatever menu is currently active? Or, if these menus should be setting keyboard handlers to capture the Tab key or otherwise not giving this event to the editor.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this was here for accessibility reasons - but indeed, there needs to be a better / more scalable architecture

Comment on lines 9 to 10
// TODO defaults?
const placeholders = options.placeholders;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There were some defaults here that we should also apply

@matthewlipski matthewlipski changed the title refactor(extensions): rewrite to use new extension system feat: Extension system overhaul Nov 26, 2025
@matthewlipski matthewlipski changed the title feat: Extension system overhaul feat: Major Extensions & UI Refactor Nov 27, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants