From 52ecefbdfbdef8249be2ea389d4f5bba36148ab5 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Fri, 13 Jun 2025 18:07:54 +0200 Subject: [PATCH 01/12] feat: bunch of proposals --- adr/2025_06_13-document-transforms.md | 20 ++ adr/2025_06_13-extensions.md | 41 +++ adr/2025_06_13-schema.md | 21 ++ adr/BlockNoteExtension.ts | 169 ++++++++++ adr/Schema.ts | 70 +++++ packages/core/package.json | 3 + packages/core/src/editor/Location.test.ts | 223 ++++++++++++++ packages/core/src/editor/Location.ts | 148 +++++++++ packages/core/src/editor/Path.ts | 356 ++++++++++++++++++++++ pnpm-lock.yaml | 43 +++ 10 files changed, 1094 insertions(+) create mode 100644 adr/2025_06_13-document-transforms.md create mode 100644 adr/2025_06_13-extensions.md create mode 100644 adr/2025_06_13-schema.md create mode 100644 adr/BlockNoteExtension.ts create mode 100644 adr/Schema.ts create mode 100644 packages/core/src/editor/Location.test.ts create mode 100644 packages/core/src/editor/Location.ts create mode 100644 packages/core/src/editor/Path.ts diff --git a/adr/2025_06_13-document-transforms.md b/adr/2025_06_13-document-transforms.md new file mode 100644 index 0000000000..8e12050b40 --- /dev/null +++ b/adr/2025_06_13-document-transforms.md @@ -0,0 +1,20 @@ +# 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. + diff --git a/adr/2025_06_13-extensions.md b/adr/2025_06_13-extensions.md new file mode 100644 index 0000000000..7c5910791f --- /dev/null +++ b/adr/2025_06_13-extensions.md @@ -0,0 +1,41 @@ +# 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 `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. +- Editing event handlers, by providing hooks for `change`, `selectionChange`, `beforeChange`, and `transaction` we give extensions access to the fundamental primitives 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 . +
diff --git a/adr/2025_06_13-schema.md b/adr/2025_06_13-schema.md new file mode 100644 index 0000000000..3789f78a95 --- /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/BlockNoteExtension.ts b/adr/BlockNoteExtension.ts new file mode 100644 index 0000000000..92f15c4cb3 --- /dev/null +++ b/adr/BlockNoteExtension.ts @@ -0,0 +1,169 @@ +import { + BlockNoteEditor, + BlocksChanged, + Schema, + Selection, +} from "@blocknote/core"; +import { Store } from "@tanstack/store"; +import { Plugin, Transaction } from "prosemirror-state"; + +/** + * This is an abstract class to make it easier to implement an extension using a class. + */ +export abstract class BlockNoteExtension< + State, + BSchema extends Schema = Schema, +> { + public key = "not-implemented"; + public store?: Store; + public priority?: number; + public plugins?: Plugin[]; + public keyboardShortcuts?: Record< + string, + (context: ExtensionContext) => boolean + >; + public onCreate?: (context: ExtensionContext) => void; + public onMount?: (context: ExtensionContext) => void; + public onUnmount?: (context: ExtensionContext) => void; + public onChange?: ( + context: ExtensionContext & { + getChanges: () => BlocksChanged; + }, + ) => void; + public onSelectionChange?: ( + context: ExtensionContext & { + getSelection: () => Selection | undefined; + }, + ) => void; + public onBeforeChange?: ( + context: ExtensionContext & { + getChanges: () => BlocksChanged; + tr: Transaction; + }, + ) => boolean | void; + public onTransaction?: ( + context: ExtensionContext & { + tr: Transaction; + }, + ) => void; +} + +export interface BlockNoteExtension { + /** + * 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 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< + string, + (context: ExtensionContext) => boolean + >; + + /** + * Called on initialization of the editor + * @note the view is not yet mounted at this point + */ + onCreate?: (context: ExtensionContext) => void; + + /** + * Called when the editor is mounted + * @note the view is available + */ + onMount?: (context: ExtensionContext) => void; + + /** + * Called when the editor is unmounted + * @note the view will no longer be available after this is executed + */ + onUnmount?: (context: ExtensionContext) => void; + + /** + * Called when an editor transaction is applied + */ + onTransaction?: ( + context: ExtensionContext & { + tr: Transaction; + }, + ) => void; + + /** + * Called when the editor content changes + * @note the changes are available + */ + onChange?: ( + context: ExtensionContext & { + getChanges: () => BlocksChanged; + }, + ) => void; + + /** + * Called when the selection changes + * @note the selection is available + */ + onSelectionChange?: ( + context: ExtensionContext & { + getSelection: () => Selection | undefined; + }, + ) => void; + + /** + * Called before an editor change is applied, + * Allowing the extension to cancel the change + */ + onBeforeChange?: ( + context: ExtensionContext & { + getChanges: () => BlocksChanged; + tr: Transaction; + }, + ) => boolean | void; +} + +export interface ExtensionContext { + editor: BlockNoteEditor; +} + +/** + * This is the class-form, where it can extend the abstract class + */ +export class MyExtension extends BlockNoteExtension<{ abc: number[] }> { + public key = "my-extension"; + public store = new Store({ abc: [1, 2, 3] }); + + constructor(_extensionOptions: { myCustomOption: string }) { + super(); + } +} + +/** + * 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; +}): BlockNoteExtension<{ state: number }> { + const myState = new Store({ state: 0 }); + return { + key: "my-extension", + store: myState, + onMount(context) { + context.editor.extensions.myExtension = this; + myState.setState({ state: 1 }); + }, + }; +} diff --git a/adr/Schema.ts b/adr/Schema.ts new file mode 100644 index 0000000000..a3256cfac0 --- /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/packages/core/package.json b/packages/core/package.json index 212054e9bd..0b0a44ae0c 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 0000000000..78157564e0 --- /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 0000000000..5d511bf5b7 --- /dev/null +++ b/packages/core/src/editor/Location.ts @@ -0,0 +1,148 @@ +import type { Block } from "../blocks/defaultBlocks.js"; +import type { + BlockSchema, + InlineContentSchema, + StyleSchema, +} from "../schema/index.js"; + +/** + * 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 path is a list of block identifiers, describing a path to a block within a document. + * Each level of the path is a child of the previous level. + * The entire document can be described by the path []. + */ +export type Path = BlockIdentifier[]; + +/** + * A point is a path with an offset, it is used to identify a specific position within a block. + */ +export type Point = { + path: Path; + 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 = Path | Point | Range; + +export function toId(id: BlockIdentifier): BlockId { + return typeof id === "string" ? id : id.id; +} + +export function isPoint(location: unknown): location is Point { + return ( + !!location && + typeof location === "object" && + "offset" in location && + typeof location.offset === "number" && + "path" in location && + isPath(location.path) + ); +} + +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 isPath(location: unknown): location is Path { + return ( + Array.isArray(location) && + location.every( + (segment) => + typeof segment === "string" || + (typeof segment === "object" && "id" in segment), + ) + ); +} + +export function isLocation(location: unknown): location is Location { + return isPath(location) || isPoint(location) || isRange(location); +} + +export function getBlockAtPath< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema, +>(path: Path, document: Block[]): Block[] { + if (!path.length) { + return document; + } + + let currentBlocks = 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; +} + +/** + * Can be used to get all blocks at any location + */ +export function getBlocks< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema, +>( + location: Location, + document: Block[], +): Block[] { + if (isPath(location)) { + return getBlockAtPath(location, document); + } + if (isPoint(location)) { + return getBlockAtPath(location.path, document); + } + if (isRange(location)) { + // TODO this is not actually correct, they need to get the common ancestor and then get the blocks from that point to the end of the range + return Array.from( + new Set([ + ...getBlocks(location.anchor.path, document), + ...getBlocks(location.head.path, document), + ]), + ); + } + throw new Error("Invalid location"); +} diff --git a/packages/core/src/editor/Path.ts b/packages/core/src/editor/Path.ts new file mode 100644 index 0000000000..96f1b551a8 --- /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 741d2b7260..7a53ce81ee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3217,6 +3217,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 +3265,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 +3325,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 +8311,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 +9187,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 +13081,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 +14410,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 +19362,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 +20378,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 +25268,8 @@ snapshots: protocols@2.0.2: {} + proxy-compare@3.0.1: {} + proxy-from-env@1.1.0: {} pseudomap@1.0.2: {} @@ -26875,6 +26911,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: From 24517fbcdcf98759ffe6fc1d342155a9e1a3f7f3 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Fri, 13 Jun 2025 18:23:26 +0200 Subject: [PATCH 02/12] docs: add packaging schema --- adr/2025_06_13-packages.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 adr/2025_06_13-packages.md diff --git a/adr/2025_06_13-packages.md b/adr/2025_06_13-packages.md new file mode 100644 index 0000000000..4e5ace3828 --- /dev/null +++ b/adr/2025_06_13-packages.md @@ -0,0 +1,30 @@ +# 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) From 536ea9de42b469df9c61b982fbc9a0929e184d70 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Mon, 16 Jun 2025 10:20:18 +0200 Subject: [PATCH 03/12] docs: how to export extension methods --- adr/2025_06_13-extensions.md | 8 ++++++++ adr/BlockNoteExtension.ts | 19 +++++++++++++++++++ package.json | 3 +++ pnpm-lock.yaml | 4 ++++ 4 files changed, 34 insertions(+) diff --git a/adr/2025_06_13-extensions.md b/adr/2025_06_13-extensions.md index 7c5910791f..303f7ac969 100644 --- a/adr/2025_06_13-extensions.md +++ b/adr/2025_06_13-extensions.md @@ -39,3 +39,11 @@ Zustand is a popular library, but allowing partial states makes it somewhat unsa 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). diff --git a/adr/BlockNoteExtension.ts b/adr/BlockNoteExtension.ts index 92f15c4cb3..2a2b11df82 100644 --- a/adr/BlockNoteExtension.ts +++ b/adr/BlockNoteExtension.ts @@ -149,6 +149,10 @@ export class MyExtension extends BlockNoteExtension<{ abc: number[] }> { constructor(_extensionOptions: { myCustomOption: string }) { super(); } + + getFoo() { + return 8; + } } /** @@ -167,3 +171,18 @@ export function myExtension(_extensionOptions: { }, }; } + +/** + * This type exposes the public API of an extension, excluding any {@link BlockNoteExtension} methods (for cleaner typing) + */ +export type ExtensionMethods> = + Extension extends BlockNoteExtension + ? Omit, "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; +// editor.extensions.myExtension.getFoo(); diff --git a/package.json b/package.json index 130ec226cd..4c3de03c8b 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/pnpm-lock.yaml b/pnpm-lock.yaml index 7a53ce81ee..94d6a7a6b0 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 From a80259fe21992cd6842150540e8111d84a8c2609 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Mon, 16 Jun 2025 15:09:04 +0200 Subject: [PATCH 04/12] docs: add partial nested-blocks description --- adr/2025_06_16-nested-blocks.md | 155 ++++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 adr/2025_06_16-nested-blocks.md diff --git a/adr/2025_06_16-nested-blocks.md b/adr/2025_06_16-nested-blocks.md new file mode 100644 index 0000000000..c6132a172a --- /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!" + } + ] + } + ] + } + ] + } + ] +} +``` From ac54f6781977f2d34b123c6b51b91089f31cfc20 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Mon, 16 Jun 2025 15:13:50 +0200 Subject: [PATCH 05/12] docs: add descriptions for configuration --- adr/2025_06_16-configuration.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 adr/2025_06_16-configuration.md diff --git a/adr/2025_06_16-configuration.md b/adr/2025_06_16-configuration.md new file mode 100644 index 0000000000..a91abeaf58 --- /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. From 966692dca4bc9c4434ebff8c143f4d4b69dce4e3 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Mon, 16 Jun 2025 15:50:56 +0200 Subject: [PATCH 06/12] docs: describe references --- adr/2025_06_13-document-transforms.md | 45 +++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/adr/2025_06_13-document-transforms.md b/adr/2025_06_13-document-transforms.md index 8e12050b40..b627c450cd 100644 --- a/adr/2025_06_13-document-transforms.md +++ b/adr/2025_06_13-document-transforms.md @@ -18,3 +18,48 @@ Right now all transformation functions are defined directly on the `Editor` inst 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. From 9684a7d3ce180bc028e6a147f57f6265459e3046 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Tue, 17 Jun 2025 16:17:16 +0200 Subject: [PATCH 07/12] feat: concept of bundles vs. extensions --- adr/2025_06_13-extensions.md | 15 +++- adr/BlockNoteBundle.ts | 80 ++++++++++++++++++ adr/BlockNoteExtension.ts | 157 ++++++----------------------------- 3 files changed, 120 insertions(+), 132 deletions(-) create mode 100644 adr/BlockNoteBundle.ts diff --git a/adr/2025_06_13-extensions.md b/adr/2025_06_13-extensions.md index 303f7ac969..ead558d78d 100644 --- a/adr/2025_06_13-extensions.md +++ b/adr/2025_06_13-extensions.md @@ -8,8 +8,7 @@ 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 `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. -- Editing event handlers, by providing hooks for `change`, `selectionChange`, `beforeChange`, and `transaction` we give extensions access to the fundamental primitives of the editor. +- 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 @@ -47,3 +46,15 @@ Not everything can be communicated through just state, so we need to be able to 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/BlockNoteBundle.ts b/adr/BlockNoteBundle.ts new file mode 100644 index 0000000000..42611db9b5 --- /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/BlockNoteExtension.ts b/adr/BlockNoteExtension.ts index 2a2b11df82..2030790dff 100644 --- a/adr/BlockNoteExtension.ts +++ b/adr/BlockNoteExtension.ts @@ -1,54 +1,12 @@ -import { - BlockNoteEditor, - BlocksChanged, - Schema, - Selection, -} from "@blocknote/core"; +import { BlockNoteEditor } from "@blocknote/core"; import { Store } from "@tanstack/store"; -import { Plugin, Transaction } from "prosemirror-state"; +import { Plugin } from "prosemirror-state"; -/** - * This is an abstract class to make it easier to implement an extension using a class. - */ -export abstract class BlockNoteExtension< - State, - BSchema extends Schema = Schema, -> { - public key = "not-implemented"; - public store?: Store; - public priority?: number; - public plugins?: Plugin[]; - public keyboardShortcuts?: Record< - string, - (context: ExtensionContext) => boolean - >; - public onCreate?: (context: ExtensionContext) => void; - public onMount?: (context: ExtensionContext) => void; - public onUnmount?: (context: ExtensionContext) => void; - public onChange?: ( - context: ExtensionContext & { - getChanges: () => BlocksChanged; - }, - ) => void; - public onSelectionChange?: ( - context: ExtensionContext & { - getSelection: () => Selection | undefined; - }, - ) => void; - public onBeforeChange?: ( - context: ExtensionContext & { - getChanges: () => BlocksChanged; - tr: Transaction; - }, - ) => boolean | void; - public onTransaction?: ( - context: ExtensionContext & { - tr: Transaction; - }, - ) => void; -} +export type BlockNoteExtension = ( + editor: BlockNoteEditor, +) => BlockNoteExtensionConfig; -export interface BlockNoteExtension { +export interface BlockNoteExtensionConfig { /** * The name of this extension, must be unique */ @@ -62,7 +20,7 @@ export interface BlockNoteExtension { */ priority?: number; /** - * The plugins of the extension + * The prosemirror plugins of the extension */ plugins?: Plugin[]; /** @@ -71,105 +29,39 @@ export interface BlockNoteExtension { * 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< - string, - (context: ExtensionContext) => boolean - >; - - /** - * Called on initialization of the editor - * @note the view is not yet mounted at this point - */ - onCreate?: (context: ExtensionContext) => void; + keyboardShortcuts?: Record boolean>; /** * Called when the editor is mounted * @note the view is available */ - onMount?: (context: ExtensionContext) => void; + onMount?: () => void; /** * Called when the editor is unmounted * @note the view will no longer be available after this is executed */ - onUnmount?: (context: ExtensionContext) => void; - - /** - * Called when an editor transaction is applied - */ - onTransaction?: ( - context: ExtensionContext & { - tr: Transaction; - }, - ) => void; - - /** - * Called when the editor content changes - * @note the changes are available - */ - onChange?: ( - context: ExtensionContext & { - getChanges: () => BlocksChanged; - }, - ) => void; - - /** - * Called when the selection changes - * @note the selection is available - */ - onSelectionChange?: ( - context: ExtensionContext & { - getSelection: () => Selection | undefined; - }, - ) => void; - - /** - * Called before an editor change is applied, - * Allowing the extension to cancel the change - */ - onBeforeChange?: ( - context: ExtensionContext & { - getChanges: () => BlocksChanged; - tr: Transaction; - }, - ) => boolean | void; -} - -export interface ExtensionContext { - editor: BlockNoteEditor; -} - -/** - * This is the class-form, where it can extend the abstract class - */ -export class MyExtension extends BlockNoteExtension<{ abc: number[] }> { - public key = "my-extension"; - public store = new Store({ abc: [1, 2, 3] }); - - constructor(_extensionOptions: { myCustomOption: string }) { - super(); - } - - getFoo() { - return 8; - } + 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; -}): BlockNoteExtension<{ state: number }> { +export function myExtension(_extensionOptions: { myCustomOption: string }) { const myState = new Store({ state: 0 }); - return { + return ((editor) => ({ key: "my-extension", store: myState, - onMount(context) { - context.editor.extensions.myExtension = this; + onMount() { + editor.onChange((change) => { + console.log(change); + }); myState.setState({ state: 1 }); }, - }; + getFoo() { + return 8; + }, + })) satisfies BlockNoteExtension<{ state: number }>; } /** @@ -177,12 +69,17 @@ export function myExtension(_extensionOptions: { */ export type ExtensionMethods> = Extension extends BlockNoteExtension - ? Omit, "store" | "key">> + ? 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; +export type MyExtensionMethods = ExtensionMethods< + ReturnType +>; // editor.extensions.myExtension.getFoo(); From fc6ac01ecaf9d41c023bbe3d93826983c244d766 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Wed, 18 Jun 2025 14:39:46 +0200 Subject: [PATCH 08/12] docs: block note editor class --- adr/2025_06_13-packages.md | 6 ++ adr/BlockNoteEditor.ts | 131 +++++++++++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+) create mode 100644 adr/BlockNoteEditor.ts diff --git a/adr/2025_06_13-packages.md b/adr/2025_06_13-packages.md index 4e5ace3828..7e1511bff0 100644 --- a/adr/2025_06_13-packages.md +++ b/adr/2025_06_13-packages.md @@ -28,3 +28,9 @@ It contains sub-packages for: - `@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/BlockNoteEditor.ts b/adr/BlockNoteEditor.ts new file mode 100644 index 0000000000..ce345912e6 --- /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 +} From daa62dd82a7c956efd16b78acd4858ad65eac7c8 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Wed, 18 Jun 2025 14:44:24 +0200 Subject: [PATCH 09/12] docs: formats --- adr/2025_06_17-formats.md | 55 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 adr/2025_06_17-formats.md diff --git a/adr/2025_06_17-formats.md b/adr/2025_06_17-formats.md new file mode 100644 index 0000000000..b01e719c0e --- /dev/null +++ b/adr/2025_06_17-formats.md @@ -0,0 +1,55 @@ +# 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 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: From aff3cbb8759731a878eea48f435e37887034b4a6 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Wed, 18 Jun 2025 15:03:50 +0200 Subject: [PATCH 10/12] docs: small thing on ui --- adr/2025_06_18-ui-components.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 adr/2025_06_18-ui-components.md diff --git a/adr/2025_06_18-ui-components.md b/adr/2025_06_18-ui-components.md new file mode 100644 index 0000000000..b2a401375d --- /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 From e066c85ef2d492bca6afe35f76bc76e39743ac3f Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Thu, 19 Jun 2025 10:01:48 +0200 Subject: [PATCH 11/12] chore: add rendered html --- adr/2025_06_17-formats.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/adr/2025_06_17-formats.md b/adr/2025_06_17-formats.md index b01e719c0e..3c51e32215 100644 --- a/adr/2025_06_17-formats.md +++ b/adr/2025_06_17-formats.md @@ -21,6 +21,10 @@ 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; From 329e5a7f17eacf37d4045869e0a73d1d589a4913 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Thu, 19 Jun 2025 16:32:47 +0200 Subject: [PATCH 12/12] docs: update location proposal --- packages/core/src/editor/Location.ts | 102 +++------------------------ 1 file changed, 9 insertions(+), 93 deletions(-) diff --git a/packages/core/src/editor/Location.ts b/packages/core/src/editor/Location.ts index 5d511bf5b7..394999edaa 100644 --- a/packages/core/src/editor/Location.ts +++ b/packages/core/src/editor/Location.ts @@ -1,10 +1,3 @@ -import type { Block } from "../blocks/defaultBlocks.js"; -import type { - BlockSchema, - InlineContentSchema, - StyleSchema, -} from "../schema/index.js"; - /** * A block id is a unique identifier for a block, it is a string. */ @@ -15,18 +8,11 @@ export type BlockId = string; */ export type BlockIdentifier = { id: BlockId } | BlockId; -/** - * A path is a list of block identifiers, describing a path to a block within a document. - * Each level of the path is a child of the previous level. - * The entire document can be described by the path []. - */ -export type Path = BlockIdentifier[]; - /** * A point is a path with an offset, it is used to identify a specific position within a block. */ export type Point = { - path: Path; + id: BlockId; offset: number; }; @@ -41,20 +27,24 @@ export type Range = { /** * A location is a path, point, or range, it is used to identify positions within a document. */ -export type Location = Path | Point | Range; +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" && - "path" in location && - isPath(location.path) + "id" in location && + isBlockId(location.id) ); } @@ -69,80 +59,6 @@ export function isRange(location: unknown): location is Range { ); } -export function isPath(location: unknown): location is Path { - return ( - Array.isArray(location) && - location.every( - (segment) => - typeof segment === "string" || - (typeof segment === "object" && "id" in segment), - ) - ); -} - export function isLocation(location: unknown): location is Location { - return isPath(location) || isPoint(location) || isRange(location); -} - -export function getBlockAtPath< - BSchema extends BlockSchema, - I extends InlineContentSchema, - S extends StyleSchema, ->(path: Path, document: Block[]): Block[] { - if (!path.length) { - return document; - } - - let currentBlocks = 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; -} - -/** - * Can be used to get all blocks at any location - */ -export function getBlocks< - BSchema extends BlockSchema, - I extends InlineContentSchema, - S extends StyleSchema, ->( - location: Location, - document: Block[], -): Block[] { - if (isPath(location)) { - return getBlockAtPath(location, document); - } - if (isPoint(location)) { - return getBlockAtPath(location.path, document); - } - if (isRange(location)) { - // TODO this is not actually correct, they need to get the common ancestor and then get the blocks from that point to the end of the range - return Array.from( - new Set([ - ...getBlocks(location.anchor.path, document), - ...getBlocks(location.head.path, document), - ]), - ); - } - throw new Error("Invalid location"); + return isBlockId(location) || isPoint(location) || isRange(location); }