diff --git a/adr/2025_06_13-document-transforms.md b/adr/2025_06_13-document-transforms.md new file mode 100644 index 000000000..b627c450c --- /dev/null +++ b/adr/2025_06_13-document-transforms.md @@ -0,0 +1,65 @@ +# Document Transforms + +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. + +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. + +## Locations + +[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. + +## 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. + +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. + +```ts +editor.transform.insertBlocks(ctx:{ + at: Location, + blocks: Block | Block[] +}); + +editor.transform.replaceBlocks(ctx: { + at: Location, + with: Block | Block[] +}) + +editor.transform.replaceContent(ctx: { + at: Location, + with: InlineContent | InlineContent[] +}) + +editor.transform.deleteContent(ctx: { + at: Location, +}) +``` + +## References + +Currently, we do not have a good way to reference things about content within a document, except by block id. With Locations, we can be much more granular & more powerful. + +I think one application of this would be to introduce the concept of "references" to content within a document. For example, we currently do not store comments into the blocknote json, taking the position that it really is not part of the document, but rather metadata about the document. + +With references, we could store comments with references to the blocks that they are about. And map those references through changes if they are ever modified. For example, if someone commented on the text within block id `123`, and then the block was moved to a new location, we could update the comment to reference the new block id. + +So, this would allow a comment to still be a separate entity, but be able to "hydrate" within the editor and keep track of the content that it was about. + +```ts +type Reference> = { + to: Location, + metadata: Metadata +} + +type Comment = Reference<{ + threadId: string +}> + +editor.references.add(reference: Reference, onUpdate: (reference: Reference) => void); +``` + +This is inspired by [bluesky's concept of facets for rich text content](https://docs.bsky.app/docs/advanced-guides/post-richtext) which is a great example of how to make block note content more inter-operable between different applications. diff --git a/adr/2025_06_13-extensions.md b/adr/2025_06_13-extensions.md new file mode 100644 index 000000000..ead558d78 --- /dev/null +++ b/adr/2025_06_13-extensions.md @@ -0,0 +1,60 @@ +# BlockNote Extension API + +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. + +## Core Requirements + +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 `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 + +What I had the most trepidation about was deciding on whether we should prescribe a state management library, and if so, which one. + +I think the answer is _yes_, we should prescribe a state management library, and I think the answer is @tanstack/store. + +
+Why @tanstack/store? And not something else? +It comes with a few benefits: + +- 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. + +- The store is very simple, and can easily be re-implemented if needed +- There is already bindings for most major frameworks, but can be used without them +- It seems that anything tanstack does will be widely adopted so it should be a pretty safe bet + +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). + +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. + +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. + +Zustand is a popular library, but allowing partial states makes it somewhat unsafe in TypeScript. + +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 . +
+ +## Exposing extension methods + +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). + +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 diff --git a/adr/2025_06_13-packages.md b/adr/2025_06_13-packages.md new file mode 100644 index 000000000..7e1511bff --- /dev/null +++ b/adr/2025_06_13-packages.md @@ -0,0 +1,36 @@ +# BlockNote Packages & Sub-Packages + +## Core + +The `@blocknote/core` package should contain everything needed to build a pure JS editor instance. It has no opinion on the UI layer, and is only concerned with the core editor functionality. + +It contains sub-packages for: + +- `@blocknote/core/blocks` - Contains the default blocks, inline content, styles and their implementations (excluding things like blockContainer, blockGroup, etc which are core to the editor but should not be part of the schema). + - `@blocknote/core/blocks/header` - Contains the header block. + - `@blocknote/core/blocks/code-block` - Contains the code block block. + - etc... +- `@blocknote/core/extensions` - Re-exports the extension packages below. + - `@blocknote/core/extensions/comments` - Contains the comments extension. +- `@blocknote/core/exporter` - Contains the exporter package. + - `@blocknote/core/exporter/html` - Contains the html exporter package. + - `@blocknote/core/exporter/markdown` - Contains the markdown exporter package. +- `@blocknote/core/server-util` - Contains the server util package. +- `@blocknote/core/locales` - Contains the locales package. + +## React + +The `@blocknote/react` package should contain everything needed to build a React editor instance. It has no opinion on the core editor functionality, and is only concerned with the React UI layer. + +It contains sub-packages for: + +- `@blocknote/react/editor` - Contains the editor package. + - `@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. diff --git a/adr/2025_06_13-schema.md b/adr/2025_06_13-schema.md new file mode 100644 index 000000000..3789f78a9 --- /dev/null +++ b/adr/2025_06_13-schema.md @@ -0,0 +1,21 @@ +# BlockNote Schema + +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). + +You'll see some of what I came up with in the `Schema.ts` file. + +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. + +## 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. diff --git a/adr/2025_06_16-configuration.md b/adr/2025_06_16-configuration.md new file mode 100644 index 000000000..a91abeaf5 --- /dev/null +++ b/adr/2025_06_16-configuration.md @@ -0,0 +1,28 @@ +# BlockNote Configuration + +Editors are widely different across applications, often with users having opposing requirements. Most editors explode with complexity when trying to support all of these use cases. So, there needs to be a guide for the different ways to configure so that there is not just a single overwhelming list of options. + +Fundamentally, there are few different kinds of things to be configured: + +- **block-configuration**: The configuration for a specific kind of block + - e.g. the `table` block might have toggles for enabling/disabling the header rows/columns +- **schema-configuration**: The configuration of what blocks, inline content, and styles are available in the editor + - e.g. whether to include the `table` block at all +- **extension-level-configuration**: The configuration of the extension itself + - e.g. what ydoc should the collaboration extension use +- **extension-configuration**: The configuration of the extensions that are available in the editor + - e.g. whether to add collaboration to the editor +- **editor-view-configuration**: The configuration of the editor views + - e.g. whether to show the sidebar, the toolbar, etc. +- **editor-configuration**: The configuration of the editor itself + - e.g. how to handle paste events + +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. + +Configuration should be publicly accessible, so that mixed combinations can be created (i.e. different behaviors for an editor with or without collaboration). + +## TODO + +- Describe how you configure at the block level, then compose that into a schema, the compose that into an editor, and then compose that into a view. diff --git a/adr/2025_06_16-nested-blocks.md b/adr/2025_06_16-nested-blocks.md new file mode 100644 index 000000000..c6132a172 --- /dev/null +++ b/adr/2025_06_16-nested-blocks.md @@ -0,0 +1,155 @@ +# BlockNote Nested Blocks + +There are two separate problems when it comes to "nested blocks" support in BlockNote: + +- **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)) + +Let's start with the first problem, nested blocks. By describing existing block relationships: + +## Block with inline content + +This is the simplest case, and the only one that is currently supported by BlockNote. + +```json +{ + "type": "block-type", + "content": [ + { + "type": "text", + "text": "Hello, world!" + } + ], +} +``` + +There is a 1:1 relationship between the block type and it's content. And, no restrictions of the inline content allowed within the block. + +> TODO come up with a description of the sorts of keybinds & cursor behavior that should be supported for this block type + +## Block with nested blocks + +This is a proposal for how to support nested blocks. + +```json +{ + "type": "custom-block-type", + "props": { + "abc": 123 + }, + "content": null, + "children": [ + { + "type": "nested-block", + "content": [ + { + "type": "block", + "content": [ + { + "type": "text", + "text": "Hello, world!" + } + ] + } + ] + } + ] +} +``` + +This would completely enclose all nested blocks within the `custom-block-type` block. And, works the same way as the multi-column example. + +> TODO come up with a description of the sorts of keybinds & cursor behavior that should be supported for this block type + +## Block with structured inline content fields + +This is a proposal for how to support multiple inline content fields within a block. + +```json +{ + "type": "alert", + "content": null, + "children": [ + { + "type": "alert-title", + "content": [ + { + "type": "text", + "text": "Hello, world!" + } + ] + }, + { + "type": "alert-content", + "content": [ + { + "type": "text", + "text": "Hello, world!" + } + ] + } + ] +} +``` + +The core idea is that the `parent` block restricts what content is allowed within it's children. + +> TODO come up with a description of the sorts of keybinds & cursor behavior that should be supported for this block type + +## Examples + +### Rebuilding tables with nested blocks + +Using this new structure, we can rebuild tables if we had this new API: + +```json +{ + "type": "table", + "content": null, + "props": { + "backgroundColor": "default", + "textColor": "default", + "columnWidths": [100, 100, 100], + "headerRows": 1, + "headerCols": 1, + }, + "children": [ + { + "type": "table-row", + "content": null, + "children": [ + { + "type": "table-cell", + "content": null, + "children": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Hello, world!" + } + ] + } + ] + }, + { + "type": "table-cell", + "content": null, + "children": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Hello, world!" + } + ] + } + ] + } + ] + } + ] +} +``` diff --git a/adr/2025_06_17-formats.md b/adr/2025_06_17-formats.md new file mode 100644 index 000000000..3c51e3221 --- /dev/null +++ b/adr/2025_06_17-formats.md @@ -0,0 +1,59 @@ +# BlockNote Formats + +Right now, there are several formats supported by BlockNote: + +- 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 RenderedHTML = string; // What renders to the editor in the DOM +function toRenderedHTML(model: Model): RenderedHTML; +function fromRenderedHTML(html: RenderedHTML): 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). + +## 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: diff --git a/adr/2025_06_18-ui-components.md b/adr/2025_06_18-ui-components.md new file mode 100644 index 000000000..b2a401375 --- /dev/null +++ b/adr/2025_06_18-ui-components.md @@ -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. \ No newline at end of file diff --git a/adr/BlockNoteBundle.ts b/adr/BlockNoteBundle.ts new file mode 100644 index 000000000..42611db9b --- /dev/null +++ b/adr/BlockNoteBundle.ts @@ -0,0 +1,80 @@ +import { BlockNoteBundle } from "./BlockNoteExtension"; + +export interface BlockNoteBundleConfig< + Schema, + Extensions extends BlockNoteExtension[], +> { + /** + * The prosemirror plugins of the extension + */ + extensions?: Extensions; + + /** + * The schema that this extension adds to the editor + */ + schema?: BlockNoteSchema; + + /** + * Add a dictionary of locales to the editor + */ + dictionary?: Record; + + /** + * Items that are available to the slash menu + */ + slashMenuItems?: ( + | { + 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>; + +/** + * This is an example of what an extension might look like + */ +export function multiColumnExtension(extensionOptions: { + dropCursor?: Record; + columnResize?: Record; +}) { + 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; +} diff --git a/adr/BlockNoteEditor.ts b/adr/BlockNoteEditor.ts new file mode 100644 index 000000000..ce345912e --- /dev/null +++ b/adr/BlockNoteEditor.ts @@ -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: (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); + + // 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; + /** + * 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 +} diff --git a/adr/BlockNoteExtension.ts b/adr/BlockNoteExtension.ts new file mode 100644 index 000000000..2030790df --- /dev/null +++ b/adr/BlockNoteExtension.ts @@ -0,0 +1,85 @@ +import { BlockNoteEditor } from "@blocknote/core"; +import { Store } from "@tanstack/store"; +import { Plugin } from "prosemirror-state"; + +export type BlockNoteExtension = ( + editor: BlockNoteEditor, +) => BlockNoteExtensionConfig; + +export interface BlockNoteExtensionConfig { + /** + * The name of this extension, must be unique + */ + key: string; + /** + * The state of the extension, this is a @tanstack/store store instance + */ + store?: Store; + /** + * The priority of this extension, used to determine the order in which extensions are applied + */ + priority?: number; + /** + * The prosemirror plugins of the extension + */ + plugins?: Plugin[]; + /** + * Keyboard shortcuts this extension adds to the editor. + * The key is the keyboard shortcut, and the value is a function that returns a boolean indicating whether the shortcut was handled. + * If the function returns `true`, the shortcut is considered handled and will not be passed to other extensions. + * If the function returns `false`, the shortcut will be passed to other extensions. + */ + keyboardShortcuts?: Record boolean>; + + /** + * Called when the editor is mounted + * @note the view is available + */ + onMount?: () => void; + + /** + * Called when the editor is unmounted + * @note the view will no longer be available after this is executed + */ + onUnmount?: () => void; +} + +/** + * This is the object-form, where it can be just a function that returns an object that implements the interface + */ +export function myExtension(_extensionOptions: { myCustomOption: string }) { + const myState = new Store({ state: 0 }); + return ((editor) => ({ + key: "my-extension", + store: myState, + onMount() { + editor.onChange((change) => { + console.log(change); + }); + myState.setState({ state: 1 }); + }, + getFoo() { + return 8; + }, + })) satisfies BlockNoteExtension<{ state: number }>; +} + +/** + * This type exposes the public API of an extension, excluding any {@link BlockNoteExtension} methods (for cleaner typing) + */ +export type ExtensionMethods> = + Extension extends BlockNoteExtension + ? Omit< + ReturnType, + Exclude, "store" | "key"> + > + : never; + +/** + * You'll notice that the `getFoo` method is the only included type in the `MyExtensionMethods` type, + * This makes it convenient to expose the right amount of details to the rest of the application (keeping the blocknote called methods hidden) + */ +export type MyExtensionMethods = ExtensionMethods< + ReturnType +>; +// editor.extensions.myExtension.getFoo(); diff --git a/adr/Schema.ts b/adr/Schema.ts new file mode 100644 index 000000000..a3256cfac --- /dev/null +++ b/adr/Schema.ts @@ -0,0 +1,70 @@ +import { Schema } from "@blocknote/core"; +import { + defaultBlocks, + defaultInlineContent, + defaultStyles, + defaultGroups, +} from "@blocknote/core/blocks"; + +type Schema = { + blocks: Record; + inlineContent: Record; + styles: Record; + groups: Record>; +} & { + // Some sort of a type predicate to make sure the block is in the schema, and type better if it is + hasBlock( + editor: BlockNoteEditor, + block: string, + ): editor is BlockNoteEditor; + // Other predicates + hasInlineContent: (inlineContent: string) => boolean; + hasStyle: (style: string) => boolean; + getGroup: (group: string) => string[]; + // etc +}; + +// One thing, instead of 3! +// Pass around just this single type. +// If we need each type explicitly, we can do something like: +// type Schema = [BlockSchema, InlineContentSchema, StyleSchema] +// And destructure if needed +export const schema = Schema.create({ + /** + * Which blocks are in my editor? + */ + blocks: { + ...defaultBlocks, + // todoList: + }, + /** + * Which inline content is in my editor? + */ + inlineContent: { + ...defaultInlineContent, + // todoItem: + }, + /** + * Which styles are in my editor? + */ + styles: { + ...defaultStyles, + }, + /** + * Which groups are in my editor? + * + * A group is a set of editor blocks/inline-content/styles that are related to each other in some way. + * This allows for referring to a bunch of blocks/inline-content/styles at once. + * + * This is useful for things like: + * - 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) + */ + groups: { + ...defaultGroups, + todoList: new Set(["todoList", "todoItem"]), + }, +}); + +// This instance would live under the editor instance and needed for instantiating the editor +editor.schema = schema; diff --git a/package.json b/package.json index 130ec226c..4c3de03c8 100644 --- a/package.json +++ b/package.json @@ -54,5 +54,8 @@ "start": "serve playground/dist -c ../serve.json", "test": "nx run-many --target=test", "format": "prettier --write \"**/*.{js,jsx,ts,tsx,css,scss,md}\"" + }, + "dependencies": { + "@tanstack/store": "0.7.1" } } diff --git a/packages/core/package.json b/packages/core/package.json index 212054e9b..0b0a44ae0 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -76,6 +76,7 @@ "dependencies": { "@emoji-mart/data": "^1.2.1", "@shikijs/types": "3.2.1", + "@tanstack/store": "0.7.1", "@tiptap/core": "^2.12.0", "@tiptap/extension-bold": "^2.11.5", "@tiptap/extension-code": "^2.11.5", @@ -91,6 +92,7 @@ "@tiptap/extension-text": "^2.11.5", "@tiptap/extension-underline": "^2.11.5", "@tiptap/pm": "^2.12.0", + "alien-signals": "2.0.5", "emoji-mart": "^5.6.0", "hast-util-from-dom": "^5.0.1", "prosemirror-dropcursor": "^1.8.1", @@ -110,6 +112,7 @@ "remark-stringify": "^11.0.0", "unified": "^11.0.5", "uuid": "^8.3.2", + "valtio": "2.1.5", "y-prosemirror": "^1.3.4", "y-protocols": "^1.0.6", "yjs": "^13.6.15" diff --git a/packages/core/src/editor/Location.test.ts b/packages/core/src/editor/Location.test.ts new file mode 100644 index 000000000..78157564e --- /dev/null +++ b/packages/core/src/editor/Location.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, it } from "vitest"; + +import { getBlockAtPath, getBlocks } from "./Location.js"; +import { Block } from "../blocks/defaultBlocks.js"; + +const document: Block[] = [ + { + id: "1", + type: "paragraph", + props: { + textAlignment: "left", + textColor: "default", + backgroundColor: "default", + }, + content: [], + children: [ + { + id: "2", + type: "paragraph", + props: { + textAlignment: "left", + textColor: "default", + backgroundColor: "default", + }, + content: [], + children: [], + }, + ], + }, + { + id: "3", + type: "paragraph", + props: { + textAlignment: "left", + textColor: "default", + backgroundColor: "default", + }, + content: [], + children: [], + }, + { + id: "4", + type: "paragraph", + props: { + textAlignment: "left", + textColor: "default", + backgroundColor: "default", + }, + content: [], + children: [ + { + id: "5", + type: "paragraph", + props: { + textAlignment: "left", + textColor: "default", + backgroundColor: "default", + }, + content: [], + children: [], + }, + { + id: "6", + type: "paragraph", + props: { + textAlignment: "left", + textColor: "default", + backgroundColor: "default", + }, + content: [], + children: [], + }, + ], + }, +]; + +describe("getBlockAtPath", () => { + it("gets the whole document", () => { + expect(getBlockAtPath([], document)).toEqual(document); + }); + + it("gets a block at a path", () => { + expect(getBlockAtPath(["1", "2"], document)).toEqual([ + { + children: [], + content: [], + id: "2", + props: { + backgroundColor: "default", + textAlignment: "left", + textColor: "default", + }, + type: "paragraph", + }, + ]); + }); + + it("gets all children of a block", () => { + expect(getBlockAtPath(["1"], document)).toEqual([ + { + children: [], + content: [], + id: "2", + props: { + backgroundColor: "default", + textAlignment: "left", + textColor: "default", + }, + type: "paragraph", + }, + ]); + expect(getBlockAtPath(["4"], document)).toEqual([ + { + id: "5", + type: "paragraph", + props: { + textAlignment: "left", + textColor: "default", + backgroundColor: "default", + }, + content: [], + children: [], + }, + { + id: "6", + type: "paragraph", + props: { + textAlignment: "left", + textColor: "default", + backgroundColor: "default", + }, + content: [], + children: [], + }, + ]); + }); + + it("accepts block identifiers", () => { + expect(getBlockAtPath([{ id: "1" }], document)).toEqual( + document[0].children, + ); + expect(getBlockAtPath([{ id: "1" }, { id: "2" }], document)).toEqual([ + { + children: [], + content: [], + id: "2", + props: { + backgroundColor: "default", + textAlignment: "left", + textColor: "default", + }, + type: "paragraph", + }, + ]); + }); + + it("returns empty array if the path is invalid", () => { + expect(getBlockAtPath(["1", "4"], document)).toEqual([]); + expect(getBlockAtPath(["1", "2", "3"], document)).toEqual([]); + }); +}); + +describe("getBlocks", () => { + it("gets the whole document", () => { + expect(getBlocks([], document)).toEqual(document); + }); + + it("gets a block at a path", () => { + expect(getBlocks(["1", "2"], document)).toEqual([ + { + children: [], + content: [], + id: "2", + props: { + backgroundColor: "default", + textAlignment: "left", + textColor: "default", + }, + type: "paragraph", + }, + ]); + }); + + it("gets a block at a point", () => { + expect(getBlocks({ path: ["1", "2"], offset: 0 }, document)).toEqual([ + { + children: [], + content: [], + id: "2", + props: { + backgroundColor: "default", + textAlignment: "left", + textColor: "default", + }, + type: "paragraph", + }, + ]); + }); + + it("gets a block at a range", () => { + expect( + getBlocks( + { + head: { path: ["1", "2"], offset: 0 }, + anchor: { path: ["1", "2"], offset: 0 }, + }, + document, + ), + ).toEqual([ + { + children: [], + content: [], + id: "2", + props: { + backgroundColor: "default", + textAlignment: "left", + textColor: "default", + }, + type: "paragraph", + }, + ]); + }); +}); diff --git a/packages/core/src/editor/Location.ts b/packages/core/src/editor/Location.ts new file mode 100644 index 000000000..394999eda --- /dev/null +++ b/packages/core/src/editor/Location.ts @@ -0,0 +1,64 @@ +/** + * 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 is a path with 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 a path, point, or range, it is used to identify positions within a document. + */ +export type Location = BlockId | Point | Range; + +export function toId(id: BlockIdentifier): BlockId { + return typeof id === "string" ? id : id.id; +} + +export function isBlockId(id: unknown): id is BlockId { + return typeof id === "string"; +} + +export function isPoint(location: unknown): location is Point { + return ( + !!location && + typeof location === "object" && + "offset" in location && + typeof location.offset === "number" && + "id" in location && + isBlockId(location.id) + ); +} + +export function isRange(location: unknown): location is Range { + return ( + !!location && + typeof location === "object" && + "anchor" in location && + isPoint(location.anchor) && + "head" in location && + isPoint(location.head) + ); +} + +export function isLocation(location: unknown): location is Location { + return isBlockId(location) || isPoint(location) || isRange(location); +} diff --git a/packages/core/src/editor/Path.ts b/packages/core/src/editor/Path.ts new file mode 100644 index 000000000..96f1b551a --- /dev/null +++ b/packages/core/src/editor/Path.ts @@ -0,0 +1,356 @@ +import type { Block } from "../blocks/defaultBlocks.js"; +import type { + BlockSchema, + InlineContentSchema, + StyleSchema, +} from "../schema/index.js"; +import { type Path, toId } from "./Location.js"; + +/** + * TODO this is mostly AI slop, but it proves the point of having a class + * which includes a number of methods which can be used for Path operations. + */ + +export class PathTools< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema, +> { + constructor(private editor: { document: Block[] }) {} + + private get document() { + return this.editor.document; + } + + /** + * Get the block(s) at the specified path + */ + public getBlockAtPath(path: Path): Block[] { + if (!path.length) { + return this.document; + } + + let currentBlocks = this.document; + + for (let i = 0; i < path.length; i++) { + const id = toId(path[i]); + const block = currentBlocks.find((block) => block.id === id); + + if (!block) { + return []; + } + + if (!block.children.length) { + // If we're at the last path segment, return just the block + if (i === path.length - 1) { + return [block]; + } + // If we have more path segments but no children, path is invalid + return []; + } + + currentBlocks = block.children; + } + + return currentBlocks; + } + + /** + * Get a single block at the specified path + */ + public getBlock(path: Path): Block | undefined { + const blocks = this.getBlockAtPath(path); + return blocks.length > 0 ? blocks[0] : undefined; + } + + /** + * Get a list of ancestor paths for a given path. + * The paths are sorted from shallowest to deepest ancestor. + */ + public getAncestors(path: Path, options: { reverse?: boolean } = {}): Path[] { + const ancestors: Path[] = []; + let currentPath: Path = [...path]; + + while (currentPath.length > 0) { + currentPath = currentPath.slice(0, -1); + ancestors.push([...currentPath]); + } + + return options.reverse ? ancestors.reverse() : ancestors; + } + + /** + * Get the common ancestor path of two paths. + */ + public getCommonPath(path: Path, another: Path): Path { + const common: Path = []; + const minLength = Math.min(path.length, another.length); + + for (let i = 0; i < minLength; i++) { + if (toId(path[i]) === toId(another[i])) { + common.push(path[i]); + } else { + break; + } + } + + return common; + } + + /** + * Compare two paths based on their position in the document. + * Returns -1 if path comes before another, 0 if they're the same, 1 if path comes after another. + */ + public comparePaths(path: Path, another: Path): -1 | 0 | 1 { + // First check common ancestry + const minLength = Math.min(path.length, another.length); + for (let i = 0; i < minLength; i++) { + const pathId = toId(path[i]); + const anotherId = toId(another[i]); + + if (pathId !== anotherId) { + // Need to find the actual position in the document + const parentPath = i === 0 ? [] : path.slice(0, i); + const parentBlocks = this.getBlockAtPath(parentPath); + + // Find the indices of both blocks in their parent + const pathIndex = parentBlocks.findIndex( + (block) => block.id === pathId, + ); + const anotherIndex = parentBlocks.findIndex( + (block) => block.id === anotherId, + ); + + if (pathIndex < anotherIndex) { + return -1; + } + if (pathIndex > anotherIndex) { + return 1; + } + } + } + + // If all common ancestors are the same, shorter path comes first + if (path.length < another.length) { + return -1; + } + if (path.length > another.length) { + return 1; + } + + // Paths are identical + return 0; + } + + /** + * Get a list of paths at every level down to a path. + */ + public getLevels(path: Path, options: { reverse?: boolean } = {}): Path[] { + const levels: Path[] = []; + let currentPath: Path = []; + + for (const segment of path) { + currentPath = [...currentPath, segment]; + levels.push([...currentPath]); + } + + return options.reverse ? levels.reverse() : levels; + } + + /** + * Given a path, gets the path to the next sibling node. + */ + public getNextPath(path: Path): Path | null { + if (path.length === 0) { + return null; + } + + const parentPath = path.slice(0, -1); + const parentBlocks = this.getBlockAtPath(parentPath); + const currentId = toId(path[path.length - 1]); + + // Find the current block's index in its parent + const currentIndex = parentBlocks.findIndex( + (block) => block.id === currentId, + ); + + // If it's the last child or not found, there's no next sibling + if (currentIndex === -1 || currentIndex === parentBlocks.length - 1) { + return null; + } + + // Return the path to the next sibling + return [...parentPath, parentBlocks[currentIndex + 1].id]; + } + + /** + * Given a path, return a new path referring to the parent node above it. + */ + public getParentPath(path: Path): Path { + if (path.length === 0) { + throw new Error("Cannot get parent of root path"); + } + return path.slice(0, -1); + } + + /** + * Given a path, get the path to the previous sibling node. + */ + public getPreviousPath(path: Path): Path | null { + if (path.length === 0) { + return null; + } + + const parentPath = path.slice(0, -1); + const parentBlocks = this.getBlockAtPath(parentPath); + const currentId = toId(path[path.length - 1]); + + // Find the current block's index in its parent + const currentIndex = parentBlocks.findIndex( + (block) => block.id === currentId, + ); + + // If it's the first child or not found, there's no previous sibling + if (currentIndex <= 0) { + return null; + } + + // Return the path to the previous sibling + return [...parentPath, parentBlocks[currentIndex - 1].id]; + } + + /** + * Given two paths, one that is an ancestor to the other, returns the relative path. + */ + public getRelativePath(path: Path, ancestor: Path): Path { + if (!this.isAncestor(ancestor, path)) { + throw new Error("Ancestor path is not actually an ancestor"); + } + return path.slice(ancestor.length); + } + + /** + * Check if a path ends after one of the indexes in another. + */ + public endsAfter(path: Path, another: Path): boolean { + return this.comparePaths(path, another) === 1; + } + + /** + * Check if a path ends at one of the indexes in another. + */ + public endsAt(path: Path, another: Path): boolean { + return this.equals(path, another); + } + + /** + * Check if a path ends before one of the indexes in another. + */ + public endsBefore(path: Path, another: Path): boolean { + return this.comparePaths(path, another) === -1; + } + + /** + * Check if a path is exactly equal to another. + */ + public equals(path: Path, another: Path): boolean { + return ( + path.length === another.length && + path.every((segment, i) => toId(segment) === toId(another[i])) + ); + } + + /** + * Check if the path of previous sibling node exists. + */ + public hasPrevious(path: Path): boolean { + return this.getPreviousPath(path) !== null; + } + + /** + * Check if a path is after another. + */ + public isAfter(path: Path, another: Path): boolean { + return this.comparePaths(path, another) === 1; + } + + /** + * Check if a path is an ancestor of another. + */ + public isAncestor(path: Path, another: Path): boolean { + return ( + path.length < another.length && + path.every((segment, i) => toId(segment) === toId(another[i])) + ); + } + + /** + * Check if a path is before another. + */ + public isBefore(path: Path, another: Path): boolean { + return this.comparePaths(path, another) === -1; + } + + /** + * Check if a path is a child of another. + */ + public isChild(path: Path, another: Path): boolean { + return ( + path.length === another.length + 1 && + another.every((segment, i) => toId(segment) === toId(path[i])) + ); + } + + /** + * Check if a path is equal to or an ancestor of another. + */ + public isCommon(path: Path, another: Path): boolean { + return this.equals(path, another) || this.isAncestor(path, another); + } + + /** + * Check if a path is a descendant of another. + */ + public isDescendant(path: Path, another: Path): boolean { + return ( + path.length > another.length && + another.every((segment, i) => toId(segment) === toId(path[i])) + ); + } + + /** + * Check if a path is the parent of another. + */ + public isParent(path: Path, another: Path): boolean { + return ( + path.length + 1 === another.length && + path.every((segment, i) => toId(segment) === toId(another[i])) + ); + } + + /** + * Check if a path is a sibling of another. + */ + public isSibling(path: Path, another: Path): boolean { + if (path.length !== another.length) { + return false; + } + const parent = path.slice(0, -1); + const otherParent = another.slice(0, -1); + return this.equals(parent, otherParent); + } +} + +/** + * Check if a value implements the Path interface. + */ +export function isPath(value: any): value is Path { + return ( + Array.isArray(value) && + value.every( + (segment) => + typeof segment === "string" || + (typeof segment === "object" && "id" in segment), + ) + ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 741d2b726..94d6a7a6b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,10 @@ settings: importers: .: + dependencies: + '@tanstack/store': + specifier: 0.7.1 + version: 0.7.1 devDependencies: '@nx/js': specifier: 20.6.4 @@ -3217,6 +3221,9 @@ importers: '@shikijs/types': specifier: 3.2.1 version: 3.2.1 + '@tanstack/store': + specifier: 0.7.1 + version: 0.7.1 '@tiptap/core': specifier: ^2.12.0 version: 2.12.0(@tiptap/pm@2.12.0) @@ -3262,6 +3269,9 @@ importers: '@tiptap/pm': specifier: ^2.12.0 version: 2.12.0 + alien-signals: + specifier: 2.0.5 + version: 2.0.5 emoji-mart: specifier: ^5.6.0 version: 5.6.0 @@ -3319,6 +3329,9 @@ importers: uuid: specifier: ^8.3.2 version: 8.3.2 + valtio: + specifier: 2.1.5 + version: 2.1.5(@types/react@18.3.20)(react@18.3.1) y-prosemirror: specifier: ^1.3.4 version: 1.3.4(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.38.1)(y-protocols@1.0.6(yjs@13.6.24))(yjs@13.6.24) @@ -8302,6 +8315,9 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/store@0.7.1': + resolution: {integrity: sha512-PjUQKXEXhLYj2X5/6c1Xn/0/qKY0IVFxTJweopRfF26xfjVyb14yALydJrHupDh3/d+1WKmfEgZPBVCmDkzzwg==} + '@tanstack/virtual-core@3.13.5': resolution: {integrity: sha512-gMLNylxhJdUlfRR1G3U9rtuwUh2IjdrrniJIDcekVJN3/3i+bluvdMi3+eodnxzJq5nKnxnigo9h0lIpaqV6HQ==} @@ -9175,6 +9191,9 @@ packages: ajv@8.17.1: resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + alien-signals@2.0.5: + resolution: {integrity: sha512-PdJB6+06nUNAClInE3Dweq7/2xVAYM64vvvS1IHVHSJmgeOtEdrAGyp7Z2oJtYm0B342/Exd2NT0uMJaThcjLQ==} + ansi-align@3.0.1: resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} @@ -13066,6 +13085,9 @@ packages: protocols@2.0.2: resolution: {integrity: sha512-hHVTzba3wboROl0/aWRRG9dMytgH6ow//STBZh43l/wQgmMhYhOFi0EHWAPtoCz9IAUymsyP0TSBHkhgMEGNnQ==} + proxy-compare@3.0.1: + resolution: {integrity: sha512-V9plBAt3qjMlS1+nC8771KNf6oJ12gExvaxnNzN/9yVRLdTv/lc+oJlnSzrdYDAvBfTStPCoiaCOTmTs0adv7Q==} + proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} @@ -14392,6 +14414,18 @@ packages: resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + valtio@2.1.5: + resolution: {integrity: sha512-vsh1Ixu5mT0pJFZm+Jspvhga5GzHUTYv0/+Th203pLfh3/wbHwxhu/Z2OkZDXIgHfjnjBns7SN9HNcbDvPmaGw==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + react: '>=18.0.0' + peerDependenciesMeta: + '@types/react': + optional: true + react: + optional: true + vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} @@ -19332,6 +19366,8 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + '@tanstack/store@0.7.1': {} + '@tanstack/virtual-core@3.13.5': {} '@testing-library/dom@10.4.0': @@ -20346,6 +20382,8 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + alien-signals@2.0.5: {} + ansi-align@3.0.1: dependencies: string-width: 4.2.3 @@ -25234,6 +25272,8 @@ snapshots: protocols@2.0.2: {} + proxy-compare@3.0.1: {} + proxy-from-env@1.1.0: {} pseudomap@1.0.2: {} @@ -26875,6 +26915,13 @@ snapshots: validate-npm-package-name@5.0.1: {} + valtio@2.1.5(@types/react@18.3.20)(react@18.3.1): + dependencies: + proxy-compare: 3.0.1 + optionalDependencies: + '@types/react': 18.3.20 + react: 18.3.1 + vary@1.1.2: {} vfile-location@5.0.3: