Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Read by goal:
- Registration and lifecycle contracts: [Plugins & Tools](plugins.md)
- OT, batching, undo/redo: [Collaboration](collaboration.md)
- Which event bus to listen to: [Events](events.md)
- Index JSON wire format: [Index Serialization Format](index-serialization.md)

---

Expand Down Expand Up @@ -61,7 +62,7 @@ The system stays decoupled because each step communicates through interfaces and
- `EditorjsPlugin`: general UI/behavior plugin registered via `core.use()` with `PluginType.Plugin`. UI components like `BlocksUI` typically declare a `static type` from `UiComponentType` but are bound to `PluginType.Plugin` internally.
- `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 registered via `core.use(ToolConstructor, options)` during setup. The `tools` config field provides tool settings that `ToolsManager` applies 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.
- `Index`: serializable location in the document tree, independent of DOM nodes. Subclasses (e.g. `BlockIndex`, `TextIndex`, `DataIndex`) cover distinct target types. Built with immutable factory methods like `Index.block()` or `Index.text()`; serialized to a compact JSON 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`.
- `UndoRedoManager`: exists in two forms: in `@editorjs/core` for single-user undo/redo (listens to model events and groups by debounce), and in `@editorjs/collaboration-manager` for OT-aware undo/redo (manages operation stacks).
Expand Down
11 changes: 7 additions & 4 deletions docs/diagrams/architecture-overview.mmd
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,14 @@ classDiagram
}

class Index {
+blockIndex: number
+dataKey: DataKey
+textRange: TextRange
<<abstract>>
+kind: IndexKind
+serialize(): string
+parse(serialized): Index
+parse(serialized)$ Index
+block(blockIndex, documentId?)$ BlockIndex
+text(segments)$ TextIndex
+data(blockIndex, dataKey)$ DataIndex
+tune(blockIndex, tuneName, tuneKey)$ TuneIndex
}
}

Expand Down
198 changes: 198 additions & 0 deletions docs/index-serialization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
# Index Serialization Format

`IndexBase.serialize()` produces a compact JSON string. `Index.parse(serialized)` is the inverse.
`PartialIndex` cannot be serialized — it must be resolved to a concrete type first.

All formats share a `k` discriminant field that identifies the index kind.
All fields that can be omitted (`id`) are absent from the JSON when `undefined` — they are never written as `null`.

---

## `DocumentIndex` — `k: "doc"`

```json
{ "k": "doc", "id": "<documentId>" }
```

| Field | Type | Required |
|-------|--------|----------|
| `k` | `"doc"` | yes |
| `id` | string | yes |

---

## `PropertyIndex` — `k: "prop"`

```json
{ "k": "prop", "name": "<propertyName>", "id": "<documentId>" }
```

| Field | Type | Required |
|--------|----------|----------|
| `k` | `"prop"` | yes |
| `name` | string | yes |
| `id` | string | no |

---

## `BlockIndex` — `k: "block"`

```json
{ "k": "block", "b": 2, "id": "<documentId>" }
```

| Field | Type | Required |
|-------|-----------|----------|
| `k` | `"block"` | yes |
| `b` | number | yes — zero-based block position |
| `id` | string | no |

---

## `DataIndex` — `k: "data"`

```json
{ "k": "data", "b": 2, "data": "<dataKey>", "id": "<documentId>" }
```

| Field | Type | Required |
|--------|----------|----------|
| `k` | `"data"` | yes |
| `b` | number | yes — zero-based block position |
| `data` | string | yes — data key within the block |
| `id` | string | no |

---

## `TuneIndex` — `k: "tune"`

```json
{ "k": "tune", "b": 2, "tune": "<tuneName>", "key": "<tuneKey>", "id": "<documentId>" }
```

| Field | Type | Required |
|--------|----------|----------|
| `k` | `"tune"` | yes |
| `b` | number | yes — zero-based block position |
| `tune` | string | yes — block tune name |
| `key` | string | yes — key within the tune |
| `id` | string | no |

---

## `TextIndex` (single segment) — `k: "text"`

A `TextIndex` with exactly one segment serializes as a flat `text` object.

```json
{ "k": "text", "b": 2, "data": "<dataKey>", "r": [4, 9], "id": "<documentId>" }
```

| Field | Type | Required |
|--------|----------|----------|
| `k` | `"text"` | yes |
| `b` | number | yes — zero-based block position |
| `data` | string | yes — data key of the text property |
| `r` | [number, number] | yes — `[start, end]` character offsets |
| `id` | string | no |

---

## `TextIndex` (composite) — `k: "composite"`

A `TextIndex` with more than one segment serializes as a `composite` object with a `segs` array.
Each element of `segs` has the same shape as the single-segment `text` format minus the `k` field.

```json
{
"k": "composite",
"segs": [
{ "b": 0, "data": "text", "r": [0, 3] },
{ "b": 1, "data": "text", "r": [5, 9], "id": "<documentId>" }
]
}
```

| Field | Type | Required |
|-------------|----------|----------|
| `k` | `"composite"` | yes |
| `segs` | array | yes — one or more segment objects |
| `segs[].b` | number | yes — zero-based block position |
| `segs[].data` | string | yes — data key |
| `segs[].r` | [number, number] | yes — `[start, end]` character offsets |
| `segs[].id` | string | no |

---

## Full examples

### Caret inside the first paragraph's text field

A user's caret sits between characters 3 and 3 (collapsed) in the `text` data key of the block at position 0, inside document `"doc-abc"`.

```ts
Index.text([{ blockIndex: 0, dataKey: 'text', textRange: [3, 3], documentId: 'doc-abc' }])
.serialize()
// → '{"k":"text","b":0,"data":"text","r":[3,3],"id":"doc-abc"}'
```

### Selected word across a single block

Characters 4–9 selected in the second block's `text` field. No `documentId` (single-document context).

```ts
Index.text([{ blockIndex: 1, dataKey: 'text', textRange: [4, 9] }])
.serialize()
// → '{"k":"text","b":1,"data":"text","r":[4,9]}'
```

### Cross-block selection (composite)

A drag selection that starts in block 0 (`textRange: [6, 12]`) and ends in block 1 (`textRange: [0, 4]`). Produces a `composite` with two segments.

```ts
Index.fromCompositeSegments([
Index.text([{ blockIndex: 0, dataKey: 'text', textRange: [6, 12] }]),
Index.text([{ blockIndex: 1, dataKey: 'text', textRange: [0, 4] }]),
]).serialize()
// → '{"k":"composite","segs":[{"b":0,"data":"text","r":[6,12]},{"b":1,"data":"text","r":[0,4]}]}'
```

### Block-scoped OT operation target

An operation that targets the entire third block (e.g. block removal).

```ts
Index.block(2, 'doc-abc').serialize()
// → '{"k":"block","b":2,"id":"doc-abc"}'
```

### Tune property change

A `fontSize` tune on block 1, key `"size"` changed.

```ts
Index.tune(1, 'fontSize', 'size').serialize()
// → '{"k":"tune","b":1,"tune":"fontSize","key":"size"}'
```

### Round-trip through `Index.parse`

```ts
const serialized = Index.block(0, 'doc-abc').serialize();
// '{"k":"block","b":0,"id":"doc-abc"}'

const restored = Index.parse(serialized) as BlockIndex;
restored.blockIndex; // 0
restored.documentId; // 'doc-abc'
restored.kind; // IndexKind.Block
```

---

## Rules

- **Round-trip**: `Index.parse(index.serialize())` produces an equal index for all concrete types.
- **`k` is mandatory**: `Index.parse` throws `"Invalid serialized index"` if the input is not a JSON object with a string `k` field, and `"Unknown index kind: <k>"` for unrecognised values.
- **`PartialIndex` throws**: calling `serialize()` on a `PartialIndex` always throws — resolve it first via `withBlockIndex().withDocumentId().resolve()`.
- **Field names are abbreviated** (`b`, `r`, `k`, `id`) to keep serialized strings compact.
57 changes: 43 additions & 14 deletions docs/model.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,17 +33,46 @@ When a caret changes, `EditorJSModel` exposes that update under `EventType.Caret

`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.
### Class hierarchy

```
IndexBase (abstract)
├── DocumentIndex — entire document
├── PropertyIndex — top-level document property
├── BlockIndex — a single block
├── DataIndex — a data key inside a block
├── TuneIndex — a key inside a block tune
└── TextIndex — character range(s) inside a text node
```

`Index` is a separate abstract class that extends `IndexBase` and holds only static factory methods — it is never instantiated directly. All concrete classes extend `IndexBase`.

### Narrowing

Each index carries a `kind` discriminant (`IndexKind` enum). Use it — or `instanceof` — to narrow before accessing type-specific fields:

```ts
if (index.kind === IndexKind.Text) {
// index is TextIndex — access index.blockIndex, index.dataKey, index.textRange
} else if (index instanceof BlockIndex) {
// access index.blockIndex
}
```

### Construction

Use the static factory methods on `Index`:

```ts
Index.document(documentId)
Index.property(name, documentId?)
Index.block(blockIndex, documentId?)
Index.data(blockIndex, dataKey, documentId?)
Index.tune(blockIndex, tuneName, tuneKey, documentId?)
Index.text(segments)
Index.parse(serialized) // deserialize from string
```

### Serialization

`index.serialize()` produces a compact JSON string; `Index.parse(serialized)` is the inverse. The format is documented in [Index Serialization Format](index-serialization.md).
2 changes: 1 addition & 1 deletion openspec/specs/core/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ The system SHALL provide a `Core` class owning two IoC containers (one for singl
#### Scenario: Initialization order
- **GIVEN** tools, plugins, and an adapter have been registered via `use()`
- **WHEN** `initialize()` is called
- **THEN** `SelectionManager`, `BlocksManager`, `BlockRenderer`, and `UndoRedoManager` are resolved from the IoC container, plugins are initialized, tools are initialized, the model's document is initialized, and finally a `CoreEventType.Ready` event is dispatched
- **THEN** `SelectionManager`, `BlocksManager`, and `BlockRenderer` are resolved from the IoC container, plugins are initialized, tools are initialized, `UndoRedoManager` is resolved (after plugins/tools so it observes `defaultPrevented` set by them on undo/redo events), the model's document is initialized, and finally a `CoreEventType.Ready` event is dispatched

#### Scenario: Exactly one adapter is required
- **GIVEN** one or more `PluginType.Adapter` plugins are registered via `use()`
Expand Down
31 changes: 18 additions & 13 deletions openspec/specs/model-types/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Purpose

`@editorjs/model-types` provides the shared, dependency-free low-level types, nominal identifiers, event payload shapes, and base event classes used internally by `@editorjs/model` and `@editorjs/sdk`. It contains no editor runtime logic beyond the `Index`/`IndexBuilder` location-pointer utilities, `keypath` object helpers, and block ID generation. It exists so `model` and `sdk` can share type definitions without depending on each other, and is not intended for direct use by other packages or tools.
`@editorjs/model-types` provides the shared, dependency-free low-level types, nominal identifiers, event payload shapes, and base event classes used internally by `@editorjs/model` and `@editorjs/sdk`. It contains no editor runtime logic beyond the `Index` class hierarchy of location-pointer types, `keypath` object helpers, and block ID generation. It exists so `model` and `sdk` can share type definitions without depending on each other, and is not intended for direct use by other packages or tools.

## Requirements

Expand All @@ -27,24 +27,29 @@ The system SHALL define the serialized shapes for blocks, text nodes, and docume
Implemented in `src/BlockNode.ts`, `src/Text.ts`, `src/Value.ts`, `src/EditorDocument.ts`, `src/ChangeData.ts`, `src/BlockChildType.ts`.

### Requirement: Index — composite document location pointer
The system SHALL provide an `Index` class representing a location within a document (document id, block index, data key, tune name/key, property name, and/or text range, or a composite of multiple text indexes), with validation that rejects structurally inconsistent combinations.
The system SHALL provide an abstract `IndexBase` class and one concrete subclass per pointer kind — `DocumentIndex`, `PropertyIndex`, `BlockIndex`, `TuneIndex`, `DataIndex`, `TextIndex` — each carrying only the fields structurally valid for that kind (e.g. `TuneIndex` always has `tuneName`+`tuneKey`, `PropertyIndex` never carries block-related fields), a `kind` discriminant (`IndexKind`) for narrowing, and immutable `with*` copy methods (`withBlockIndex`/`withTextRange`/`withDocumentId`) that return a new instance. The abstract `Index` class exposes static factory methods (`Index.document`/`property`/`block`/`tune`/`data`/`text`) and `Index.parse`, and overrides `Symbol.hasInstance` so `value instanceof Index` is true for any `IndexBase` instance even though concrete classes extend `IndexBase` directly.

#### Scenario: Invalid combination of index fields
- **GIVEN** an `Index` is being validated
- **WHEN** `tuneName` is set without `tuneKey`, or `tuneName` is combined with `dataKey`/`textRange`, or `propertyName` is combined with any block-related field, or `blockIndex`+`textRange` is set without `dataKey`, or `documentId` is combined with `dataKey`/`tuneName`/`tuneKey`/`textRange` without `blockIndex`
- **THEN** `validate()` throws an `Invalid index` error
#### Scenario: Constructing an index of a given kind
- **GIVEN** a location needs to be pointed to
- **WHEN** the corresponding `Index.*` factory (or a concrete class constructor) is called
- **THEN** it returns an instance of the matching subclass whose `kind` and fields are fixed by the constructor signature, so invalid field combinations (e.g. a tune name without a tune key, or a property name combined with a block index) cannot be constructed for that subclass

#### Scenario: Composite index for cross-block selections
- **GIVEN** a selection spans multiple text ranges (e.g. across blocks)
- **WHEN** an `Index` is built with `compositeSegments`
- **THEN** it SHALL contain at least two segments, each of which is itself a valid text index (`isTextIndex === true`), and no other root-level index fields may be set simultaneously; an empty `compositeSegments` array is treated as a non-composite (legacy) index
#### Scenario: Composite text selections
- **GIVEN** a selection spans multiple disjoint text ranges (e.g. across blocks)
- **WHEN** a `TextIndex` is constructed with more than one segment
- **THEN** `isComposite` is true, `isTextIndex` is false, `blockIndex`/`dataKey`/`textRange`/`documentId` are `undefined` (only meaningful for a single-segment instance), and `getTextSegments()` expands it back into one single-segment `TextIndex` per segment; constructing a `TextIndex` with zero segments throws

#### Scenario: Parsing a serialized index
- **GIVEN** a serialized index value
- **GIVEN** a JSON string produced by `IndexBase#serialize()`
- **WHEN** `Index.parse` is called
- **THEN** it accepts either a plain string (legacy single-index form) or an object with a `composite` array of string segments, and throws otherwise
- **THEN** it parses the JSON and dispatches on its `k` discriminator field (`doc`/`prop`/`block`/`tune`/`data`/`text`/`composite`) to reconstruct the matching concrete instance, and throws if the value isn't a JSON object with a string `k` field or `k` is unrecognized

Implemented in `src/Index/Index.ts`, `src/Index/IndexBuilder.ts`, validated by `src/Index/Index.spec.ts`.
#### Scenario: Resolving a partial index accumulated during event bubbling
- **GIVEN** a leaf node (text/value/tune) dispatches an event carrying only its locally-known fields as a `PartialIndex`, which parent nodes progressively complete via `withBlockIndex`/`withDocumentId` as the event bubbles up
- **WHEN** `PartialIndex#resolve()` is called once all context has been attached
- **THEN** it returns the concrete `IndexBase` subclass matching the accumulated fields, throwing a specific error naming the missing/conflicting field (e.g. `TuneIndex requires tuneKey`, `DataIndex cannot be combined with tuneName`, `PropertyIndex cannot be combined with block-related fields`) when the fields don't resolve to a valid index; `PartialIndex` itself throws on `serialize()`, so it must always be resolved before leaving `model`

Implemented in `src/Index/IndexBase.ts`, `src/Index/index.ts` (factory/parse), `src/Index/DocumentIndex.ts`, `src/Index/PropertyIndex.ts`, `src/Index/BlockIndex.ts`, `src/Index/TuneIndex.ts`, `src/Index/DataIndex.ts`, `src/Index/TextIndex.ts`, `src/Index/PartialIndex.ts`, validated by `src/Index/Index.spec.ts`.

### Requirement: Base document event and event bus
The system SHALL provide a `BaseDocumentEvent` base class carrying `{ index, action, data, userId }` that fires under the `EventType.Changed` DOM event type, concrete event subclasses for each kind of document mutation (block/data/text add/remove/modify, tune/property modification), and a thin `EventBus` (`EventTarget` subclass) used to dispatch them.
Expand Down
Loading
Loading