-
-
Notifications
You must be signed in to change notification settings - Fork 570
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
base: main
Are you sure you want to change the base?
Conversation
The latest updates on your projects. Learn more about Vercel for Git ↗︎
|
@blocknote/ariakit
@blocknote/code-block
@blocknote/core
@blocknote/mantine
@blocknote/react
@blocknote/server-util
@blocknote/shadcn
@blocknote/xl-ai
@blocknote/xl-docx-exporter
@blocknote/xl-multi-column
@blocknote/xl-odt-exporter
@blocknote/xl-pdf-exporter
commit: |
packages/core/src/editor/Location.ts
Outdated
* Each level of the path is a child of the previous level. | ||
* The entire document can be described by the path []. | ||
*/ | ||
export type Path = BlockIdentifier[]; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Because a block has a unique id, I'm not sure whether Path
is useful? For example, in Point
below, we could just use a blockIdentfier instead of path
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fair.
a Path
can be useful for being able to compare depths of things based on their content alone.
For example:
const a = ['abc', '123']
const b = ['abc', '345']
Based on this alone, you know they share parents, and therefore siblings of some kind, but I guess you would always have to check with the actual document to see that they really exist or not, so you are probably right
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice! Lots of great stuff in here. Besides discussing the open comments, how do we move forward from here?
My suggestion would be to extract isolated work items and prioritize them. Maybe first identify which other substantial core improvements are missing, the following come to mind:
- UI elements cleanup
- Streamline Rendered content, "external HTML", markdown, exporters (i.e.: topics discussed in Berin)
- Zod props
- unified style / theming API
- ...?
|
||
```ts | ||
editor.transform.insertBlocks(ctx:{ | ||
at: Location, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is it useful to insertblocks at a Location
vs the current API (before / after / inside an existing block)?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Toss-up for insertBlocks
, but for insertContent(ctx: {at: Location; content: string })
}) | ||
``` | ||
|
||
## References |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice, currently things like comments can't be represented in the BlockNote API, and indeed, this would enable us to separate them from the document data definitely feels like the "right approach"
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah I like this idea a lot, feels more general than ProseMirror's decorations. In theory it would also make it much nicer for our UI architecture, since instead of writing plugins you could just use references. E.g. for the formatting toolbar, you could listen to selection changes and create a reference pointing to the selection when you want to show it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
E.g. for the formatting toolbar, you could listen to selection changes and create a reference pointing to the selection when you want to show it.
🤯 what a great idea! If it were robust enough for that, this could simplify a ton of things!
packages/core/src/editor/Location.ts
Outdated
*/ | ||
export type Path = BlockIdentifier[]; | ||
|
||
/** |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How would this work for things like table cells?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Likely would use a Point to refer to table-cells as it is right now.
If a table-cell was implemented as nested block, then it would just be a Path
|
||
We've done pretty well with our existing API, but there are a few things that could be improved. | ||
|
||
Referencing content within the document is either awkward or non-existent. Right now we essentially really only have an API for referencing blocks by their id with no further level of granularity. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Overall, I think the concept of a Location
is useful to have - but if we start implementing this I think we should have more sight on practical use-cases. i.e. which use-cases / APIs do we want to "unlock" with this?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
insertContentAt(at: Location, content: Block | PartialBlock | string)
getSelection(): {range: Range}
A number of the low-level Tiptap API uses for things like deleting some chars
This will also be very useful for server-side editing ops like rewriting a paragraph
|
||
## Transforms separation of concerns | ||
|
||
Right now all transformation functions are defined directly on the `Editor` instance, this is not ideal because it only further muddles the API. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cool. I think maybe this should be a separate work-item ("cleaning up BlockNoteEditor
"). Doing this hand-in-hand with updating documentation probably gives us a lot of insights into what we think would be a better organization of functions
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Working on that 😉
- Relationships between blocks/inline-content/styles can be defined (e.g. allow for a todo block to only have todo item children) | ||
- Properties of blocks/inline-content/styles can be defined (e.g. adding `heading` to the `toggleable` group) | ||
|
||
This may or may not be useful, but it is a thought. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
cool, let's keep in mind 👍
|
||
Right now it is overly burdensome to have to pass around 3 different types to the editor, and it is also not very type-safe (when you just end up with `any` everywhere). | ||
|
||
The idea is to have a single type that is a union of the 3 types, and then make type predicates available to check if accessed properties are valid (and likely just assertions too). |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
would be great if we can get this to work!
|
||
These forms of configuration are not mutually exclusive, and can be combined in different ways. For example, knowing that the editor has collaboration enabled, might change the what the keybindings do for undo/redo. | ||
|
||
In an ideal world, these configurations would be made at the "lowest possible level", like configuring the number of levels of headings would be configured when composing the schema for the editor, rather than at the editor level. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
great principle 🙌
This is a proposal for how to support nested blocks. | ||
|
||
```json | ||
{ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this is technically what we already support to some level, right?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yep, modeled on the multi-column, but generic for others to implement
"type": "alert", | ||
"content": null, | ||
"children": [ | ||
{ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure if this will work out. now suddenly, children
can not only contain blocks anymore, but also inline content. I'm afraid this will break quite a bit (both technically and in terms of explainability)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
or wait, alert-title and alert-content are considered blocks here as well?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yep, considered blocks. Would need to introduce the concept of a "nested block" which is not valid on it's own but only in the context of being part of another block.
This is also shown with the table example below
Agreed, I feel like this is a bit larger than I'm willing to take on at the moment though.
Agreed, I will add this to the list.
Will add to the list
I think this is the same as the ui elements cleanup
I feel like these are the most pressing at the moment, but let me know what you think! |
Forgot this one:
|
@@ -0,0 +1,55 @@ | |||
# BlockNote Formats | |||
|
|||
Right now, there are several formats supported by BlockNote: |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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
/** | ||
* Items that are available to the slash menu | ||
*/ | ||
slashMenuItems?: ( |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
public get document(): Block[]; | ||
public set document(document: Block[]); | ||
public get selection(): Location; // likely more complex than this | ||
public set selection(selection: Location); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
|
||
[Looking at Slate](https://docs.slatejs.org/concepts/03-locations) (highly recommend reading the docs), they have the concept of a `Location` which is a way to reference a specific point in the document, but it does not have to be so specific as positions, it has levels of granularity. | ||
|
||
This gives us a unified way to reference content within the document, allowing for much more granular editing. Take a look at the `Location.ts` file for more details around this. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we want to necessarily use the Location
concept? I like the granularity but it feels like we could achieve something similar by just using block/inline content ID references. I.e.:
- Low granularity: Point to the start/end of a block by its ID.
- Mid granularity: Point to the start/end of inline content by its ID.
- High granularity: Point to the start of inline content with an offset by its ID.
I feel like Slate's Location
is necessary due to the fact that you can only reference nodes by their positions. Since we set IDs, I think we should make use of that since imo it'll result in a simpler API. So our Location
could look more like:
type Location = {
id: string;
side?: "start" | "end";
offset?: number
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
inline content by its ID.
Inline content doesn't have an ID does it? And, I don't think it should, it'd be too granular and require wrapping things in marks everywhere.
I'm willing to remove Path
as pointed out here: #1756 (comment), and, I've now updated the typescript file to represent that.
I don't think your proposal for a Location
captures everything that Slate's could though, such as representing a Range
which is extremely useful.
Using your language, I think we can achieve this with:
/**
* A block id is a unique identifier for a block, it is a string.
*/
export type BlockId = string;
/**
* A block identifier is a unique identifier for a block, it is either a string, or can be object with an id property (out of convenience).
*/
export type BlockIdentifier = { id: BlockId } | BlockId;
/**
* A point has an id and an offset, it is used to identify a specific position within a block.
*/
export type Point = {
id: BlockId;
offset: number;
};
/**
* A range is a pair of points, it is used to identify a range of blocks within a document.
*/
export type Range = {
anchor: Point;
head: Point;
};
/**
* A location is an id, point, or range, it is used to identify positions within a document.
*/
export type Location = BlockId | Point | Range;
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah good point about ranges, missed that. I'm wondering if there's a use case for pointing to a specific inline content that we would miss by just having block + offset, maybe for extensions which work with links?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To point to a specific inline content, you could have a Point
which points at the start of the inline content, or a Range
that encapsulates the whole inline content range:
{id: 'abc', content:[{type:'link', text: 'ABC'}]}
const point = { id: 'abc', offset: 0 };
const range: { anchor: { id: 'abc', offset: 0 }, head: { id: 'abc', offset: 3 } }
|
||
- It gives a single API for all state management, which is more convenient/consistent for consumers | ||
|
||
As for which library to use, I think we should use @tanstack/store. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Duplicate
|
||
Not everything can be communicated through just state, so we need to be able to expose additional methods to the application. | ||
|
||
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). |
There was a problem hiding this comment.
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,
}>
}) {...}
There was a problem hiding this comment.
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:
BlockNote/adr/BlockNoteExtension.ts
Line 9 in 9684a7d
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 is the simplest case, and the only one that is currently supported by BlockNote. | ||
|
||
```json |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Worth mentioning that the current inline content object shape is a mess. You have StyledText
which is a different shape to Link
, which is also different to custom inline content. This makes trying to do basic things like reading plain text kind of a nightmare, and is something that we definitely need to address.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good thing to add, I don't have an answer for this atm. But feel free to write a proposal
|
||
### Rebuilding tables with nested blocks | ||
|
||
Using this new structure, we can rebuild tables if we had this new API: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This makes sense, but 2 questions:
- The consumer will have to specify when creating a block, which blocks it can be a part of or which blocks it can contain, right? And for the alert example, there will have to be some way of enforcing that each alert block must have 1 alert title followed by 1 alert description. I assume we would use something like the ProseMirror
group
andcontent
fields for this? - If you wanted to have nested blocks in the table block, would you just append
nested-block
at the end of the children? If so, is it possible to have e.g.children: [{ type: "block1", ... }, { type: "nested-block", ... }, { type: "block2", ... }]
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The consumer will have to specify when creating a block, which blocks it can be a part of or which blocks it can contain, right? And for the alert example, there will have to be some way of enforcing that each alert block must have 1 alert title followed by 1 alert description. I assume we would use something like the ProseMirror group and content fields for this?
Correct. We'd have to have prosemirror enforce this, we can decide how best to let the consumer describe this structure. I proposed groups partially for this reason:
BlockNote/adr/2025_06_13-schema.md
Lines 11 to 21 in 52ecefb
## Groups | |
In a somewhat similar vein, I think there might be use for having an indirection layer for referring to specific blocks, inline content, styles, etc. Reason being that it allows callers to refer to a group of things with a single identifier, and allow customizing that membership by just modifying that group. | |
Examples include: | |
- Keyboard shortcuts can refer to a group of blocks/inline-content/styles without modification to the handler | |
- Relationships between blocks/inline-content/styles can be defined (e.g. allow for a todo block to only have todo item children) | |
- Properties of blocks/inline-content/styles can be defined (e.g. adding `heading` to the `toggleable` group) | |
This may or may not be useful, but it is a thought. |
If you wanted to have nested blocks in the table block, would you just append nested-block at the end of the children?
Tables would be defined to only allow table-cell
s as children, so no other blocks would be allowed within a table
block. However, table-cell
s would be allowed to have arbitrary blocks (or, not if they were configured not to).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see, so this is more of a change of concept of what a nested blocks actually is I suppose. Because right now, it's an inherent property of all blocks, that they can have nested blocks that are indented below. Whereas in this new design, nested blocks are a lot more flexible, and the indented nested blocks are just a generic implementation.
Am I understanding that right?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I tried to define my terms here:
BlockNote/adr/2025_06_16-nested-blocks.md
Lines 5 to 6 in a80259f
- **nested-blocks** The first is the ability for a block to contain other blocks within it (e.g. a table cell having not just inline content, but actual other blocks inside it) | |
- **content-fields** The second is the ability for a block to contain multiple pieces of content within it (e.g. an alert block having a title & description field (which contain inline content)) |
But, I see that "nested-blocks" is indeed overloaded, take it to mean "blocks as content", maybe.
"content": null, | ||
"children": [ | ||
{ | ||
"type": "nested-block", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nested-block
is analagous to the current blockGroup
right?
|
||
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). |
There was a problem hiding this comment.
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)?
There was a problem hiding this comment.
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)
/** | ||
* This is an example of what an extension might look like | ||
*/ | ||
export function multiColumnExtension(extensionOptions: { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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".
This is to open some decisions for discussion around some core parts of the blocknote API.
There are a few proposals in here that can be seen in the ADR directory as markdown files. With some Typescript files to support how they might actually look like in an implementation.