Skip to content

Commit 9684a7d

Browse files
committed
feat: concept of bundles vs. extensions
1 parent 966692d commit 9684a7d

File tree

3 files changed

+120
-132
lines changed

3 files changed

+120
-132
lines changed

adr/2025_06_13-extensions.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,7 @@ What I identified was:
88

99
- 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.
1010
- 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.
11+
- 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.
1312

1413
## State Management
1514

@@ -47,3 +46,15 @@ Not everything can be communicated through just state, so we need to be able to
4746
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).
4847

4948
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).
49+
50+
# BlockNote Bundles
51+
52+
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.)
53+
54+
## Core Requirements
55+
56+
- A way to add blocks, inline content, and styles to the editor
57+
- A way to add extensions to the editor
58+
- A way to add to the dictionary of locales to the editor
59+
- A way to add to the slash menu
60+
- A way to add to the formatting toolbar

adr/BlockNoteBundle.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { BlockNoteBundle } from "./BlockNoteExtension";
2+
3+
export interface BlockNoteBundleConfig<
4+
Schema,
5+
Extensions extends BlockNoteExtension<any>[],
6+
> {
7+
/**
8+
* The prosemirror plugins of the extension
9+
*/
10+
extensions?: Extensions;
11+
12+
/**
13+
* The schema that this extension adds to the editor
14+
*/
15+
schema?: BlockNoteSchema<Schema>;
16+
17+
/**
18+
* Add a dictionary of locales to the editor
19+
*/
20+
dictionary?: Record<string, Locale>;
21+
22+
/**
23+
* Items that are available to the slash menu
24+
*/
25+
slashMenuItems?: (
26+
| {
27+
name: string;
28+
icon?: any;
29+
onClick: () => void;
30+
}
31+
// or return a component
32+
| (() => any)
33+
)[];
34+
35+
/**
36+
* Items that are available to the formatting toolbar
37+
*/
38+
formattingToolbar?: (
39+
| {
40+
name: string;
41+
icon?: any;
42+
onClick: () => void;
43+
}
44+
// or return a component
45+
| (() => any)
46+
)[];
47+
}
48+
49+
export type Locale = Record<string, string | Record<string, string>>;
50+
51+
/**
52+
* This is an example of what an extension might look like
53+
*/
54+
export function multiColumnExtension(extensionOptions: {
55+
dropCursor?: Record<string, string>;
56+
columnResize?: Record<string, string>;
57+
}) {
58+
return (editor) =>
59+
({
60+
schema: withMultiColumnSchema(editor.schema),
61+
extensions: [
62+
multiColumnDropCursor(editor),
63+
columnResizeExtension(editor),
64+
],
65+
slashMenuItems: [
66+
{
67+
name: "2 Column",
68+
icon: {},
69+
onClick: () => {},
70+
},
71+
],
72+
formattingToolbar: [
73+
{
74+
name: "Multi Column",
75+
icon: {},
76+
onClick: () => {},
77+
},
78+
],
79+
}) satisfies BlockNoteBundleConfig<any, any[]>;
80+
}

adr/BlockNoteExtension.ts

Lines changed: 27 additions & 130 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,12 @@
1-
import {
2-
BlockNoteEditor,
3-
BlocksChanged,
4-
Schema,
5-
Selection,
6-
} from "@blocknote/core";
1+
import { BlockNoteEditor } from "@blocknote/core";
72
import { Store } from "@tanstack/store";
8-
import { Plugin, Transaction } from "prosemirror-state";
3+
import { Plugin } from "prosemirror-state";
94

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-
}
5+
export type BlockNoteExtension<State> = (
6+
editor: BlockNoteEditor,
7+
) => BlockNoteExtensionConfig<State>;
508

51-
export interface BlockNoteExtension<State, BSchema extends Schema = Schema> {
9+
export interface BlockNoteExtensionConfig<State> {
5210
/**
5311
* The name of this extension, must be unique
5412
*/
@@ -62,7 +20,7 @@ export interface BlockNoteExtension<State, BSchema extends Schema = Schema> {
6220
*/
6321
priority?: number;
6422
/**
65-
* The plugins of the extension
23+
* The prosemirror plugins of the extension
6624
*/
6725
plugins?: Plugin[];
6826
/**
@@ -71,118 +29,57 @@ export interface BlockNoteExtension<State, BSchema extends Schema = Schema> {
7129
* If the function returns `true`, the shortcut is considered handled and will not be passed to other extensions.
7230
* If the function returns `false`, the shortcut will be passed to other extensions.
7331
*/
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;
32+
keyboardShortcuts?: Record<string, (context) => boolean>;
8433

8534
/**
8635
* Called when the editor is mounted
8736
* @note the view is available
8837
*/
89-
onMount?: (context: ExtensionContext<BSchema>) => void;
38+
onMount?: () => void;
9039

9140
/**
9241
* Called when the editor is unmounted
9342
* @note the view will no longer be available after this is executed
9443
*/
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-
getFoo() {
154-
return 8;
155-
}
44+
onUnmount?: () => void;
15645
}
15746

15847
/**
15948
* This is the object-form, where it can be just a function that returns an object that implements the interface
16049
*/
161-
export function myExtension(_extensionOptions: {
162-
myCustomOption: string;
163-
}): BlockNoteExtension<{ state: number }> {
50+
export function myExtension(_extensionOptions: { myCustomOption: string }) {
16451
const myState = new Store({ state: 0 });
165-
return {
52+
return ((editor) => ({
16653
key: "my-extension",
16754
store: myState,
168-
onMount(context) {
169-
context.editor.extensions.myExtension = this;
55+
onMount() {
56+
editor.onChange((change) => {
57+
console.log(change);
58+
});
17059
myState.setState({ state: 1 });
17160
},
172-
};
61+
getFoo() {
62+
return 8;
63+
},
64+
})) satisfies BlockNoteExtension<{ state: number }>;
17365
}
17466

17567
/**
17668
* This type exposes the public API of an extension, excluding any {@link BlockNoteExtension} methods (for cleaner typing)
17769
*/
17870
export type ExtensionMethods<Extension extends BlockNoteExtension<any>> =
17971
Extension extends BlockNoteExtension<infer State>
180-
? Omit<Extension, Exclude<keyof BlockNoteExtension<State>, "store" | "key">>
72+
? Omit<
73+
ReturnType<Extension>,
74+
Exclude<keyof BlockNoteExtensionConfig<State>, "store" | "key">
75+
>
18176
: never;
18277

18378
/**
18479
* You'll notice that the `getFoo` method is the only included type in the `MyExtensionMethods` type,
18580
* This makes it convenient to expose the right amount of details to the rest of the application (keeping the blocknote called methods hidden)
18681
*/
187-
export type MyExtensionMethods = ExtensionMethods<MyExtension>;
82+
export type MyExtensionMethods = ExtensionMethods<
83+
ReturnType<typeof myExtension>
84+
>;
18885
// editor.extensions.myExtension.getFoo();

0 commit comments

Comments
 (0)