Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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