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).
15 changes: 6 additions & 9 deletions packages/collaboration-manager/src/BatchedOperation.spec.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
import type { Index } from '@editorjs/sdk';
import { createDataKey, IndexBuilder, type TextRange } from '@editorjs/sdk';
import { createDataKey, Index, type TextIndex, type TextRange } from '@editorjs/sdk';
import { BatchedOperation } from './BatchedOperation.js';
import type { SerializedOperation } from './Operation.js';
import { Operation, OperationType } from './Operation.js';

const createIndexByRange = (range: TextRange): Index => new IndexBuilder()
.addBlockIndex(0)
.addDataKey(createDataKey('key'))
.addTextRange(range)
.build();
const createIndexByRange = (range: TextRange): Index => Index.text([{ blockIndex: 0,
dataKey: createDataKey('key'),
textRange: range }]);

const templateIndex = createIndexByRange([0, 0]);

Expand Down Expand Up @@ -99,8 +96,8 @@ describe('BatchedOperation', () => {
expect(transformedBatch.operations.length).toBe(2);
// Check if text ranges were shifted by 1 due to insertion
/* eslint-disable @typescript-eslint/no-magic-numbers */
expect(transformedBatch.operations[0].index.textRange![0]).toBe(2);
expect(transformedBatch.operations[1].index.textRange![0]).toBe(3);
expect((transformedBatch.operations[0].index as TextIndex).textRange![0]).toBe(2);
expect((transformedBatch.operations[1].index as TextIndex).textRange![0]).toBe(3);
/* eslint-enable @typescript-eslint/no-magic-numbers */
});

Expand Down
14 changes: 9 additions & 5 deletions packages/collaboration-manager/src/BatchedOperation.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { TextIndex } from '@editorjs/sdk';
import type { InvertedOperationType } from './Operation.js';
import { Operation, OperationType, type SerializedOperation } from './Operation.js';

Expand Down Expand Up @@ -126,10 +127,13 @@ export class BatchedOperation<T extends OperationType = OperationType> extends O
/**
* @todo - implement other index types
*/
if (!op.index.isTextIndex || !lastOp.index.isTextIndex) {
if (!(op.index instanceof TextIndex) || !(lastOp.index instanceof TextIndex)) {
return false;
}

const opTextIndex = op.index;
const lastOpTextIndex = lastOp.index;

if (op.type === OperationType.Modify || lastOp.type === OperationType.Modify) {
return false;
}
Expand All @@ -138,7 +142,7 @@ export class BatchedOperation<T extends OperationType = OperationType> extends O
return false;
}

if (op.index.blockIndex !== lastOp.index.blockIndex || op.index.dataKey !== lastOp.index.dataKey) {
if (opTextIndex.blockIndex !== lastOpTextIndex.blockIndex || opTextIndex.dataKey !== lastOpTextIndex.dataKey) {
return false;
}

Expand All @@ -147,15 +151,15 @@ export class BatchedOperation<T extends OperationType = OperationType> extends O
* For Insert operations, each character is appended sequentially:
* [0,0], [1,1], [2,2] ...
*/
return op.index.textRange![0] === lastOp.index.textRange![1] + 1;
return opTextIndex.textRange![0] === lastOpTextIndex.textRange![1] + 1;
}

/**
* For Delete operations two consecutive patterns are allowed:
* - Backspace: each deletion decrements the position: [3,3], [2,2], [1,1] ...
* - Forward delete: the position stays the same after each deletion: [0,0], [0,0] ...
*/
return op.index.textRange![0] === lastOp.index.textRange![0] - 1
|| op.index.textRange![0] === lastOp.index.textRange![0];
return opTextIndex.textRange![0] === lastOpTextIndex.textRange![0] - 1
|| opTextIndex.textRange![0] === lastOpTextIndex.textRange![0];
}
}
Loading
Loading