-
-
Notifications
You must be signed in to change notification settings - Fork 563
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?
Changes from 4 commits
52ecefb
24517fb
536ea9d
a80259f
ac54f67
966692d
9684a7d
fc6ac01
daa62dd
aff3cbb
e066c85
329e5a7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 commentThe 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). | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Isn't There was a problem hiding this comment. Choose a reason for hiding this commentThe 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> |
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. |
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?: ( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 commentThe 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: { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 commentThe 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[]>; | ||
} |
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); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I toyed with calling this 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 commentThe reason will be displayed to describe this comment to others. Learn more. Yeah I'm also not 100% convinced about There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
|
||
// 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 | ||
} |
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:
Uh oh!
There was an error while loading. Please reload this page.
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
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.