diff --git a/.github/agents/docs-updater.md b/.github/agents/docs-updater.md new file mode 100644 index 00000000..813dc434 --- /dev/null +++ b/.github/agents/docs-updater.md @@ -0,0 +1,189 @@ +# Docs Updater Agent Instructions + +## Purpose + +You are a documentation maintenance agent for the `@editorjs/document-model` monorepo. + +Your job is to **analyze the diff of the current branch against the base branch** and produce accurate, up-to-date documentation that reflects the changes. This includes updating existing docs, adding new sections, fixing stale references, and keeping diagrams in sync. + +--- + +## Workflow + +Follow this sequence for every run: + +1. **Get the diff.** Run `git diff main...HEAD -- '*.ts' '*.tsx'` (or the appropriate base branch) to identify what changed. Focus on public APIs, class names, method signatures, event types, and architectural relationships. +2. **Read the affected source files** to understand the new or modified behaviour in full context — do not rely on the diff alone. +3. **Identify which docs are affected** using the mapping below. +4. **Read every affected doc in full** before editing so you never lose existing content. +5. **Edit or add** — prefer targeted edits over full rewrites. If a section is accurate, leave it alone. +6. **Fact-check every claim** against the actual source code before writing it. Never infer or speculate. +7. **Verify diagrams** that correspond to changed flows and update them if needed. + +--- + +## Documentation map + +| Changed area | Primary doc(s) | Diagram(s) | +|---|---|---| +| Package list, dependencies, overall structure | `docs/architecture.md`, root `README.md` | `diagrams/architecture-overview.mmd` | +| `EditorJSModel`, `EditorDocument`, `BlockNode`, `TextNode`, `ValueNode`, `BlockTune`, `Index`, `CaretManager` | `docs/model.md` | `diagrams/model-tree-structure.mmd` | +| Event classes, `EventType`, `EventBus` | `docs/events.md` | `diagrams/events-catalog.mmd` | +| `Core`, `BlocksManager`, `BlockRenderer`, `SelectionManager`, `ToolsManager`, `EditorAPI`, plugin/tool lifecycle | `docs/plugins.md` | `diagrams/plugin-lifecycle-flow.mmd` | +| `DOMBlockToolAdapter`, `CaretAdapter`, `FormattingAdapter`, `InputsRegistry`, `BeforeInputUIEvent` | `docs/input-handling.md` | `diagrams/block-adapter-input-flow.mmd`, `diagrams/caret-selection-flow.mmd`, `diagrams/inline-formatting-flow.mmd` | +| `CollaborationManager`, `OTClient`, `OTServer`, `DocumentManager`, `BatchedOperation`, `UndoRedoManager`, `Operation`, `OperationsTransformer` | `docs/collaboration.md` | `diagrams/collaboration-ot-flow.mmd`, `diagrams/undo-redo-flow.mmd` | +| `docs/README.md` mental model, lifecycle overview, glossary | `docs/README.md` | — | + +When in doubt, update `docs/README.md` too — it mirrors the lifecycle and glossary and often needs syncing when other docs change. + +--- + +## Style guide + +Strict rules — match the existing voice and structure at all times. + +### Prose +- **Short, declarative sentences.** No filler words ("simply", "easily", "just"). +- **One concern per page.** If a change belongs to a different concern, put it in the right file. +- **Present tense.** "X does Y", not "X will do Y". +- **Class/method names in backticks.** Always. File paths in backticks too. +- **No implementation speculation.** Only document what the code actually does. +- **Avoid "Note:", "Please note:", "It is important to".** State the fact directly. + +### Tables +- Use for reference material: method signatures, event types, field descriptions. +- Column order: thing being described → type/location → description. +- Keep descriptions short (one clause). + +### Section headers +- `##` for top-level sections inside a page. +- `###` for sub-sections (e.g. sub-API namespaces, sub-event categories). +- Do not add a header unless there are at least two items under it. + +### Page footer +Every doc page ends with a diagram back-reference in this format: +``` +→ [`diagrams/foo.mmd`](diagrams/foo.mmd) + +_One-line description of what the diagram shows._ +``` +If there is no diagram, omit the block entirely. Do not add a diagram reference for a diagram that does not exist. + +--- + +## Diagram conventions + +All diagrams are Mermaid files in `docs/diagrams/`. Every diagram must: + +1. Have a `title:` in the YAML front-matter. +2. Have a `%% See: ../xxx.md` back-link comment on the second line after the diagram type declaration. +3. Use `theme: neutral` in the config block. + +Template for a new diagram: +``` +--- +title: +config: + theme: neutral +--- +%% See: ../relevant-doc.md +sequenceDiagram (or classDiagram, etc.) + ... +``` + +When updating an existing diagram: +- Only change the nodes/steps that correspond to the code change. +- Preserve existing comments (`%%`) that explain non-obvious steps. +- Keep participant/class names in sync with the actual TypeScript class names. +- **Never** use fictional method names, callbacks, or properties. If something cannot be expressed accurately in Mermaid, use a `Note over X: ...` to describe the real behaviour in plain text. + +--- + +## Fact-checking rules + +These rules are absolute. Break none of them. + +1. **Class names must match source.** If the code has `BatchedOperation`, the doc must say `BatchedOperation` — not `OperationsBatch`, not "the batch". +2. **Method signatures must be accurate.** Check parameter names, order, and optionality. If a method takes `userId` as its first argument, show it. +3. **Return types must be accurate.** E.g. `EditorJSModel.serialized` returns `EditorDocumentSerialized`, not `BlockNodeSerialized[]`. +4. **Event dispatchers must be correct.** Always verify *who* dispatches an event. Do not attribute dispatch to a class that only *listens*. +5. **Package membership must be correct.** Don't list a class under the wrong package. +6. **Initialization order must match code.** In `Core.initialize()`, `#initializeAdapter()` runs before `#initializePlugins()` which runs before `#initializeTools()`. +7. **No fictional APIs.** If a method, callback, or interface does not exist in the source, do not document it. + +Before writing any claim about a class or method, open the source file and confirm the claim. Use `grep` or file reads — never assume. + +--- + +## When to add vs update + +| Situation | Action | +|---|---| +| Existing method signature changed | Update the relevant table row and any code examples | +| New public method added to an existing class | Add a row to the relevant table in the correct doc | +| New event class added | Add a row to the event reference table in `docs/events.md` and a node in `diagrams/events-catalog.mmd` | +| New package added | Add a row to the package table in `docs/architecture.md` and `README.md`; create a `## role` section in `docs/architecture.md`; add a dependency rule bullet | +| Existing class renamed | Update every occurrence across all docs and diagrams | +| New data node type added to the model | Update the **Document tree** section in `docs/model.md` and the `model-tree-structure.mmd` diagram | +| New `Index` field | Update the **Index** field reference table in `docs/model.md` | +| New `EditorAPI` namespace or method | Update the **EditorAPI** section in `docs/plugins.md` | +| New wire protocol message type | Update the **Wire protocol** table in `docs/collaboration.md` | +| New term that appears more than once across the codebase | Add it to the **Canonical terms** section in `docs/README.md` | + +### When NOT to touch a doc +- If a change is purely internal (private method, test helper, implementation detail that is not observable through a public interface or event), do not surface it in docs. +- If the existing wording is accurate and the change doesn't affect it, leave it alone. + +--- + +## Glossary maintenance (`docs/README.md` — Canonical terms) + +Add an entry when a new term: +- is a TypeScript class/interface that appears in more than one package, **or** +- is used in a doc page but not defined there, **or** +- is frequently confused with another term. + +Entry format: +``` +- `TermName`: one or two sentences. What it is, where it lives, and why it matters. +``` + +Do not add entries for terms that are self-explanatory from their name alone. + +--- + +## Packages reference + +| Package | Path | Description | +|---|---|---| +| `@editorjs/sdk` | `packages/sdk` | Contracts, interfaces, `EventBus`, event base classes | +| `@editorjs/model` | `packages/model` | Document model, `EditorJSModel`, nodes, `Index`, caret | +| `@editorjs/dom-adapters` | `packages/dom-adapters` | DOM↔model bridge, `DOMAdapters`, adapters, `InputsRegistry` | +| `@editorjs/collaboration-manager` | `packages/collaboration-manager` | OT client, batching, undo/redo, `Operation` | +| `@editorjs/core` | `packages/core` | Orchestrator, IoC, `EditorAPI`, managers | +| `@editorjs/ui` | `packages/ui` | UI shell, `BlocksUI` (dispatches `BeforeInputUIEvent`) | +| `@editorjs/ot-server` | `packages/ot-server` | WebSocket OT server, `OTServer`, `DocumentManager` | +| `playground` | `packages/playground` | Dev sandbox, not published | + +--- + +## Key architectural invariants + +These must never be contradicted by the docs: + +- **`BlockRenderer`** (not `BlocksManager`) creates `BlockToolAdapter` instances in response to `BlockAddedEvent`. +- **`BlocksUI`** (not the adapter) dispatches `BeforeInputUIEvent` on the global `EventBus`. +- **`SelectionManager.applyInlineToolForCurrentSelection()`** calls `model.format()` / `model.unformat()` directly — it does not delegate to `FormattingAdapter`. `FormattingAdapter` handles DOM re-rendering only. +- **`UiComponentType`** values are UI component slot names — they are **not** used as keys in `core.use()`. `core.use()` uses `ToolType` and `PluginType` values. +- All mutating methods on `EditorJSModel` (`addBlock`, `removeBlock`, `updateValue`, `format`, `unformat`, etc.) require `userId` as their **first** argument. +- `EditorJSModel.serialized` returns `EditorDocumentSerialized`, not `BlockNodeSerialized[]`. +- `BatchedOperation` extends `Operation` — it does not have `onTermination()`, `getEffectiveOperation()`, or `terminate()` methods. + +--- + +## Output expectations + +- Only edit files that need changing. Do not reformat or rewrite sections that are already correct. +- Commit message (if applicable): `docs: update for `. +- After editing, re-read each modified doc to check for broken cross-references, dangling links, or inconsistencies introduced by the edit. + diff --git a/README.md b/README.md index ff0450bf..f21ce042 100644 --- a/README.md +++ b/README.md @@ -1 +1,47 @@ # @editorjs/document-model + +A model-driven, collaboration-ready Editor.js engine split into focused packages. + +## Packages + +| Package | Description | +|---|---| +| [`@editorjs/sdk`](packages/sdk) | Shared contracts — interfaces, base event classes, `EventBus` | +| [`@editorjs/model`](packages/model) | In-memory document model (`EditorJSModel`, `BlockNode`, `TextNode`, caret management) | +| [`@editorjs/dom-adapters`](packages/dom-adapters) | Binds model nodes to DOM inputs (`DOMBlockToolAdapter`, `CaretAdapter`, `FormattingAdapter`) | +| [`@editorjs/collaboration-manager`](packages/collaboration-manager) | Operational transformation, batching, undo/redo, OT WebSocket client | +| [`@editorjs/core`](packages/core) | Orchestrator — IoC container, plugin/tool lifecycle, `EditorAPI` | +| [`@editorjs/ui`](packages/ui) | Default UI shell (`EditorjsUI`, `BlocksUI`, `Toolbar`, `InlineToolbar`, `Toolbox`) | +| [`@editorjs/ot-server`](packages/ot-server) | Standalone WebSocket OT server (`OTServer`, `DocumentManager`) | +| [`playground`](packages/playground) | Vite dev sandbox for manual testing | + +## Documentation + +In-depth architecture, flow, and API docs live in [`docs/`](docs/README.md). + +Quick links: +- [Architecture overview](docs/architecture.md) +- [Data model](docs/model.md) +- [Input handling & caret](docs/input-handling.md) +- [Plugins & Tools](docs/plugins.md) +- [Collaboration & Undo/Redo](docs/collaboration.md) +- [Event system](docs/events.md) + +## Development + +```bash +# Install all package dependencies +yarn install + +# Build all packages +yarn workspaces run build + +# Run tests for a specific package (e.g. model) +cd packages/model && yarn test + +# Start the playground +cd packages/playground && yarn dev + +# Start the OT server (Docker) +docker compose up +``` diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..891489e3 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,67 @@ +# How the Editor Works + +This folder documents how the editor is wired end to end, with short pages that keep one concern each. + +Read by goal: +- System boundaries: [Architecture](architecture.md) +- Document structures and mutation API: [Data Model](model.md) +- Typing, caret, formatting pipeline: [Input Handling](input-handling.md) +- Registration and lifecycle contracts: [Plugins & Tools](plugins.md) +- OT, batching, undo/redo: [Collaboration](collaboration.md) +- Which event bus to listen to: [Events](events.md) + +--- + +## Mental model in 90 seconds + +Five core parts: +1. `Core` owns startup and dependency wiring. +2. `EditorJSModel` is the source of truth for document state. +3. DOM adapters map model changes to concrete DOM inputs. +4. Tools/plugins add behavior through stable interfaces. +5. `CollaborationManager` translates model changes into OT operations. + +Two event transports (never mixed): +- Model events on `EditorJSModel` +- Core/UI events on the `EventBus` held by the IoC container + +--- + +## Lifecycle (from `new Core()` to live editor) + +1. `new Core(config)` binds IoC services and built-ins. +2. `core.use(...)` registers UI components/plugins by `plugin.type`. +3. `core.initialize()` initializes the adapter plugin (`DOMAdapters` or a custom replacement) first. +4. UI plugins are instantiated (`EditorjsPlugin` instances, e.g. `EditorjsUI`). +5. Tools are prepared and announced with `ToolLoadedCoreEvent`. +6. Initial document is inserted into `EditorJSModel`; `BlockRenderer` reacts to each `BlockAddedEvent` to create a `BlockToolAdapter`, render the tool, and emit `BlockAddedCoreEvent`. +7. Collaboration manager connects (if server config is provided). + +--- + +## One keystroke, full path + +1. Browser fires `beforeinput` inside the `contenteditable` blocks holder. +2. `BlocksUI` (the `@editorjs/ui` blocks component) intercepts it, wraps it in `BeforeInputUIEvent`, and dispatches it on the global `EventBus`. +3. `DOMBlockToolAdapter` listens on the `EventBus` for `BeforeInputUIEvent` and calls `model.insertText(...)`. +4. Model mutates and emits `TextAddedEvent`. +5. `DOMBlockToolAdapter` updates the affected DOM range. +6. `CollaborationManager` converts the event to an `Operation`, adds it to the current `BatchedOperation`, and resets the debounce timer. +7. Browser `selectionchange` fires; `CaretAdapter` builds an `Index` and updates the model caret. +8. `SelectionManager` emits `SelectionChangedCoreEvent`; `CaretAdapter` restores DOM selection from the model index if needed. + +The system stays decoupled because each step communicates through interfaces and events, not direct cross-component calls. + +--- + +## Canonical terms + +- `EditorjsPlugin`: general UI/behavior plugin registered via `core.use()` with `PluginType.Plugin`. +- `UiComponentType`: reserved string keys for UI component slots (`shell`, `blocks`, `inline-toolbar`, `toolbox`, `toolbar`). These name components in the UI layer but are **not** used as arguments to `core.use()` — plugins are registered by `PluginType` or `ToolType` values. +- `BlockTool` / `InlineTool` / `BlockTune`: tool contracts provided via config and prepared during `initialize()`. +- `Index`: serializable location in the document tree, independent of DOM nodes. Fields: `documentId`, `blockIndex`, `dataKey`, `textRange`, `tuneName`, `tuneKey`, `propertyName`. A `compositeSegments` array holds multiple per-input text indices for cross-block selections. Built with `IndexBuilder`; serialized to a compact string for caret storage and OT operations. +- `DataKey`: branded string identifying a data slot inside a `BlockNode` (e.g. `"text"`, `"caption"`). Created via `createDataKey()`. +- `BatchedOperation`: groups rapid single-character inserts or deletes on the same data key into one logical edit for undo/redo. Lives in `@editorjs/collaboration-manager`. +- `InputsRegistry`: shared map of `(blockIndex, dataKey) → HTMLElement` maintained by `DOMAdapters`. Both `DOMBlockToolAdapter` and `CaretAdapter` read from it. +- `BlockRenderer`: internal `@editorjs/core` component that subscribes to `BlockAddedEvent`/`BlockRemovedEvent` and creates/tears down `BlockToolAdapter` instances. Not to be confused with `BlocksManager` which handles the programmatic insert/delete/move API. +- `CaretManager`: owns one `Caret` per collaborating user. Dispatches `CaretManagerCaretUpdatedEvent` on `EditorJSModel` when any caret changes. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 00000000..1eb66ba0 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,41 @@ +# Architecture Overview + +The editor is split into eight packages in a layered dependency direction. + +| Package | Role | +|---|---| +| `@editorjs/sdk` | Shared contracts — interfaces, base event classes, `EventBus` | +| `@editorjs/model` | In-memory document model (`EditorJSModel`) | +| `@editorjs/dom-adapters` | Binds model nodes to DOM inputs; default adapter implementation | +| `@editorjs/collaboration-manager` | Operational transformation, batching, undo/redo, OT WebSocket client | +| `@editorjs/core` | Orchestrator — IoC container, plugin/tool lifecycle, `EditorAPI` | +| `@editorjs/ui` | Default UI shell — `EditorjsUI`, `BlocksUI`, `Toolbar`, `InlineToolbar`, `Toolbox` | +| `@editorjs/ot-server` | Standalone WebSocket OT server — `OTServer`, `DocumentManager` | +| `playground` | Vite dev sandbox; not published | + +## Dependency rules + +- `sdk` is the contract layer all other packages depend on. +- `core` wires runtime dependencies; it should be the only orchestrator. +- `model` does not depend on DOM concerns. +- `dom-adapters` and `collaboration-manager` observe/apply model changes through public APIs and events. +- `ui` depends on `sdk` only; it is registered as an `EditorjsPlugin` via `core.use()`. +- `ot-server` depends on `collaboration-manager` (for `Operation` / message types) and `model`; it runs server-side only. + +## Runtime ownership + +`Core` is the entry point and owner of service wiring. Most services are wired in the constructor; `core.use(...)` registers UI plugins and tools; `initialize()` prepares tools, initializes the model, and starts collaboration. + +### `@editorjs/ui` role + +`BlocksUI` owns the `contenteditable` blocks holder. It intercepts browser `beforeinput` events, normalises them into `BeforeInputUIEvent`, and dispatches them on the global `EventBus`. It also listens for `BlockAddedCoreEvent` / `BlockRemovedCoreEvent` to insert/remove rendered block elements in the DOM. + +### `@editorjs/ot-server` role + +`OTServer` is a standalone Node.js WebSocket server. It maintains one `DocumentManager` per `documentId`. On each incoming `Operation` message it transforms the operation against any conflicting operations (ops with a higher or equal revision number), bumps the revision, applies the result to its own `EditorJSModel` copy, and broadcasts the transformed operation to all connected clients for that document. + +Direct cross-layer coupling should be avoided: use interfaces/events from `sdk` and mutation APIs from `EditorJSModel`. + +→ [`diagrams/architecture-overview.mmd`](diagrams/architecture-overview.mmd) + +_Package boundaries and integration contracts. Keep this as the high-level map; see other docs for per-subsystem flow details._ diff --git a/docs/collaboration.md b/docs/collaboration.md new file mode 100644 index 00000000..ff7fbcef --- /dev/null +++ b/docs/collaboration.md @@ -0,0 +1,61 @@ +# Collaboration & Undo/Redo + +## Architecture + +`CollaborationManager` bridges `EditorJSModel` and `OTClient` (WebSocket client): + +1. Converts local model changes into `Operation` and sends them. +2. Applies incoming remote operations back to the model. +3. Batches rapid operations and forwards completed batches to `UndoRedoManager`. + +## Operational transformation + +When concurrent edits start from revision `N`, reconciliation is two-step: + +- **Step 1**: transform each pending local op against the incoming remote op. +- **Step 2**: transform the remote op against pending local ops before applying locally. + +This preserves convergence regardless of arrival order. + + +→ [`diagrams/collaboration-ot-flow.mmd`](diagrams/collaboration-ot-flow.mmd) + +_Handshake, local send/ack path, remote OT transform path, then local apply._ + +## Wire protocol + +`OTClient` and `OTServer` exchange JSON messages over WebSocket. Every message has the shape: + +```json +{ "type": "", "payload": { ... } } +``` + +### Message types + +| `type` | Direction | Payload fields | Description | +|---|---|---|---| +| `Handshake` | Client → Server | `document` (DocumentId), `userId`, `rev`, `data?` (EditorDocumentSerialized) | Connect to a document. Client sends its current state on first connect. | +| `Handshake` | Server → Client | `document`, `userId`, `rev`, `data?` | Server echoes back. If the server already has the document, `data` contains the authoritative state and the client should call `initializeDocument(data)`. | +| `Operation` | Client → Server | Serialized `Operation` (`type`, `index`, `data`, `userId`, `rev`) | A local operation to apply. | +| `Operation` | Server → All clients | Serialized transformed `Operation` | Server broadcasts the transformed operation to every client in the document room (including the author — the author's copy serves as an ack). | + +### Server-side OT + +`OTServer` maintains one `DocumentManager` per `documentId`. On receiving an `Operation`: +1. If `operation.rev` is ahead of the server's current revision the operation is rejected (WebSocket closed with code `4400`). +2. Conflicting operations (all ops with `rev >= operation.rev`) are fetched and the incoming op is transform-reduced through them. +3. The transformed op is applied to the server's `EditorJSModel` copy and the revision counter is incremented. +4. The transformed op (with the new `rev`) is broadcast to all clients in the document room. + +Operations are processed sequentially per document — `DocumentManager` queues them so a new op always awaits the previous one. + +## Undo / Redo + +`BatchedOperation` groups rapid edits with debounce and terminates on timeout or incompatible operation type. + +`UndoRedoManager` stores completed batches. Undo/redo invert and apply operations while event re-recording is disabled (`shouldHandleEvents = false`) to avoid stack pollution. + + +→ [`diagrams/undo-redo-flow.mmd`](diagrams/undo-redo-flow.mmd) + +_Rapid edits are merged into one logical batch; undo/redo replay inverses of that effective operation._ diff --git a/docs/diagrams/architecture-overview.mmd b/docs/diagrams/architecture-overview.mmd new file mode 100644 index 00000000..33d1b981 --- /dev/null +++ b/docs/diagrams/architecture-overview.mmd @@ -0,0 +1,124 @@ +--- +title: Architecture Overview +config: + theme: neutral +--- +%% See: ../architecture.md +classDiagram + direction LR + + + %% Layer 0 — Contracts (@editorjs/sdk) + namespace sdk { + + class EventBus { + <> + +addEventListener(type, callback) + +removeEventListener(type, callback) + +dispatchEvent(event) + } + + class BlockToolAdapter { + <> + +attachInput(keyRaw, input: HTMLElement) + } + + class BlockTool { + <> + +render(): HTMLElement + +validate?(data): boolean + +destroy?() + } + + class EditorjsPlugin { + <> + +destroy?() + } + + class EditorjsPluginConstructor { + <> + +type: string + +new(params: EditorjsPluginParams): EditorjsPlugin + } + + class EditorJSAdapterPlugin { + <> + +createBlockToolAdapter(blockIndex, toolName): BlockToolAdapter + } + + class EditorjsAdapterPluginConstructor { + <> + +type: string + +new(params: EditorjsAdapterPluginParams): EditorJSAdapterPlugin + } + } + + %% Layer 1 — Data model (@editorjs/model) + namespace model { + class EditorJSModel { + +serialized: EditorDocumentSerialized + +initializeDocument(config) + +addBlock(userId, blockData, index?) + +removeBlock(userId, index) + +insertText(userId, blockIndex, dataKey, text, start?) + +removeText(userId, blockIndex, dataKey, start, end?) + +format(userId, blockIndex, dataKey, tool, start, end, data?) + +getFragments(blockIndex, dataKey, start?, end?, tool?) + +createCaret(userId): Caret + +getCaret(userId): Caret + +updateCaret(userId, caret) + } + } + + %% Layer 2a — DOM binding (@editorjs/dom-adapters) + namespace domAdapters { + class DOMAdapters { + +createBlockToolAdapter(blockIndex, toolName): BlockToolAdapter + } + } + + %% Layer 2b — Collaboration (@editorjs/collaboration-manager) + namespace collaborationManager { + class CollaborationManager { + +connect() + +applyOperation(operation) + +undo() + +redo() + } + } + + %% Layer 3 — Orchestrator (@editorjs/core) + namespace core { + class Core { + +constructor(config) + +initialize() + +use(plugin): Core + } + } + + %% Infrastructure relations + EditorJSModel --|> EventBus : extends + Core *-- EventBus : creates & holds + + %% Core — owns & wires + Core *-- EditorJSModel + Core *-- CollaborationManager + Core ..> EditorjsPluginConstructor : registers via use() + Core ..> EditorjsAdapterPluginConstructor : registers via use() + + %% Model interactions + CollaborationManager --> EditorJSModel : listens / applies ops + BlockToolAdapter --> EditorJSModel : syncs DOM↔model + + %% Tool contracts + BlockTool ..> BlockToolAdapter : receives via constructor + + %% Plugin contracts + EditorjsPluginConstructor ..> EditorjsPlugin : creates + EditorjsPlugin ..> EventBus : receives + + %% Adapter plugin contracts + EditorjsAdapterPluginConstructor ..> EditorJSAdapterPlugin : creates + DOMAdapters ..|> EditorJSAdapterPlugin : implements + EditorJSAdapterPlugin ..> BlockToolAdapter : creates + EditorJSAdapterPlugin ..> EventBus : receives diff --git a/docs/diagrams/block-adapter-input-flow.mmd b/docs/diagrams/block-adapter-input-flow.mmd new file mode 100644 index 00000000..5b05a727 --- /dev/null +++ b/docs/diagrams/block-adapter-input-flow.mmd @@ -0,0 +1,53 @@ +--- +title: Block Adapter Input Flow +config: + theme: neutral +--- +%% See: ../input-handling.md +sequenceDiagram + actor User + participant BlocksManager + participant EditorJSModel + participant BlockRenderer + participant DOMAdapters + participant InputsRegistry + participant DOMBlockToolAdapter + participant BlockTool + participant EventBus + + %% ── Block insertion & adapter creation ────────── + User->>BlocksManager: insert(toolName, data, index) + BlocksManager->>EditorJSModel: addBlock(userId, data, index) + EditorJSModel-->>BlockRenderer: BlockAddedEvent + BlockRenderer->>DOMAdapters: createBlockToolAdapter(blockIndex, toolName) + DOMAdapters->>InputsRegistry: insertBlock(blockIndex) + DOMAdapters->>DOMBlockToolAdapter: resolve from IoC container + DOMAdapters->>DOMBlockToolAdapter: setBlockIndex(blockIndex), setToolName(toolName) + BlockRenderer->>BlockTool: tool.create({ adapter, data }) + BlockTool->>BlockTool: render() + BlockTool->>DOMBlockToolAdapter: createDataNode(key, initialData) + DOMBlockToolAdapter->>EditorJSModel: createDataNode(userId, blockIndex, key, data) + BlockTool->>DOMBlockToolAdapter: attachInput(key, inputElement) + DOMBlockToolAdapter->>InputsRegistry: register(blockIndex, key, inputElement) + BlockRenderer->>EventBus: dispatch BlockAddedCoreEvent (ui element) + + %% ── User types (text node) ─────────────────────── + User->>EventBus: BeforeInputUIEvent (delegated from DOM) + EventBus-->>DOMBlockToolAdapter: BeforeInputUIEvent + DOMBlockToolAdapter->>EditorJSModel: insertText(userId, blockIndex, key, text, start) + EditorJSModel-->>DOMBlockToolAdapter: TextAddedEvent + DOMBlockToolAdapter->>DOMBlockToolAdapter: update DOM range + + %% ── Value node registration ────────────────────── + BlockTool->>DOMBlockToolAdapter: createDataNode(key, initialValue) + DOMBlockToolAdapter->>EditorJSModel: createDataNode(userId, blockIndex, key, data) + EditorJSModel-->>DOMBlockToolAdapter: DataNodeAddedEvent + DOMBlockToolAdapter-->>BlockTool: KeyAddedEvent + + %% ── Tool updates value node ────────────────────── + BlockTool->>DOMBlockToolAdapter: updateValue(key, newValue) + DOMBlockToolAdapter->>EditorJSModel: updateValue(userId, blockIndex, key, newValue) + EditorJSModel-->>DOMBlockToolAdapter: ValueModifiedEvent + DOMBlockToolAdapter-->>BlockTool: ValueNodeChangedEvent + + diff --git a/docs/diagrams/caret-selection-flow.mmd b/docs/diagrams/caret-selection-flow.mmd new file mode 100644 index 00000000..f330106d --- /dev/null +++ b/docs/diagrams/caret-selection-flow.mmd @@ -0,0 +1,45 @@ +--- +title: Caret & Selection Flow +config: + theme: neutral +--- +%% See: ../input-handling.md +sequenceDiagram + actor User + participant DOM + participant InputsRegistry + participant CaretAdapter + participant EditorJSModel + participant SelectionManager + participant ToolsManager + participant EventBus + + %% ── User moves caret / changes selection ──────────── + User->>DOM: click or keyboard navigation + DOM->>CaretAdapter: selectionchange event + CaretAdapter->>InputsRegistry: entries() — iterate all registered inputs + CaretAdapter->>CaretAdapter: clip selection range per input, build Index per segment + CaretAdapter->>CaretAdapter: sort segments in document order + CaretAdapter->>EditorJSModel: caret.update(index or compositeIndex) + + %% ── Model dispatches caret event ──────────────────── + EditorJSModel-->>SelectionManager: CaretManagerCaretUpdatedEvent (local userId) + EditorJSModel-->>CaretAdapter: CaretManagerCaretUpdatedEvent (local userId) + + %% ── SelectionManager computes available tools ─────── + SelectionManager->>EditorJSModel: getFragments per segment + EditorJSModel-->>SelectionManager: InlineFragment[] + SelectionManager->>ToolsManager: inlineTools.entries() — create instances on-demand + SelectionManager->>EventBus: dispatch SelectionChangedCoreEvent(index, availableInlineTools, fragments) + + %% ── CaretAdapter restores DOM selection (round-trip) ─ + CaretAdapter->>InputsRegistry: findInput(blockIndex, dataKey) + InputsRegistry-->>CaretAdapter: HTMLElement + CaretAdapter->>CaretAdapter: compare current DOM selection with model index + alt selection differs + CaretAdapter->>DOM: selection.addRange(range) + end + + %% ── Remote caret (collaborator) ───────────────────── + EditorJSModel-->>CaretAdapter: CaretManagerCaretUpdatedEvent (remote userId) + diff --git a/docs/diagrams/collaboration-ot-flow.mmd b/docs/diagrams/collaboration-ot-flow.mmd new file mode 100644 index 00000000..11565d42 --- /dev/null +++ b/docs/diagrams/collaboration-ot-flow.mmd @@ -0,0 +1,90 @@ +--- +title: Collaboration OT Flow +config: + theme: neutral +--- +%% See: ../collaboration.md +sequenceDiagram + actor LocalUser as Local User + participant EditorJSModel + participant CollaborationManager + participant UndoRedoManager + participant OTClient + participant OTServer as OT Server + actor RemoteUser as Remote User + + %% ── Connect ────────────────────────────────────── + LocalUser->>CollaborationManager: connect() + CollaborationManager->>OTClient: new OTClient(serverAddr, userId) + OTClient->>OTServer: WebSocket open + CollaborationManager->>OTClient: connectDocument(model.serialized) + OTClient->>OTServer: Handshake(documentId, userId, rev, data) + OTServer-->>OTClient: Handshake(data?) + alt server has newer document state + OTClient->>EditorJSModel: initializeDocument(data) + end + + %% ── Local edit → server ────────────────────────── + LocalUser->>EditorJSModel: insertText, addBlock, etc. + EditorJSModel-->>CollaborationManager: TextAddedEvent, BlockAddedEvent, etc. + CollaborationManager->>CollaborationManager: build Operation from event + CollaborationManager->>OTClient: send(operation) + OTClient->>OTServer: Operation(type, index, payload, userId, rev) + OTServer-->>OTClient: Ack(userId, rev) + + %% ── Batch & undo stack ─────────────────────────── + CollaborationManager->>CollaborationManager: create/grow BatchedOperation, reset debounce timer + Note over CollaborationManager: debounce timeout fires (or incompatible op / remote op received) + CollaborationManager->>UndoRedoManager: put(currentBatch) + + %% ── Remote edit → local model ──────────────────── + RemoteUser->>OTServer: Operation(type, index, payload, userId, rev) + OTServer-->>OTClient: Operation(type, index, payload, userId, rev) + OTClient->>OTClient: transform against pending operations (OT) + OTClient->>CollaborationManager: onRemoteOperation(transformedOp) + CollaborationManager->>EditorJSModel: insertData, removeData, modifyData + + %% ── Undo ───────────────────────────────────────── + LocalUser->>CollaborationManager: undo() + Note over CollaborationManager: #putBatchToUndo() — flush currentBatch + CollaborationManager->>UndoRedoManager: put(currentBatch) + CollaborationManager->>UndoRedoManager: undo() + UndoRedoManager-->>CollaborationManager: invertedOperation + CollaborationManager->>EditorJSModel: applyOperation(invertedOperation) + + %% ══════════════════════════════════════════════════ + %% Operational Transformation: concurrent edit scenario + %% ══════════════════════════════════════════════════ + %% Both users start at the same document revision N. + %% User A types before receiving User B's operation, + %% so their ops are based on the same state and must be transformed. + + rect rgb(235, 245, 255) + Note over LocalUser, RemoteUser: OT: concurrent edits (both at rev N) + + LocalUser->>OTClient: send Op-A (e.g. insert "x" at pos 5, rev N) + OTClient->>OTServer: Op-A(Insert, pos=5, rev=N) + Note over OTClient: Op-A is now in pendingOperations + + RemoteUser->>OTServer: Op-B(Insert, pos=3, rev=N) + + OTServer->>OTServer: apply Op-B first (wins the race), rev = N+1 + OTServer-->>OTClient: broadcast Op-B(Insert, pos=3, rev=N+1) + + Note over OTClient: Op-A is still pending (no Ack yet) + + Note over OTClient: Step 1 — transform each pending op against the remote op,
so pending ops stay valid on top of the new server state:
Op-A' = Op-A.transform(Op-B) → pos 5 shifts to 6 + OTClient->>OTClient: Op-A (pending) = Op-A.transform(Op-B) + + Note over OTClient: Step 2 — transform the remote op against each pending op
in sequence, so it applies correctly to local state:
Op-B' = Op-B.transform(Op-A) → pos 3 unchanged (before pos 5) + OTClient->>OTClient: Op-B' = Op-B.transform(Op-A) + + OTClient->>CollaborationManager: onRemoteOperation(Op-B') + CollaborationManager->>EditorJSModel: insertData at pos 3 (Op-B') + + OTServer->>OTServer: transform Op-A against Op-B, apply, rev = N+2 + OTServer-->>OTClient: Ack Op-A(rev=N+2) + Note over OTClient: pendingOperations cleared, rev = N+2 + end + + diff --git a/docs/diagrams/events-catalog.mmd b/docs/diagrams/events-catalog.mmd new file mode 100644 index 00000000..ce1fc572 --- /dev/null +++ b/docs/diagrams/events-catalog.mmd @@ -0,0 +1,178 @@ +--- +title: Events Catalog +config: + theme: neutral + layout: dagre +--- +%% See: ../events.md +classDiagram + + direction LR + + %% ── Base classes ───────────────────────────────── + class BaseDocumentEvent { + <> + detail.index: Index + detail.action: EventAction + detail.data: unknown + detail.userId: string or number + } + + class CoreEventBase { + <> + detail: Payload + } + + class UIEventBase { + <> + detail: Payload + } + + %% ── Event Dispatchers ──────────────────────────── + class EditorJSModel { + <> + } + + class CaretManager { + <> + } + + class CoreEventBus["Core EventBus"] { + <> + } + + class BlockToolAdapterBus["BlockToolAdapter"] { + <> + } + + %% ── @editorjs/model ────────────────────────────── + namespace model { + class BlockAddedEvent { + detail.data: BlockNodeSerialized + } + class BlockRemovedEvent { + detail.data: BlockNodeSerialized + } + class TextAddedEvent { + detail.data: string + } + class TextRemovedEvent { + detail.data: string + } + class TextFormattedEvent { + detail.data.tool: InlineToolName + detail.data.data: InlineToolData + } + class TextUnformattedEvent { + detail.data.tool: InlineToolName + detail.data.data: InlineToolData + } + class DataNodeAddedEvent { + detail.data: BlockNodeDataSerializedValue + } + class DataNodeRemovedEvent { + detail.data: BlockNodeDataSerializedValue + } + class ValueModifiedEvent { + detail.data.value: T + detail.data.previous: T + } + class TuneModifiedEvent { + detail.data.value: T + detail.data.previous: T + } + class CaretManagerCaretUpdatedEvent { + <> + detail.index: SerializedIndex or null + detail.userId: string or number + } + class CaretManagerCaretAddedEvent { + <> + detail.index: SerializedIndex + detail.userId: string or number + } + class CaretManagerCaretRemovedEvent { + <> + detail.userId: string or number + } + } + + %% ── @editorjs/sdk — EventBus ───────────────────── + namespace sdk { + class BlockAddedCoreEvent { + detail.tool: string + detail.data: BlockToolData + detail.index: number + detail.ui: HTMLElement + } + class BlockRemovedCoreEvent { + detail.tool: string + detail.index: number + } + class ToolLoadedCoreEvent { + detail.tool: ToolFacadeClass + } + class SelectionChangedCoreEvent { + detail.index: Index or null + detail.availableInlineTools: Map~InlineToolName, InlineTool~ + detail.fragments: InlineFragment[] + } + class UndoCoreEvent { + detail: undefined + } + class RedoCoreEvent { + detail: undefined + } + class BeforeInputUIEvent { + detail.data: string + detail.inputType: string + detail.targetRanges: StaticRange[] + detail.isCrossInputSelection: boolean + } + } + + %% ── @editorjs/sdk — BlockToolAdapter (per-block) ─ + namespace adapter { + class KeyAddedEvent { + detail: string + } + class KeyRemovedEvent { + detail: string + } + class ValueNodeChangedEvent { + detail.key: string + detail.value: unknown + } + } + + %% ── Inheritance ────────────────────────────────── + BlockAddedEvent --|> BaseDocumentEvent + BlockRemovedEvent --|> BaseDocumentEvent + TextAddedEvent --|> BaseDocumentEvent + TextRemovedEvent --|> BaseDocumentEvent + TextFormattedEvent --|> BaseDocumentEvent + TextUnformattedEvent --|> BaseDocumentEvent + DataNodeAddedEvent --|> BaseDocumentEvent + DataNodeRemovedEvent --|> BaseDocumentEvent + ValueModifiedEvent --|> BaseDocumentEvent + TuneModifiedEvent --|> BaseDocumentEvent + + BlockAddedCoreEvent --|> CoreEventBase + BlockRemovedCoreEvent --|> CoreEventBase + ToolLoadedCoreEvent --|> CoreEventBase + SelectionChangedCoreEvent --|> CoreEventBase + UndoCoreEvent --|> CoreEventBase + RedoCoreEvent --|> CoreEventBase + + BeforeInputUIEvent --|> UIEventBase + + %% ── Dispatchers ────────────────────────────────── + BaseDocumentEvent ..> EditorJSModel : dispatched on + CaretManagerCaretUpdatedEvent ..> EditorJSModel : dispatched on EventType.CaretManagerUpdated + CaretManagerCaretAddedEvent ..> EditorJSModel : dispatched on EventType.CaretManagerUpdated + CaretManagerCaretRemovedEvent ..> EditorJSModel : dispatched on EventType.CaretManagerUpdated + CoreEventBase ..> CoreEventBus : dispatched on + UIEventBase ..> CoreEventBus : dispatched on + KeyAddedEvent ..> BlockToolAdapterBus : dispatched on + KeyRemovedEvent ..> BlockToolAdapterBus : dispatched on + ValueNodeChangedEvent ..> BlockToolAdapterBus : dispatched on diff --git a/docs/diagrams/inline-formatting-flow.mmd b/docs/diagrams/inline-formatting-flow.mmd new file mode 100644 index 00000000..d38e1467 --- /dev/null +++ b/docs/diagrams/inline-formatting-flow.mmd @@ -0,0 +1,58 @@ +--- +title: Inline Formatting Flow +config: + theme: neutral +--- +%% See: ../input-handling.md +sequenceDiagram + participant EventBus + participant ToolsManager + participant FormattingAdapter + participant SelectionManager + participant EditorJSModel + participant CaretAdapter + participant InputsRegistry + participant InlineTool + participant DOM + actor User + + %% ── Setup: FormattingAdapter caches tool instances ── + EventBus-->>FormattingAdapter: ToolLoadedCoreEvent (inline tool) + FormattingAdapter->>InlineTool: tool.create() + FormattingAdapter->>FormattingAdapter: attachTool(name, instance) + + %% ── User applies inline tool ───────────────────────── + User->>SelectionManager: applyInlineToolForCurrentSelection(toolName, data) + SelectionManager->>EditorJSModel: getCaret(userId) + EditorJSModel-->>SelectionManager: Caret (index with segments) + SelectionManager->>ToolsManager: inlineTools.get(toolName).create() + ToolsManager-->>SelectionManager: InlineTool instance + + loop for each text segment in composite index + SelectionManager->>EditorJSModel: getFragments(blockIndex, dataKey, start, end, toolName) + EditorJSModel-->>SelectionManager: InlineFragment[] + SelectionManager->>InlineTool: getFormattingOptions(textRange, fragments) + InlineTool-->>SelectionManager: action (Format or Unformat), range + alt Format + SelectionManager->>EditorJSModel: format(userId, blockIndex, dataKey, toolName, start, end, data) + else Unformat + SelectionManager->>EditorJSModel: unformat(userId, blockIndex, dataKey, toolName, start, end) + end + end + + %% ── Model event → FormattingAdapter re-renders DOM ── + EditorJSModel-->>FormattingAdapter: TextFormattedEvent or TextUnformattedEvent + FormattingAdapter->>CaretAdapter: findInput(blockIndex, dataKey) + CaretAdapter->>InputsRegistry: getInput(blockIndex, dataKey) + InputsRegistry-->>CaretAdapter: HTMLElement + CaretAdapter-->>FormattingAdapter: HTMLElement (input) + FormattingAdapter->>EditorJSModel: getFragments(blockIndex, dataKey, rangeStart, rangeEnd) + EditorJSModel-->>FormattingAdapter: affected InlineFragment[] + FormattingAdapter->>FormattingAdapter: rerenderRange — clear range, re-wrap all fragments + loop for each affected fragment + FormattingAdapter->>InlineTool: createWrapper(toolData) + InlineTool-->>FormattingAdapter: HTMLElement wrapper + FormattingAdapter->>DOM: surround(wrapper, input, range) + end + FormattingAdapter->>CaretAdapter: updateIndex(index, userId) + CaretAdapter->>DOM: restore caret position diff --git a/docs/diagrams/model-tree-structure.mmd b/docs/diagrams/model-tree-structure.mmd new file mode 100644 index 00000000..5e092040 --- /dev/null +++ b/docs/diagrams/model-tree-structure.mmd @@ -0,0 +1,149 @@ +--- +title: Model Tree Structure +config: + theme: neutral +--- +%% See: ../model.md +classDiagram + direction TB + + + %% ── Document tree ──────────────────────────────── + class EditorJSModel { + <> + -document: EditorDocument + -caretManager: CaretManager + -currentUserId: string or number + +length: number + +serialized: EditorDocumentSerialized + +addBlock(userId, data, index?) + +removeBlock(userId, index) + +updateValue(userId, blockIndex, dataKey, value) + +format(userId, blockIndex, dataKey, ...) + +unformat(userId, blockIndex, dataKey, ...) + +createCaret(userId, index?): Caret + +getCaret(userId): Caret + +removeCaret(userId) + } + + class EditorDocument { + +identifier: DocumentId + +children: BlockNode[] + +length: number + +initialize(blocks) + +addBlock(data, index?) + +removeBlock(index) + +updateValue(blockIndex, dataKey, value) + } + + class BlockNode { + +name: BlockToolName + +data: BlockNodeData + +tunes: Record~BlockTuneName, BlockTune~ + +parent: EditorDocument + +createDataNode(key, data) + +removeDataNode(key) + +updateValue(key, value) + +getText(key): string + +getFragments(key, ...): InlineFragment[] + } + + class BlockTune { + +name: BlockTuneName + +data: Record~string, unknown~ + +update(key, value) + } + + class TextNode { + +length: number + +getText(start?, end?): string + +insertText(text, index?) + +removeText(start?, end?) + +format(tool, start, end, data?) + +unformat(tool, start, end) + +getFragments(start?, end?, tool?): InlineFragment[] + } + + class ValueNode~T~ { + +value: T + +update(value: T) + } + + class ParentInlineNode { + <> + +children: InlineNode[] + +insertText(text, index?) + +removeText(start?, end?) + +format(tool, start, end, data?) + +unformat(tool, start, end) + +getFragments(start?, end?): InlineFragment[] + } + + class FormattingInlineNode { + +tool: InlineToolName + +data?: InlineToolData + +format(tool, start, end, data?) + +unformat(tool, start, end) + +split(index): FormattingInlineNode + } + + class TextInlineNode { + +value: string + +length: number + +insertText(text, index?) + +removeText(start?, end?) + +format(tool, start, end, data?): InlineNode[] + } + + %% ── Caret management ───────────────────────────── + class CaretManager { + <> + -registry: Map~userId, Caret~ + +getCaret(userId): Caret + +createCaret(userId, index?): Caret + +updateCaret(caret) + +removeCaret(caret) + } + + class Caret { + <> + -userId: string or number + -index: Index or null + +update(index: Index or null) + +toJSON(): CaretSerialized + } + + %% ── Base ───────────────────────────────────────── + class EventBus { + <> + } + + %% EventBus inheritance + CaretManager --|> EventBus + Caret --|> EventBus + EditorJSModel --|> EventBus + EditorDocument --|> EventBus + BlockNode --|> EventBus + BlockTune --|> EventBus + ValueNode --|> EventBus + ParentInlineNode --|> EventBus + + %% Document composition + EditorJSModel "1" *-- "1" EditorDocument : document + + %% Tree structure + EditorDocument "1" *-- "0..*" BlockNode : children + BlockNode "1" *-- "0..*" TextNode : data[key] + BlockNode "1" *-- "0..*" ValueNode : data[key] + BlockNode "1" *-- "0..*" BlockTune : tunes[name] + + %% Inline tree + TextNode --|> ParentInlineNode + FormattingInlineNode --|> ParentInlineNode + ParentInlineNode "1" *-- "0..*" FormattingInlineNode : children + ParentInlineNode "1" *-- "0..*" TextInlineNode : children + + %% Caret management (declared last → placed right) + EditorJSModel "1" *-- "1" CaretManager : caretManager + CaretManager "1" *-- "0..*" Caret : registry + diff --git a/docs/diagrams/plugin-lifecycle-flow.mmd b/docs/diagrams/plugin-lifecycle-flow.mmd new file mode 100644 index 00000000..d42ab0fd --- /dev/null +++ b/docs/diagrams/plugin-lifecycle-flow.mmd @@ -0,0 +1,66 @@ +--- +title: Plugin Lifecycle Flow +config: + theme: neutral +--- +%% See: ../plugins.md +sequenceDiagram + actor Dev as Developer + participant Core + participant Plugins as plugins sub-container + participant IoC as IoC Container + participant ToolsManager + participant EventBus + participant EditorJSModel + participant BlockRenderer + participant Plugin as EditorjsPlugin + participant Adapter as EditorJSAdapterPlugin + participant Tool as BlockTool / InlineTool + + %% ── Constructor ────────────────────────────────── + Dev->>Core: new Core(config) + Core->>IoC: bind EditorConfig, EventBus, EditorJSModel, ToolsManager + Core->>Plugins: bind DOMAdapters, Paragraph, Bold, Italic, Link, ShortcutsPlugin + + %% ── Registration ───────────────────────────────── + + Dev->>Core: use(CustomAdapter) + Core->>Plugins: rebind PluginType.Adapter = CustomAdapter + + Dev->>Core: use(SomePlugin) + Core->>Plugins: bind PluginType.Plugin = SomePlugin + + Dev->>Core: use(SomeTool, settings) + Core->>Plugins: bind ToolType.Block / Inline / Tune = [SomeTool, settings] + + %% ── await initialize() ─────────────────────────── + Dev->>Core: await initialize() + + Core->>IoC: bind TOKENS.Adapter as toDynamicValue(ctx => new Adapter(model, config, api, eventBus)) + + Core->>Plugins: getAll(PluginType.Plugin) + Plugins-->>Core: [SomePlugin, ...] + Core->>Plugin: new SomePlugin(config, api, eventBus) + Plugin->>EventBus: wire event listeners + + Core->>Plugins: getAll(ToolType.Block / Inline / Tune) + Plugins-->>Core: [[SomeTool, settings], ...] + Core->>ToolsManager: prepareTools(blockTools, inlineTools, blockTunes) + loop for each tool + ToolsManager->>Tool: Tool.prepare(toolName, config) + ToolsManager->>ToolsManager: wrap in BlockToolFacade or InlineToolFacade + ToolsManager->>EventBus: dispatch ToolLoadedCoreEvent(facade) + end + + Core->>IoC: get SelectionManager, BlocksManager, BlockRenderer + + Core->>EditorJSModel: initializeDocument(blocks) + EditorJSModel-->>BlockRenderer: BlockAddedEvent (per block) + BlockRenderer->>Adapter: createBlockToolAdapter(blockIndex, toolName) + BlockRenderer->>Tool: facade.create(adapter, data) + + Core->>Core: collaborationManager.connect() + + %% ── Destroy ────────────────────────────────────── + Dev->>Plugin: plugin.destroy() + Plugin->>EventBus: removeEventListener (cleanup) diff --git a/docs/diagrams/undo-redo-flow.mmd b/docs/diagrams/undo-redo-flow.mmd new file mode 100644 index 00000000..06a31806 --- /dev/null +++ b/docs/diagrams/undo-redo-flow.mmd @@ -0,0 +1,62 @@ +--- +title: Undo / Redo Flow +config: + theme: neutral +--- +%% See: ../collaboration.md +sequenceDiagram + actor User + participant CollaborationManager + participant UndoRedoManager + participant OTClient + participant EditorJSModel + + %% ── Rapid typing — ops are batched ────────────── + User->>EditorJSModel: insertText "H" (op1) + EditorJSModel-->>CollaborationManager: TextAddedEvent + CollaborationManager->>OTClient: send(op1) + CollaborationManager->>CollaborationManager: #currentBatch = new BatchedOperation(op1), reset debounce + + User->>EditorJSModel: insertText "i" (op2) + EditorJSModel-->>CollaborationManager: TextAddedEvent + CollaborationManager->>OTClient: send(op2) + CollaborationManager->>CollaborationManager: currentBatch.add(op2), reset debounce + + User->>EditorJSModel: insertText "!" (op3) + EditorJSModel-->>CollaborationManager: TextAddedEvent + CollaborationManager->>OTClient: send(op3) + CollaborationManager->>CollaborationManager: currentBatch.add(op3), reset debounce + + %% ── Batch terminates on timeout ────────────────── + Note over CollaborationManager: debounce timeout fires + CollaborationManager->>UndoRedoManager: put(currentBatch) + + %% ── Batch terminated early by incompatible op ──── + User->>EditorJSModel: insertText "X" (opA — different block/key) + EditorJSModel-->>CollaborationManager: TextAddedEvent + Note over CollaborationManager: currentBatch.canAdd(opA) → false + CollaborationManager->>UndoRedoManager: put(currentBatch) + CollaborationManager->>CollaborationManager: #currentBatch = new BatchedOperation(opA) + + %% ── Undo ───────────────────────────────────────── + User->>CollaborationManager: undo() + Note over CollaborationManager: #putBatchToUndo() — flush currentBatch + CollaborationManager->>UndoRedoManager: put(currentBatch) + + CollaborationManager->>UndoRedoManager: undo() + UndoRedoManager->>UndoRedoManager: pop undoStack → op.inverse() → Delete "Hi!" + UndoRedoManager->>UndoRedoManager: push inverted op to redoStack + UndoRedoManager-->>CollaborationManager: invertedOp (Delete "Hi!") + + CollaborationManager->>EditorJSModel: applyOperation(invertedOp) + + %% ── Redo ───────────────────────────────────────── + User->>CollaborationManager: redo() + Note over CollaborationManager: #putBatchToUndo() — flush currentBatch + CollaborationManager->>UndoRedoManager: redo() + UndoRedoManager->>UndoRedoManager: pop redoStack → op.inverse() → Insert "Hi!" + UndoRedoManager->>UndoRedoManager: push inverted op back to undoStack + UndoRedoManager-->>CollaborationManager: invertedOp (Insert "Hi!") + + CollaborationManager->>EditorJSModel: applyOperation(invertedOp) + diff --git a/docs/events.md b/docs/events.md new file mode 100644 index 00000000..5d2ea57f --- /dev/null +++ b/docs/events.md @@ -0,0 +1,68 @@ +# Event System + +The editor exposes two public event transports. + +| Transport | Dispatched on | Event types | +|---|---|---| +| `EditorJSModel` `EventType.Changed` | `EditorJSModel` instance | `BlockAddedEvent`, `BlockRemovedEvent`, `TextAddedEvent`, `TextRemovedEvent`, `TextFormattedEvent`, `TextUnformattedEvent`, `ValueModifiedEvent`, `TuneModifiedEvent`, `DataNodeAddedEvent`, `DataNodeRemovedEvent`, `PropertyModifiedEvent` | +| `EditorJSModel` `EventType.CaretManagerUpdated` | `EditorJSModel` instance | `CaretManagerCaretUpdatedEvent`, `CaretManagerCaretAddedEvent`, `CaretManagerCaretRemovedEvent` | +| Core `EventBus` (per editor instance) | `EventBus` held in the IoC container | `BlockAddedCoreEvent`, `BlockRemovedCoreEvent`, `ToolLoadedCoreEvent`, `SelectionChangedCoreEvent`, `UndoCoreEvent`, `RedoCoreEvent`, `BeforeInputUIEvent` | + +## Model events (`@editorjs/model`) + +All document mutation events extend `BaseDocumentEvent`: +- `detail.index` — location of the change +- `detail.action` — action type +- `detail.data` — changed value +- `detail.userId` — who made the change + +### Full model event reference + +| Event | `detail.data` type | When | +|---|---|---| +| `BlockAddedEvent` | `BlockNodeSerialized` | A block was inserted | +| `BlockRemovedEvent` | `BlockNodeSerialized` | A block was removed | +| `TextAddedEvent` | `string` | Characters inserted into a `TextNode` | +| `TextRemovedEvent` | `string` | Characters deleted from a `TextNode` | +| `TextFormattedEvent` | `{ tool, data }` | An inline tool was applied to a text range | +| `TextUnformattedEvent` | `{ tool, data }` | An inline tool was removed from a text range | +| `ValueModifiedEvent` | `{ value, previous }` | A `ValueNode`'s value was changed | +| `TuneModifiedEvent` | `{ value, previous }` | A `BlockTune`'s data was updated | +| `DataNodeAddedEvent` | `BlockNodeDataSerializedValue` | A data node (text or value) was created on a block | +| `DataNodeRemovedEvent` | `BlockNodeDataSerializedValue` | A data node was removed from a block | +| `PropertyModifiedEvent` | `{ value, previous }` | A top-level document property was set | + +Use model events for synchronization, collaboration, and persistence logic. + +## Core / UI events (`@editorjs/sdk`) + +Dispatched on the IoC-managed `EventBus` (one instance per editor) with prefixed type strings (`core:*`, `ui:*`). Higher-level signals for plugins and tools. + +| Event | Type string | `detail` shape | Who dispatches | +|---|---|---|---| +| `BlockAddedCoreEvent` | `core:block-added` | `{ tool, data, index, ui: HTMLElement }` | `BlockRenderer` | +| `BlockRemovedCoreEvent` | `core:block-removed` | `{ tool, index }` | `BlockRenderer` | +| `ToolLoadedCoreEvent` | `core:tool-loaded` | `{ tool: ToolFacadeClass }` | `ToolsManager` | +| `SelectionChangedCoreEvent` | `core:selection-changed` | `{ index, availableInlineTools, fragments }` | `SelectionManager` | +| `UndoCoreEvent` | `core:undo` | — | `BlocksUI` (Cmd/Ctrl+Z) | +| `RedoCoreEvent` | `core:redo` | — | `BlocksUI` (Cmd/Ctrl+Shift+Z) | +| `BeforeInputUIEvent` | `ui:before-input` | `{ data, inputType, targetRanges, isCrossInputSelection }` | `BlocksUI` | + +`BlockAddedCoreEvent` carries the rendered `HTMLElement` in `detail.ui`, while the model-level `BlockAddedEvent` carries serialised data — they are complementary. + +Use core/UI events for UI workflows and extension coordination. + +## Adapter internals + +`BlockToolAdapter` and `CaretAdapter` maintain per-block/per-input state. `BlockToolAdapter` dispatches `KeyAddedEvent`, `KeyRemovedEvent`, and `ValueNodeChangedEvent` on its own internal event bus — these are consumed by block tools, not by the rest of the system. + +## Quick choice + +- Need document truth? Listen on `EditorJSModel`. +- Need app-level UI signal? Listen on global `EventBus`. +- Need per-block behavior? Implement it in the tool/adapter path and rely on model/core events for cross-component signaling. + + +→ [`diagrams/events-catalog.mmd`](diagrams/events-catalog.mmd) + +_Event classes grouped by package and transport. Model events are dispatched on `EditorJSModel`; SDK core/UI events are dispatched on the global `EventBus`._ diff --git a/docs/input-handling.md b/docs/input-handling.md new file mode 100644 index 00000000..25724274 --- /dev/null +++ b/docs/input-handling.md @@ -0,0 +1,42 @@ +# Input Handling + +This page is the canonical typing and selection pipeline. + +## Block adapter creation + +When a block appears, `BlockRenderer` creates a per-block `BlockToolAdapter`. + +The block tool registers DOM inputs through that adapter. Inputs are tracked inside adapter internals (`BlockToolAdapter` and `CaretAdapter`) for selection and rendering lookups. + + +→ [`diagrams/block-adapter-input-flow.mmd`](diagrams/block-adapter-input-flow.mmd) + +_Block inserted → `BlockAddedEvent` → `BlockRenderer` creates adapter → tool attaches inputs. Typing then flows through events into model mutation and targeted DOM update._ + +## BeforeInput delegation + +The `contenteditable` blocks holder is owned by `BlocksUI` (`@editorjs/ui`). It intercepts the browser `beforeinput` event, prevents its default, and re-dispatches it as `BeforeInputUIEvent` on the global `EventBus`. `DOMBlockToolAdapter` listens for this event and performs the actual model mutation (`insertText`, `removeText`, etc.). + +## Caret & selection + +`CaretAdapter` listens to browser `selectionchange`, scans attached inputs, and builds an `Index` in document coordinates. + +That index is written to the model. `SelectionManager` reads it, resolves fragments/tools, and emits `SelectionChangedCoreEvent`. + +On the return path, `CaretAdapter` restores DOM selection from the model index after re-renders, so caret state stays stable. + + +→ [`diagrams/caret-selection-flow.mmd`](diagrams/caret-selection-flow.mmd) + +_Caret moved -> adapter builds `Index` -> model caret update -> tool availability computed -> UI event emitted -> DOM selection restored if needed._ + +## Inline formatting + +When an inline tool is applied, `SelectionManager.applyInlineToolForCurrentSelection()` reads the current caret index, queries the model for existing fragments in the selection range, and calls `model.format()` or `model.unformat()` directly depending on whether the range is already formatted. + +The model emits `TextFormattedEvent` / `TextUnformattedEvent`; `FormattingAdapter` listens to these events, re-renders only the affected DOM range, and then caret position is restored from the model index. + + +→ [`diagrams/inline-formatting-flow.mmd`](diagrams/inline-formatting-flow.mmd) + +_Inline tool activation -> model format/unformat -> formatting event -> targeted DOM rerender -> caret restore._ diff --git a/docs/model.md b/docs/model.md new file mode 100644 index 00000000..faeec305 --- /dev/null +++ b/docs/model.md @@ -0,0 +1,49 @@ +# Data Model + +`EditorJSModel` is the source of truth. All document mutations go through it. + +Internally it owns an `EditorDocument` (ordered `BlockNode[]`) and a `CaretManager` (one caret per `userId`). + +## Document tree + +Each `BlockNode` contains keyed data nodes: + +- `TextNode`: rich text with inline tree (`FormattingInlineNode` + `TextInlineNode`). +- `ValueNode`: non-text typed value for tools. +- `BlockTune`: per-block tune configuration. + +## Mutation and event invariant + +- Nodes dispatch internal change events when they mutate. +- `EditorJSModel` listens and re-dispatches a normalized stream for consumers. +- Consumers should subscribe to `EditorJSModel` events instead of listening to deep nodes. + + +→ [`diagrams/model-tree-structure.mmd`](diagrams/model-tree-structure.mmd) + +_Node hierarchy. Document tree (left): `EditorJSModel` → `EditorDocument` → `BlockNode` → data nodes. Caret (right): `CaretManager` holds per-user `Caret` instances. All nodes extend `EventBus`._ + +## Caret & selection + +`CaretManager` stores one `Caret` per collaborating user. Each `Caret` holds an `Index`: a serializable selection structure that can span blocks/data keys. + +When a caret changes, `EditorJSModel` exposes that update under `EventType.CaretManagerUpdated` so the rest of the system can react without reading DOM state directly. + +## Index + +`Index` is the universal address type used throughout the system — for event locations, caret positions, and OT operation targets. It is DOM-independent and fully serializable. + +| Field | Type | Meaning | +|---|---|---| +| `documentId` | `DocumentId?` | Which document the index belongs to | +| `blockIndex` | `number?` | Position of the block in `EditorDocument.children` | +| `dataKey` | `DataKey?` | Named data slot inside the block (e.g. `"text"`) | +| `textRange` | `[number, number]?` | Character-offset range `[start, end]` inside a `TextNode` | +| `tuneName` | `BlockTuneName?` | Identifies a `BlockTune` entry | +| `tuneKey` | `string?` | Key inside a tune's data object | +| `propertyName` | `string?` | Top-level document property | +| `compositeSegments` | `Index[]?` | For cross-input selections: one text index per covered input, in document order | + +An index that has `blockIndex + dataKey + textRange` (and no `compositeSegments`) is a **text index** (`isTextIndex === true`). An index with only `blockIndex` is a **block index** (`isBlockIndex === true`). + +Use `IndexBuilder` to construct indices incrementally, and `Index.parse(serialized)` / `index.serialize()` to round-trip through storage or the network. diff --git a/docs/plugins.md b/docs/plugins.md new file mode 100644 index 00000000..050f8cbe --- /dev/null +++ b/docs/plugins.md @@ -0,0 +1,69 @@ +# Plugins & Tools + +## Registration + +`core.use(...)` registers UI components/plugins by static `type` (values from `ToolType` for tools, `PluginType.Adapter` for adapters, and `PluginType.Plugin` for general plugins). + +Tools are configured through editor config (`tools`) and loaded by `ToolsManager` during `initialize()`. + +| Type | Interface / Source | Purpose | +|---|---|---| +| UI Plugin | `EditorjsPlugin` | UI component/behavior registered via `core.use(...)` | +| Block Tool | `BlockTool` (from config `tools`) | Block rendering and block-specific behavior | +| Inline Tool | `InlineTool` (from config `tools`) | Selection formatting actions | +| Block Tune | `BlockTune` (from config `tools`) | Per-block tune behavior | + +## Initialization sequence + +Canonical startup order: + +1. Initialize the adapter (`DOMAdapters` / `PluginType.Adapter`). +2. Instantiate registered UI plugins. +3. Prepare tools and emit `ToolLoadedCoreEvent` for each available tool. +4. Resolve `SelectionManager`, `BlocksManager`, and `BlockRenderer`. `BlockRenderer` subscribes to model block events and creates `BlockToolAdapter` instances per block when a `BlockAddedEvent` fires. +5. Initialize model with configured blocks (triggers `BlockAddedEvent` for each block). +6. Connect collaboration manager. + +## Lifecycle boundary + +- Plugins receive dependencies via constructor params (`config`, `api`, `eventBus`). +- Plugin instances may implement `destroy()`, but `Core` currently does not expose a global `destroy()` lifecycle hook. + +## EditorAPI + +Every plugin and tool receives an `api` object of type `EditorAPI` in its constructor. It is composed of three namespaces: + +### `api.blocks` + +Programmatic block management — delegates to `BlocksManager`. + +| Method | Description | +|---|---| +| `insert(type?, data?, index?, focus?, replace?)` | Insert a block of the given tool type | +| `insertMany(blocks, index?)` | Insert multiple serialised blocks | +| `delete(index?)` | Remove a block (defaults to caret block) | +| `move(toIndex, fromIndex?)` | Move a block to a new position | +| `render(document)` | Re-initialize the document from serialised data | +| `clear()` | Remove all blocks | +| `getBlocksCount()` | Return the total number of blocks | + +### `api.selection` + +Inline tool application — delegates to `SelectionManager`. + +| Method | Description | +|---|---| +| `applyInlineToolForCurrentSelection(toolName, data?)` | Apply or toggle an inline tool on the current caret selection | + +### `api.document` + +Read-only document access — delegates to `DocumentAPI`. + +| Property | Description | +|---|---| +| `data` | Returns `EditorDocumentSerialized` — the current serialised document state | + + +→ [`diagrams/plugin-lifecycle-flow.mmd`](diagrams/plugin-lifecycle-flow.mmd) + +_`new Core` wires services; `use()` registers UI plugins; `initialize()` prepares tools, initializes document, and starts collaboration._