Skip to content

feat: API design feedback #1756

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 12 commits into
base: main
Choose a base branch
from
15 changes: 13 additions & 2 deletions adr/2025_06_13-extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@ What I identified was:

- The ability to "eject" to the prosemirror API, if we don't provide something good enough. This is a core choice, and one I don't think we would ever need to walk back on. Fundamentally, we do not want to expose absolutely everything that Prosemirror can do, but also do not want to stop those with the know-how to actually get stuff done.
- A unified store API, to make consuming extension state homogenous, this will be discussed further below
- Life-cycle event handlers, by providing hooks for `create`, `mount`, and `unmount` we allow extensions to attach event handlers to DOM elements, and have a better general understanding of the current state of the editor. The `onCreate` handler will be very handy to guarantee access to the editor instance before anything is called.
- Editing event handlers, by providing hooks for `change`, `selectionChange`, `beforeChange`, and `transaction` we give extensions access to the fundamental primitives of the editor.
- Life-cycle event handlers, by providing hooks for `mount` and `unmount` we allow extensions to attach event handlers to DOM elements, and have a better general understanding of the current state of the editor.

## State Management

Expand Down Expand Up @@ -47,3 +46,15 @@ Not everything can be communicated through just state, so we need to be able to
I propose that we have a `extensions` property on the `BlockNoteEditor` instance, which is a map of the extension key to the extension instance, but filtered out to only include non-blocknote methods (as defined by the `ExtensionMethods` type).
Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm curious to see what you have in mind for how extensions should be defined too. Right now, to make something resembling an extension using the BlockNote API only, you generally listen to editor.onChange/useEditorChange and go from there, whether it's making changes to the editor, or doing stuff outside the editor.

So I'm wondering if all we really need an extension to be is a state + listeners, sort of like this:

function defineExtension<State extends Record<string, any>>({
  initialState: State;
  listeners?: Partial<{
    onCreate: (editor: BlockNoteEditor) => State,
    onContentChange: (editor: BlockNoteEditor) => State,
    onSelectionChange: (editor: BlockNoteEditor) => State,
    onDestroy: (editor: BlockNoteEditor) => State,
  }>
}) {...}

Copy link
Contributor Author

@nperez0111 nperez0111 Jun 19, 2025

Choose a reason for hiding this comment

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

Described in the TS file here:

export interface BlockNoteExtensionConfig<State> {

I also thought it'd be good to have hooks for these sort of event's, but Yousef pointed out they were already available on the editor instance.


This will allow the application to access the extension methods, and also allows us to keep the extension instance private to the editor (type-wise).

# BlockNote Bundles

Somewhat related, to extensions, we also need a way for bundling sets of editor elements in a single packaging. This will be a much higher-level API, which will aim to provide a single import for adding an entire set of functionality to the editor (e.g. adding multi-column support, or comments, etc.)

## Core Requirements

- A way to add blocks, inline content, and styles to the editor
- A way to add extensions to the editor
- A way to add to the dictionary of locales to the editor
- A way to add to the slash menu
- A way to add to the formatting toolbar
6 changes: 6 additions & 0 deletions adr/2025_06_13-packages.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,9 @@ It contains sub-packages for:
- `@blocknote/react/comments` - Contains the comments package.
- `@blocknote/react/table-handles` - Contains the table handles package.
- etc... (need to figure out what needs to be exported vs. available and how coupled this all is)

## Editor instance

The editor instance has been growing in complexity, and handles too many different concerns. To focus it, we should split it up into something like: the `BlockNoteEditor.ts` file.

The goal would be to group the functionality of the editor instance in such a way that it is easier to navigate and understand.
55 changes: 55 additions & 0 deletions adr/2025_06_17-formats.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# BlockNote Formats

Right now, there are several formats supported by BlockNote:
Copy link
Collaborator

Choose a reason for hiding this comment

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

I would even consider the "rendered" HTML another format (i.e.: what you see in the editor)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yep, I did miss that one


- internal html (serialized html from the editor's schema) for things like clipboard handling
- external html (a "normal" html format) for things like rendering to a blog
- markdown
- exported formats: docx, pdf, react

There is a relationship between all of these formats:

```ts
/** The editor content */
type Model = Block[] | InternalEditorState;

type InternalHTML = string;
function toInternalHTML(model: Model): InternalHTML;
function fromInternalHTML(html: InternalHTML): Model;

type ExternalHTML = string;
function toExternalHTML(model: Model): ExternalHTML;
function fromExternalHTML(html: ExternalHTML): Model;

type Markdown = string;
function toMarkdown(model: ExternalHTML): Markdown;
function fromMarkdown(markdown: Markdown): Model;

type Docx = Buffer;
function toDocx(model: Model): Docx;

type PDF = Buffer;
function toPDF(model: Model): PDF;
```

You'll notice that the formats are all derived from the model.

This gives us a unidirectional flow of data. Any other direction is potentially lossy (e.g. markdown -> model).
Copy link
Collaborator

Choose a reason for hiding this comment

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

Isn't Model -> *** also potentially lossy if the target format doesn't have mappings to aspects of the model (e.g. nested blocks in Markdown)?

Copy link
Contributor Author

@nperez0111 nperez0111 Jun 19, 2025

Choose a reason for hiding this comment

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

Sure, that is true too. Depends how good your parse/render is (e.g. model to html should be lossless)


## Internal vs. External HTML

There are two representations of HTML:

- The internal HTML format which is a serialized version of the editor's schema, without regard for normalization (e.g. list items will be nested oddly)
- Used for clipboard handling
- The external HTML format which is a normalized version of the editor's schema, with list items nested "normally"

The internal HTML format is "lossless", whereas the external HTML format is not

Ideally we could avoid having to convert between the two formats, and just use the external HTML format as an output format.

One idea to get closer to this, would be to have the clipboard instead use the model as the "internal" format, and then have the external HTML be a fallback for when the model would not be interpretable (such as by other applications).

In an ideal world, we would only have a single html format, and it would be what is currently the "external" html format.

See some earlier discussion about this: <https://github.com/TypeCellOS/BlockNote/issues/1583>
9 changes: 9 additions & 0 deletions adr/2025_06_18-ui-components.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# BlockNote UI

## Lightweight JSX

There is a large difference between UI components defined in the core library compared to the ones in the react package. The paradigms are very different due to the low-level elements in the core library, ideally we could bring this closer together without completely depending on react for the core library.

## Theming

There are several approaches here that would require a lot more research and discussion.
80 changes: 80 additions & 0 deletions adr/BlockNoteBundle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { BlockNoteBundle } from "./BlockNoteExtension";

export interface BlockNoteBundleConfig<
Schema,
Extensions extends BlockNoteExtension<any>[],
> {
/**
* The prosemirror plugins of the extension
*/
extensions?: Extensions;

/**
* The schema that this extension adds to the editor
*/
schema?: BlockNoteSchema<Schema>;

/**
* Add a dictionary of locales to the editor
*/
dictionary?: Record<string, Locale>;

/**
* Items that are available to the slash menu
*/
slashMenuItems?: (
Copy link
Collaborator

Choose a reason for hiding this comment

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

Let's cross this bridge when we get there, but I think we can come up with something more generic / scalable

Copy link
Contributor Author

Choose a reason for hiding this comment

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

100% was just a concept of cross-cutting concerns

| {
name: string;
icon?: any;
onClick: () => void;
}
// or return a component
| (() => any)
)[];

/**
* Items that are available to the formatting toolbar
*/
formattingToolbar?: (
| {
name: string;
icon?: any;
onClick: () => void;
}
// or return a component
| (() => any)
)[];
}

export type Locale = Record<string, string | Record<string, string>>;

/**
* This is an example of what an extension might look like
*/
export function multiColumnExtension(extensionOptions: {
Copy link
Collaborator

Choose a reason for hiding this comment

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

I noticed this is quite a different approach to how we currently do e.g. the AI extension, as here everything is v much bundled together whereas for AI you add stuff like slash menu items manually.

This seems like another case of convenience vs composability, as it's much nicer to add an extension but can be annoying if e.g. you don't want to include the extension's keyboard handlers. What made you lean towards this approach instead?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is meant to be a higher-level API than extensions are, and would be useful for packaging sets of functionality (e.g. for 3rd-party packages).

There is always a trade-off when it comes to flexibility vs convenience. This is definitely the most half-baked of the APIs, but is mostly to point out that there isn't any single "BlockNote Extension API". There has to be something low-level like a prosemirror plugin, and something high-level like a "Add comments to the editor".

dropCursor?: Record<string, string>;
columnResize?: Record<string, string>;
}) {
return (editor) =>
({
schema: withMultiColumnSchema(editor.schema),
extensions: [
multiColumnDropCursor(editor),
columnResizeExtension(editor),
],
slashMenuItems: [
{
name: "2 Column",
icon: {},
onClick: () => {},
},
],
formattingToolbar: [
{
name: "Multi Column",
icon: {},
onClick: () => {},
},
],
}) satisfies BlockNoteBundleConfig<any, any[]>;
}
131 changes: 131 additions & 0 deletions adr/BlockNoteEditor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { BlockNoteExtension } from "./BlockNoteExtension";

/**
* Transform is a class which represents a transformation to a block note document (independent of the editor state)
*
* These are higher-level APIs than editor commands, and operate at the block level.
*/
export class Transform {
// low-level operations
public exec: (command: Command) => void;
public canExec: (command: Command) => boolean;
public transact: <T>(callback: (transform: Transform) => T) => T;

// current state
public get document(): Block[];
public set document(document: Block[]);
public get selection(): Location; // likely more complex than this
public set selection(selection: Location);
Copy link
Collaborator

Choose a reason for hiding this comment

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

Thinking about naming, my association with a "transform" is that it would change something. I wouldn't expect things like selection, getBlock etc. to be "transforms". Should these be something else, or do we need a better name?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I toyed with calling this editor.state but it felt overly generic, but maybe that is the right way to go.

My way for explaining transforms was that it is all about transitions of state & what you might need to go from A -> B

Copy link
Collaborator

Choose a reason for hiding this comment

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

Yeah I'm also not 100% convinced about Transform - I see the reasoning it just feels like it should only include stuff which does indeed modify the editor state, whereas the getters do not.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I also toyed around with trying to separate reads from writes, but it too felt awkward for example:

// feels like mixing levels of the API
editor.transform.replaceBlocks(editor.state.getCurrentSelection().blocks, [])

// feels about right, but state is overloaded with prosemirror
editor.state.replaceBlocks(editor.state.selection.blocks, [])

// Feels awkward
Transform.replaceBlocks(editor, editor.state.getCurrentSelection().blocks, [])

// Maybe? But, split now
editor.commands.replaceBlocks(editor.state.getCurrentSelection().blocks, [])

What I'm trying to stay away from is just throwing it on the top-level for the editor instance, things which operate on it's state should be different than things like editor.focus, but maybe I've gone too far in the other direction.


// Block-level operations
public forEachBlock: (callback: (block: Block) => void) => void;
public getBlock(at: Location): Block | undefined;
public getPrevBlock(at: Location): Block | undefined;
public getNextBlock(at: Location): Block | undefined;
public getParentBlock(at: Location): Block | undefined;
public insertBlocks(ctx: { at: Location; blocks: Block | Block[] });
public updateBlock(ctx: { at: Location; block: PartialBlock });
public replaceBlocks(ctx: { at: Location; with: Block | Block[] });
public nestBlock(ctx: { at: Location }): boolean;
public unnestBlock(ctx: { at: Location }): boolean;
public moveBlocksUp(ctx: { at: Location }): boolean;
public moveBlocksDown(ctx: { at: Location }): boolean;

// Things which operate on the editor state, not just the document
public undo(): boolean;
public redo(): boolean;
public createLink(ctx: { at: Location; url: string; text?: string }): boolean;
public deleteContent(ctx: { at: Location }): boolean;
public replaceContent(ctx: {
at: Location;
with: InlineContent | InlineContent[];
}): boolean;
public getStyles(at?: Location): Styles;
public addStyles(styles: Styles): void;
public removeStyles(styles: Styles): void;
public toggleStyles(styles: Styles): void;
public getText(at?: Location): string;
public pasteHTML(html: string, raw?: boolean): void;
public pasteText(text: string): void;
public pasteMarkdown(markdown: string): void;
}

export type Unsubscribe = () => void;

/**
* EventManager is a class which manages the events of the editor
*/
export class EventManager {
public onChange: (
callback: (ctx: {
editor: BlockNoteEditor;
get changes(): BlocksChanged;
}) => void,
) => Unsubscribe;
public onSelectionChange: (
callback: (ctx: {
editor: BlockNoteEditor;
get selection(): Location;
}) => void,
) => Unsubscribe;
public onMount: (
callback: (ctx: { editor: BlockNoteEditor }) => void,
) => Unsubscribe;
public onUnmount: (
callback: (ctx: { editor: BlockNoteEditor }) => void,
) => Unsubscribe;
}

export class BlockNoteEditor {
public events: EventManager;
public transform: Transform;
public extensions: Record<string, BlockNoteExtension>;
/**
* If {@link BlockNoteEditor.extensions} is untyped, this is a way to get a typed extension
*/
public getExtension: (ext: BlockNoteExtension) => BlockNoteExtension;
public mount: (parentElement: HTMLElement) => void;
public unmount: () => void;
public pm: {
get schema(): Schema;
get state(): EditorState;
get view(): EditorView;
};
public get editable(): boolean;
public set editable(editable: boolean);
public get focused(): boolean;
public set focused(focused: boolean);
public get readOnly(): boolean;
public set readOnly(readOnly: boolean);

public readonly dictionary: Dictionary;
public readonly schema: BlockNoteSchema;
}

// A number of utility functions can be exported from `@blocknote/core/utils`

export function getSelectionBoundingBox(editor: BlockNoteEditor) {
// implementation
}

export function isEmpty(editor: BlockNoteEditor) {
// implementation
}

// Formats can be exported from `@blocknote/core/formats`

export function toHTML(editor: BlockNoteEditor): string {
// implementation
}

export function toMarkdown(editor: BlockNoteEditor): string {
// implementation
}

export function tryParseHTMLToBlocks(html: string): Block[] {
// implementation
}

export function tryParseMarkdownToBlocks(markdown: string): Block[] {
// implementation
}
Loading
Loading