Skip to content

Commit 52ecefb

Browse files
committed
feat: bunch of proposals
1 parent 95a72b7 commit 52ecefb

File tree

10 files changed

+1094
-0
lines changed

10 files changed

+1094
-0
lines changed

adr/2025_06_13-document-transforms.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Document Transforms
2+
3+
A core part of the BlockNote API is the ability to make changes to the document, using BlockNote's core design of blocks, this is much easier to do programmatically than with other editors.
4+
5+
We've done pretty well with our existing API, but there are a few things that could be improved.
6+
7+
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.
8+
9+
## Locations
10+
11+
[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.
12+
13+
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.
14+
15+
## Transforms separation of concerns
16+
17+
Right now all transformation functions are defined directly on the `Editor` instance, this is not ideal because it only further muddles the API.
18+
19+
Instead, we should have a separate `Transform` class which defines methods that operate on the editor's document to make changes to it. This will also be very useful for doing server-side transformations.
20+

adr/2025_06_13-extensions.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# BlockNote Extension API
2+
3+
It is definitely more designed by accident than intention, so let's put some thought into the sort of API we would want people to build extensions with.
4+
5+
## Core Requirements
6+
7+
What I identified was:
8+
9+
- 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.
10+
- A unified store API, to make consuming extension state homogenous, this will be discussed further below
11+
- 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.
12+
- Editing event handlers, by providing hooks for `change`, `selectionChange`, `beforeChange`, and `transaction` we give extensions access to the fundamental primitives of the editor.
13+
14+
## State Management
15+
16+
What I had the most trepidation about was deciding on whether we should prescribe a state management library, and if so, which one.
17+
18+
I think the answer is _yes_, we should prescribe a state management library, and I think the answer is @tanstack/store.
19+
20+
<details>
21+
<summary>Why @tanstack/store? And not something else?</summary>
22+
It comes with a few benefits:
23+
24+
- It gives a single API for all state management, which is more convenient/consistent for consumers
25+
26+
As for which library to use, I think we should use @tanstack/store.
27+
28+
- The store is very simple, and can easily be re-implemented if needed
29+
- There is already bindings for most major frameworks, but can be used without them
30+
- It seems that anything tanstack does will be widely adopted so it should be a pretty safe bet
31+
32+
What I had trouble with is there are a few different use-cases for state management, events like we have now aren't great because they put the burden on the consumer to manage the state. Or, they can emit an event (e.g. `update`), but then have to round-trip back to the extension to get the state (and somehow store it on their side again).
33+
34+
Something like observables have a nicer API for this, but they are for pushing data (i.e. multiple readers), not for pulling it (i.e. any writers). They also have the same problem of putting the burden on the consumer.
35+
36+
Signals are a nice middle ground, being that they are for both pushing & pulling data. The problem is that there are many implementations, and not super well-known in the React ecosystem.
37+
38+
Zustand is a popular library, but allowing partial states makes it somewhat unsafe in TypeScript.
39+
40+
Jotai is probably my second choice, but it makes it a bit awkward to update states because it relies on a separate store instance rather than the "atom" being able to update itself <https://jotai.org/docs/guides/using-store-outside-react>.
41+
</details>

adr/2025_06_13-schema.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# BlockNote Schema
2+
3+
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).
4+
5+
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).
6+
7+
You'll see some of what I came up with in the `Schema.ts` file.
8+
9+
You'll also notice that the default blocks, inline content, styles, and groups are all defined in the `@blocknote/core/blocks` package. This is assuming that we have already moved them to the blocknote API and out of the core package.
10+
11+
## Groups
12+
13+
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.
14+
15+
Examples include:
16+
17+
- Keyboard shortcuts can refer to a group of blocks/inline-content/styles without modification to the handler
18+
- Relationships between blocks/inline-content/styles can be defined (e.g. allow for a todo block to only have todo item children)
19+
- Properties of blocks/inline-content/styles can be defined (e.g. adding `heading` to the `toggleable` group)
20+
21+
This may or may not be useful, but it is a thought.

adr/BlockNoteExtension.ts

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import {
2+
BlockNoteEditor,
3+
BlocksChanged,
4+
Schema,
5+
Selection,
6+
} from "@blocknote/core";
7+
import { Store } from "@tanstack/store";
8+
import { Plugin, Transaction } from "prosemirror-state";
9+
10+
/**
11+
* This is an abstract class to make it easier to implement an extension using a class.
12+
*/
13+
export abstract class BlockNoteExtension<
14+
State,
15+
BSchema extends Schema = Schema,
16+
> {
17+
public key = "not-implemented";
18+
public store?: Store<State>;
19+
public priority?: number;
20+
public plugins?: Plugin[];
21+
public keyboardShortcuts?: Record<
22+
string,
23+
(context: ExtensionContext<BSchema>) => boolean
24+
>;
25+
public onCreate?: (context: ExtensionContext<BSchema>) => void;
26+
public onMount?: (context: ExtensionContext<BSchema>) => void;
27+
public onUnmount?: (context: ExtensionContext<BSchema>) => void;
28+
public onChange?: (
29+
context: ExtensionContext<BSchema> & {
30+
getChanges: () => BlocksChanged;
31+
},
32+
) => void;
33+
public onSelectionChange?: (
34+
context: ExtensionContext<BSchema> & {
35+
getSelection: () => Selection<any, any, any> | undefined;
36+
},
37+
) => void;
38+
public onBeforeChange?: (
39+
context: ExtensionContext<BSchema> & {
40+
getChanges: () => BlocksChanged;
41+
tr: Transaction;
42+
},
43+
) => boolean | void;
44+
public onTransaction?: (
45+
context: ExtensionContext<BSchema> & {
46+
tr: Transaction;
47+
},
48+
) => void;
49+
}
50+
51+
export interface BlockNoteExtension<State, BSchema extends Schema = Schema> {
52+
/**
53+
* The name of this extension, must be unique
54+
*/
55+
key: string;
56+
/**
57+
* The state of the extension, this is a @tanstack/store store instance
58+
*/
59+
store?: Store<State>;
60+
/**
61+
* The priority of this extension, used to determine the order in which extensions are applied
62+
*/
63+
priority?: number;
64+
/**
65+
* The plugins of the extension
66+
*/
67+
plugins?: Plugin[];
68+
/**
69+
* Keyboard shortcuts this extension adds to the editor.
70+
* The key is the keyboard shortcut, and the value is a function that returns a boolean indicating whether the shortcut was handled.
71+
* If the function returns `true`, the shortcut is considered handled and will not be passed to other extensions.
72+
* If the function returns `false`, the shortcut will be passed to other extensions.
73+
*/
74+
keyboardShortcuts?: Record<
75+
string,
76+
(context: ExtensionContext<BSchema>) => boolean
77+
>;
78+
79+
/**
80+
* Called on initialization of the editor
81+
* @note the view is not yet mounted at this point
82+
*/
83+
onCreate?: (context: ExtensionContext<BSchema>) => void;
84+
85+
/**
86+
* Called when the editor is mounted
87+
* @note the view is available
88+
*/
89+
onMount?: (context: ExtensionContext<BSchema>) => void;
90+
91+
/**
92+
* Called when the editor is unmounted
93+
* @note the view will no longer be available after this is executed
94+
*/
95+
onUnmount?: (context: ExtensionContext<BSchema>) => void;
96+
97+
/**
98+
* Called when an editor transaction is applied
99+
*/
100+
onTransaction?: (
101+
context: ExtensionContext<BSchema> & {
102+
tr: Transaction;
103+
},
104+
) => void;
105+
106+
/**
107+
* Called when the editor content changes
108+
* @note the changes are available
109+
*/
110+
onChange?: (
111+
context: ExtensionContext<BSchema> & {
112+
getChanges: () => BlocksChanged;
113+
},
114+
) => void;
115+
116+
/**
117+
* Called when the selection changes
118+
* @note the selection is available
119+
*/
120+
onSelectionChange?: (
121+
context: ExtensionContext<BSchema> & {
122+
getSelection: () => Selection<any, any, any> | undefined;
123+
},
124+
) => void;
125+
126+
/**
127+
* Called before an editor change is applied,
128+
* Allowing the extension to cancel the change
129+
*/
130+
onBeforeChange?: (
131+
context: ExtensionContext<BSchema> & {
132+
getChanges: () => BlocksChanged;
133+
tr: Transaction;
134+
},
135+
) => boolean | void;
136+
}
137+
138+
export interface ExtensionContext<BSchema extends Schema> {
139+
editor: BlockNoteEditor<BSchema>;
140+
}
141+
142+
/**
143+
* This is the class-form, where it can extend the abstract class
144+
*/
145+
export class MyExtension extends BlockNoteExtension<{ abc: number[] }> {
146+
public key = "my-extension";
147+
public store = new Store({ abc: [1, 2, 3] });
148+
149+
constructor(_extensionOptions: { myCustomOption: string }) {
150+
super();
151+
}
152+
}
153+
154+
/**
155+
* This is the object-form, where it can be just a function that returns an object that implements the interface
156+
*/
157+
export function myExtension(_extensionOptions: {
158+
myCustomOption: string;
159+
}): BlockNoteExtension<{ state: number }> {
160+
const myState = new Store({ state: 0 });
161+
return {
162+
key: "my-extension",
163+
store: myState,
164+
onMount(context) {
165+
context.editor.extensions.myExtension = this;
166+
myState.setState({ state: 1 });
167+
},
168+
};
169+
}

adr/Schema.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { Schema } from "@blocknote/core";
2+
import {
3+
defaultBlocks,
4+
defaultInlineContent,
5+
defaultStyles,
6+
defaultGroups,
7+
} from "@blocknote/core/blocks";
8+
9+
type Schema = {
10+
blocks: Record<string, BlockSchema>;
11+
inlineContent: Record<string, InlineContentSchema>;
12+
styles: Record<string, StyleSchema>;
13+
groups: Record<string, Set<string>>;
14+
} & {
15+
// Some sort of a type predicate to make sure the block is in the schema, and type better if it is
16+
hasBlock(
17+
editor: BlockNoteEditor<Schema>,
18+
block: string,
19+
): editor is BlockNoteEditor<Schema>;
20+
// Other predicates
21+
hasInlineContent: (inlineContent: string) => boolean;
22+
hasStyle: (style: string) => boolean;
23+
getGroup: (group: string) => string[];
24+
// etc
25+
};
26+
27+
// One thing, instead of 3!
28+
// Pass around just this single type.
29+
// If we need each type explicitly, we can do something like:
30+
// type Schema = [BlockSchema, InlineContentSchema, StyleSchema]
31+
// And destructure if needed
32+
export const schema = Schema.create({
33+
/**
34+
* Which blocks are in my editor?
35+
*/
36+
blocks: {
37+
...defaultBlocks,
38+
// todoList:
39+
},
40+
/**
41+
* Which inline content is in my editor?
42+
*/
43+
inlineContent: {
44+
...defaultInlineContent,
45+
// todoItem:
46+
},
47+
/**
48+
* Which styles are in my editor?
49+
*/
50+
styles: {
51+
...defaultStyles,
52+
},
53+
/**
54+
* Which groups are in my editor?
55+
*
56+
* A group is a set of editor blocks/inline-content/styles that are related to each other in some way.
57+
* This allows for referring to a bunch of blocks/inline-content/styles at once.
58+
*
59+
* This is useful for things like:
60+
* - Keyboard shortcuts can refer to a group of blocks/inline-content/styles without modification to the handler
61+
* - relationships between blocks/inline-content/styles can be defined (e.g. allow for a todo block to only have todo item children)
62+
*/
63+
groups: {
64+
...defaultGroups,
65+
todoList: new Set(["todoList", "todoItem"]),
66+
},
67+
});
68+
69+
// This instance would live under the editor instance and needed for instantiating the editor
70+
editor.schema = schema;

packages/core/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@
7676
"dependencies": {
7777
"@emoji-mart/data": "^1.2.1",
7878
"@shikijs/types": "3.2.1",
79+
"@tanstack/store": "0.7.1",
7980
"@tiptap/core": "^2.12.0",
8081
"@tiptap/extension-bold": "^2.11.5",
8182
"@tiptap/extension-code": "^2.11.5",
@@ -91,6 +92,7 @@
9192
"@tiptap/extension-text": "^2.11.5",
9293
"@tiptap/extension-underline": "^2.11.5",
9394
"@tiptap/pm": "^2.12.0",
95+
"alien-signals": "2.0.5",
9496
"emoji-mart": "^5.6.0",
9597
"hast-util-from-dom": "^5.0.1",
9698
"prosemirror-dropcursor": "^1.8.1",
@@ -110,6 +112,7 @@
110112
"remark-stringify": "^11.0.0",
111113
"unified": "^11.0.5",
112114
"uuid": "^8.3.2",
115+
"valtio": "2.1.5",
113116
"y-prosemirror": "^1.3.4",
114117
"y-protocols": "^1.0.6",
115118
"yjs": "^13.6.15"

0 commit comments

Comments
 (0)