diff --git a/docs/README.md b/docs/README.md index 8c6f43c3..8fb98390 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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) --- @@ -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). diff --git a/docs/diagrams/architecture-overview.mmd b/docs/diagrams/architecture-overview.mmd index 219b189c..a1b1f5b6 100644 --- a/docs/diagrams/architecture-overview.mmd +++ b/docs/diagrams/architecture-overview.mmd @@ -19,11 +19,14 @@ classDiagram } class Index { - +blockIndex: number - +dataKey: DataKey - +textRange: TextRange + <> + +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 } } diff --git a/docs/index-serialization.md b/docs/index-serialization.md new file mode 100644 index 00000000..d9fc1f15 --- /dev/null +++ b/docs/index-serialization.md @@ -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": "" } +``` + +| Field | Type | Required | +|-------|--------|----------| +| `k` | `"doc"` | yes | +| `id` | string | yes | + +--- + +## `PropertyIndex` — `k: "prop"` + +```json +{ "k": "prop", "name": "", "id": "" } +``` + +| Field | Type | Required | +|--------|----------|----------| +| `k` | `"prop"` | yes | +| `name` | string | yes | +| `id` | string | no | + +--- + +## `BlockIndex` — `k: "block"` + +```json +{ "k": "block", "b": 2, "id": "" } +``` + +| 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": "", "id": "" } +``` + +| 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": "", "key": "", "id": "" } +``` + +| 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": "", "r": [4, 9], "id": "" } +``` + +| 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": "" } + ] +} +``` + +| 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: "` 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. diff --git a/docs/model.md b/docs/model.md index faeec305..b01c057d 100644 --- a/docs/model.md +++ b/docs/model.md @@ -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). diff --git a/openspec/specs/core/spec.md b/openspec/specs/core/spec.md index 7c9d4180..81e938ef 100644 --- a/openspec/specs/core/spec.md +++ b/openspec/specs/core/spec.md @@ -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()` diff --git a/openspec/specs/model-types/spec.md b/openspec/specs/model-types/spec.md index 0ad4bb4a..02d10f4e 100644 --- a/openspec/specs/model-types/spec.md +++ b/openspec/specs/model-types/spec.md @@ -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 @@ -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. diff --git a/packages/collaboration-manager/src/BatchedOperation.spec.ts b/packages/collaboration-manager/src/BatchedOperation.spec.ts index b973e9b6..9db9a316 100644 --- a/packages/collaboration-manager/src/BatchedOperation.spec.ts +++ b/packages/collaboration-manager/src/BatchedOperation.spec.ts @@ -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]); @@ -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 */ }); diff --git a/packages/collaboration-manager/src/BatchedOperation.ts b/packages/collaboration-manager/src/BatchedOperation.ts index 8cc85d64..d2f00713 100644 --- a/packages/collaboration-manager/src/BatchedOperation.ts +++ b/packages/collaboration-manager/src/BatchedOperation.ts @@ -1,3 +1,4 @@ +import { TextIndex } from '@editorjs/sdk'; import type { InvertedOperationType } from './Operation.js'; import { Operation, OperationType, type SerializedOperation } from './Operation.js'; @@ -126,10 +127,13 @@ export class BatchedOperation 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; } @@ -138,7 +142,7 @@ export class BatchedOperation 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; } @@ -147,7 +151,7 @@ export class BatchedOperation 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; } /** @@ -155,7 +159,7 @@ export class BatchedOperation extends O * - 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]; } } diff --git a/packages/collaboration-manager/src/CollaborationManager.spec.ts b/packages/collaboration-manager/src/CollaborationManager.spec.ts index 83fd4209..a1c2d2bc 100644 --- a/packages/collaboration-manager/src/CollaborationManager.spec.ts +++ b/packages/collaboration-manager/src/CollaborationManager.spec.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-magic-numbers */ -import { createDataKey, EventType, IndexBuilder } from '@editorjs/sdk'; +import { createDataKey, EventType, Index } from '@editorjs/sdk'; import { EditorJSModel } from '@editorjs/model'; import { CoreEventType, type CoreConfig } from '@editorjs/sdk'; import { beforeAll, jest } from '@jest/globals'; @@ -35,7 +35,7 @@ describe('CollaborationManager', () => { const collaborationManager = createManager(config as Required, model).manager; // @ts-expect-error - for test purposes - expect(() => collaborationManager.applyOperation(new Operation('unknown', new IndexBuilder().build(), 'hello'))).toThrow('Unknown operation type'); + expect(() => collaborationManager.applyOperation(new Operation('unknown', Index.block(0), 'hello'))).toThrow('Unknown operation type'); }); it('should add text on apply Insert Operation', () => { @@ -53,10 +53,11 @@ describe('CollaborationManager', () => { }], }); const collaborationManager = createManager(config as Required, model).manager; - const index = new IndexBuilder().addBlockIndex(0) - .addDataKey(createDataKey('text')) - .addTextRange([0, 4]) - .build(); + const index = Index.text([{ + blockIndex: 0, + dataKey: createDataKey('text'), + textRange: [0, 4], + }]); const operation = new Operation(OperationType.Insert, index, { payload: 'test', }, userId); @@ -94,10 +95,11 @@ describe('CollaborationManager', () => { }], }); const collaborationManager = createManager(config as Required, model).manager; - const index = new IndexBuilder().addBlockIndex(0) - .addDataKey(createDataKey('text')) - .addTextRange([3, 5]) - .build(); + const index = Index.text([{ + blockIndex: 0, + dataKey: createDataKey('text'), + textRange: [3, 5], + }]); const operation = new Operation(OperationType.Delete, index, { payload: '11', }, userId); @@ -127,8 +129,7 @@ describe('CollaborationManager', () => { blocks: [], }); const collaborationManager = createManager(config as Required, model).manager; - const index = new IndexBuilder().addBlockIndex(0) - .build(); + const index = Index.block(0); const operation = new Operation(OperationType.Insert, index, { payload: [{ name: 'paragraph', @@ -175,8 +176,7 @@ describe('CollaborationManager', () => { blocks: [block], }); const collaborationManager = createManager(config as Required, model).manager; - const index = new IndexBuilder().addBlockIndex(0) - .build(); + const index = Index.block(0); const operation = new Operation(OperationType.Delete, index, { payload: [block], }, userId); @@ -204,10 +204,11 @@ describe('CollaborationManager', () => { }], }); const collaborationManager = createManager(config as Required, model).manager; - const index = new IndexBuilder().addBlockIndex(0) - .addDataKey(createDataKey('text')) - .addTextRange([0, 5]) - .build(); + const index = Index.text([{ + blockIndex: 0, + dataKey: createDataKey('text'), + textRange: [0, 5], + }]); const operation = new Operation(OperationType.Modify, index, { payload: { tool: 'bold', @@ -251,10 +252,11 @@ describe('CollaborationManager', () => { }], }); const collaborationManager = createManager(config as Required, model).manager; - const index = new IndexBuilder().addBlockIndex(0) - .addDataKey(createDataKey('text')) - .addTextRange([0, 5]) - .build(); + const index = Index.text([{ + blockIndex: 0, + dataKey: createDataKey('text'), + textRange: [0, 5], + }]); const operation = new Operation(OperationType.Neutral, index, { payload: [], }, userId); @@ -281,14 +283,26 @@ describe('CollaborationManager', () => { }], }); const collaborationManager = createManager(config as Required, model).manager; - const op1 = new Operation(OperationType.Insert, new IndexBuilder().addBlockIndex(0) - .addDataKey(createDataKey('text')) - .addTextRange([0, 0]) - .build(), { payload: 'a' }, userId); - const op2 = new Operation(OperationType.Insert, new IndexBuilder().addBlockIndex(0) - .addDataKey(createDataKey('text')) - .addTextRange([1, 1]) - .build(), { payload: 'b' }, userId); + const op1 = new Operation( + OperationType.Insert, + Index.text([{ + blockIndex: 0, + dataKey: createDataKey('text'), + textRange: [0, 0], + }]), + { payload: 'a' }, + userId + ); + const op2 = new Operation( + OperationType.Insert, + Index.text([{ + blockIndex: 0, + dataKey: createDataKey('text'), + textRange: [1, 1], + }]), + { payload: 'b' }, + userId + ); const batch = new BatchedOperation(op1); batch.add(op2); @@ -331,10 +345,11 @@ describe('CollaborationManager', () => { }], }); const collaborationManager = createManager(config as Required, model).manager; - const index = new IndexBuilder().addBlockIndex(0) - .addDataKey(createDataKey('text')) - .addTextRange([0, 3]) - .build(); + const index = Index.text([{ + blockIndex: 0, + dataKey: createDataKey('text'), + textRange: [0, 3], + }]); const operation = new Operation(OperationType.Modify, index, { payload: null, prevPayload: { @@ -384,10 +399,11 @@ describe('CollaborationManager', () => { }], }); const collaborationManager = createManager(config as Required, model).manager; - const index = new IndexBuilder().addBlockIndex(0) - .addDataKey(createDataKey('text')) - .addTextRange([0, 4]) - .build(); + const index = Index.text([{ + blockIndex: 0, + dataKey: createDataKey('text'), + textRange: [0, 4], + }]); const operation = new Operation(OperationType.Insert, index, { payload: 'test', }, userId); @@ -426,11 +442,11 @@ describe('CollaborationManager', () => { }], }); const collaborationManager = createManager(config as Required, model).manager; - const index = new IndexBuilder().addBlockIndex(0) - .addDataKey(createDataKey('text')) - .addTextRange([ - 3, 5]) - .build(); + const index = Index.text([{ + blockIndex: 0, + dataKey: createDataKey('text'), + textRange: [3, 5], + }]); const operation = new Operation(OperationType.Delete, index, { payload: '11', }, userId); @@ -469,10 +485,11 @@ describe('CollaborationManager', () => { }], }); const collaborationManager = createManager(config as Required, model).manager; - const index = new IndexBuilder().addBlockIndex(0) - .addDataKey(createDataKey('text')) - .addTextRange([0, 4]) - .build(); + const index = Index.text([{ + blockIndex: 0, + dataKey: createDataKey('text'), + textRange: [0, 4], + }]); const operation = new Operation(OperationType.Insert, index, { payload: 'test', }, userId); @@ -512,10 +529,11 @@ describe('CollaborationManager', () => { }], }); const collaborationManager = createManager(config as Required, model).manager; - const index = new IndexBuilder().addBlockIndex(0) - .addDataKey(createDataKey('text')) - .addTextRange([0, 4]) - .build(); + const index = Index.text([{ + blockIndex: 0, + dataKey: createDataKey('text'), + textRange: [0, 4], + }]); const operation = new Operation(OperationType.Insert, index, { payload: 'test', }, userId); @@ -548,8 +566,7 @@ describe('CollaborationManager', () => { blocks: [], }); const collaborationManager = createManager(config as Required, model).manager; - const index = new IndexBuilder().addBlockIndex(0) - .build(); + const index = Index.block(0); const operation = new Operation(OperationType.Insert, index, { payload: [{ name: 'paragraph', @@ -588,10 +605,11 @@ describe('CollaborationManager', () => { }], }); const collaborationManager = createManager(config as Required, model).manager; - const index = new IndexBuilder().addBlockIndex(0) - .addDataKey(createDataKey('text')) - .addTextRange([0, 5]) - .build(); + const index = Index.text([{ + blockIndex: 0, + dataKey: createDataKey('text'), + textRange: [0, 5], + }]); const operation = new Operation(OperationType.Modify, index, { payload: { tool: 'bold', @@ -638,10 +656,11 @@ describe('CollaborationManager', () => { }], }); const collaborationManager = createManager(config as Required, model).manager; - const index = new IndexBuilder().addBlockIndex(0) - .addDataKey(createDataKey('text')) - .addTextRange([0, 3]) - .build(); + const index = Index.text([{ + blockIndex: 0, + dataKey: createDataKey('text'), + textRange: [0, 3], + }]); const operation = new Operation(OperationType.Modify, index, { payload: null, prevPayload: { @@ -691,10 +710,11 @@ describe('CollaborationManager', () => { }], }); const collaborationManager = createManager(config as Required, model).manager; - const index = new IndexBuilder().addBlockIndex(0) - .addDataKey(createDataKey('text')) - .addTextRange([0, 3]) - .build(); + const index = Index.text([{ + blockIndex: 0, + dataKey: createDataKey('text'), + textRange: [0, 3], + }]); const operation = new Operation(OperationType.Modify, index, { payload: null, prevPayload: { @@ -744,8 +764,7 @@ describe('CollaborationManager', () => { blocks: [block], }); const collaborationManager = createManager(config as Required, model).manager; - const index = new IndexBuilder().addBlockIndex(0) - .build(); + const index = Index.block(0); const operation = new Operation(OperationType.Delete, index, { payload: [block], }, userId); @@ -780,8 +799,7 @@ describe('CollaborationManager', () => { blocks: [block], }); const collaborationManager = createManager(config as Required, model).manager; - const index = new IndexBuilder().addBlockIndex(0) - .build(); + const index = Index.block(0); const operation = new Operation(OperationType.Delete, index, { payload: [block], }, userId); @@ -819,8 +837,7 @@ describe('CollaborationManager', () => { blocks: [block], }); const collaborationManager = createManager(config as Required, model).manager; - const index = new IndexBuilder().addBlockIndex(0) - .build(); + const index = Index.block(0); const operation = new Operation(OperationType.Delete, index, { payload: [block], }, userId); @@ -861,8 +878,7 @@ describe('CollaborationManager', () => { blocks: [block], }); const collaborationManager = createManager(config as Required, model).manager; - const index = new IndexBuilder().addBlockIndex(0) - .build(); + const index = Index.block(0); const operation = new Operation(OperationType.Delete, index, { payload: [block], }, userId); @@ -902,17 +918,16 @@ describe('CollaborationManager', () => { }], }); const collaborationManager = createManager(config as Required, model).manager; - const index1 = new IndexBuilder().addBlockIndex(0) - .addDataKey(createDataKey('text')) - .addTextRange([0, 0]) - .build(); + const index1 = Index.text([{ + blockIndex: 0, + dataKey: createDataKey('text'), + textRange: [0, 0], + }]); const operation1 = new Operation(OperationType.Insert, index1, { payload: 't', }, userId); - const index2 = new IndexBuilder().from(index1) - .addTextRange([1, 1]) - .build(); + const index2 = index1.withTextRange([1, 1]); const operation2 = new Operation(OperationType.Insert, index2, { payload: 's', }, userId); @@ -954,17 +969,16 @@ describe('CollaborationManager', () => { }], }); const collaborationManager = createManager(config as Required, model).manager; - const index1 = new IndexBuilder().addBlockIndex(0) - .addDataKey(createDataKey('text')) - .addTextRange([0, 0]) - .build(); + const index1 = Index.text([{ + blockIndex: 0, + dataKey: createDataKey('text'), + textRange: [0, 0], + }]); const operation1 = new Operation(OperationType.Insert, index1, { payload: 't', }, userId); - const index2 = new IndexBuilder().from(index1) - .addTextRange([1, 1]) - .build(); + const index2 = index1.withTextRange([1, 1]); const operation2 = new Operation(OperationType.Insert, index2, { payload: 's', }, userId); @@ -1080,10 +1094,11 @@ describe('CollaborationManager', () => { const collaborationManager = createManager(config as Required, model).manager; // Create local operation - const localIndex = new IndexBuilder().addBlockIndex(0) - .addDataKey(createDataKey('text')) - .addTextRange([0, 4]) - .build(); + const localIndex = Index.text([{ + blockIndex: 0, + dataKey: createDataKey('text'), + textRange: [0, 4], + }]); const localOp = new Operation(OperationType.Insert, localIndex, { payload: 'test', @@ -1092,10 +1107,11 @@ describe('CollaborationManager', () => { collaborationManager.applyOperation(localOp); // Apply remote operation - const remoteIndex = new IndexBuilder().addBlockIndex(0) - .addDataKey(createDataKey('text')) - .addTextRange([0, 5]) - .build(); + const remoteIndex = Index.text([{ + blockIndex: 0, + dataKey: createDataKey('text'), + textRange: [0, 5], + }]); const remoteOp = new Operation(OperationType.Insert, remoteIndex, { payload: 'hello', @@ -1186,10 +1202,9 @@ describe('CollaborationManager', () => { for (let i = 0; i < localText.length; i++) { const char = localText[i]; - const localIndex = new IndexBuilder().addBlockIndex(0) - .addDataKey(createDataKey('text')) - .addTextRange([i, i]) - .build(); + const localIndex = Index.text([{ blockIndex: 0, + dataKey: createDataKey('text'), + textRange: [i, i] }]); const localOp = new Operation(OperationType.Insert, localIndex, { payload: char, @@ -1199,10 +1214,11 @@ describe('CollaborationManager', () => { } // Insert 'world' from remote user - const remoteIndex = new IndexBuilder().addBlockIndex(0) - .addDataKey(createDataKey('text')) - .addTextRange([2, 2]) - .build(); + const remoteIndex = Index.text([{ + blockIndex: 0, + dataKey: createDataKey('text'), + textRange: [2, 2], + }]); const remoteOp = new Operation(OperationType.Insert, remoteIndex, { payload: 'world', @@ -1249,20 +1265,22 @@ describe('CollaborationManager', () => { const remoteCollaborationManager = createManager(remoteConfig as Required, model).manager; // Isert line 'hello' from local user - const localIndex = new IndexBuilder().addBlockIndex(0) - .addDataKey(createDataKey('text')) - .addTextRange([0, 0]) - .build(); + const localIndex = Index.text([{ + blockIndex: 0, + dataKey: createDataKey('text'), + textRange: [0, 0], + }]); const localOp = new Operation(OperationType.Insert, localIndex, { payload: 'hello', }, userId); // Create remote insert index - const remoteIndex = new IndexBuilder().addBlockIndex(0) - .addDataKey(createDataKey('text')) - .addTextRange([2, 2]) - .build(); + const remoteIndex = Index.text([{ + blockIndex: 0, + dataKey: createDataKey('text'), + textRange: [2, 2], + }]); const remoteOp = new Operation(OperationType.Insert, remoteIndex, { payload: 'world', @@ -1309,10 +1327,11 @@ describe('CollaborationManager', () => { const collaborationManager = createManager(config as Required, model).manager; // Create local delete operation - const localIndex = new IndexBuilder().addBlockIndex(0) - .addDataKey(createDataKey('text')) - .addTextRange([0, 0]) - .build(); + const localIndex = Index.text([{ + blockIndex: 0, + dataKey: createDataKey('text'), + textRange: [0, 0], + }]); const localOp = new Operation(OperationType.Insert, localIndex, { payload: 'initial', diff --git a/packages/collaboration-manager/src/Operation.spec.ts b/packages/collaboration-manager/src/Operation.spec.ts index aa500e95..87bf21c1 100644 --- a/packages/collaboration-manager/src/Operation.spec.ts +++ b/packages/collaboration-manager/src/Operation.spec.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-magic-numbers */ -import type { BlockNodeSerialized, DataKey, DocumentIndex } from '@editorjs/sdk'; -import { IndexBuilder } from '@editorjs/sdk'; +import type { BlockIndex, BlockNodeSerialized, DataKey, DocumentId, TextIndex } from '@editorjs/sdk'; +import { Index } from '@editorjs/sdk'; import { describe } from '@jest/globals'; import { type InsertOrDeleteOperationData, type ModifyOperationData, Operation, OperationType } from './Operation.js'; @@ -12,14 +12,11 @@ const createOperation = ( // eslint-disable-next-line @typescript-eslint/no-explicit-any prevValue?: Record ): Operation => { - const index = new IndexBuilder() - .addBlockIndex(0); - - if (Array.isArray(value)) { - index.addBlockIndex(startIndex); - } else { - index.addDataKey('text' as DataKey).addTextRange([startIndex, startIndex]); - } + const index = Array.isArray(value) + ? Index.block(startIndex) + : Index.text([{ blockIndex: 0, + dataKey: 'text' as DataKey, + textRange: [startIndex, startIndex] }]); const data: InsertOrDeleteOperationData | ModifyOperationData = { payload: value as ArrayLike, @@ -30,21 +27,22 @@ const createOperation = ( (data).prevPayload = prevValue; } - return new Operation( - type, - index.build(), - data, - 'user' - ); + return new Operation(type, index, data, 'user'); }; describe('Operation', () => { describe('.transform()', () => { it('should not change operation if document ids are different', () => { const receivedOp = createOperation(OperationType.Insert, 0, 'abc'); - const localOp = createOperation(OperationType.Insert, 0, 'def'); - - localOp.index.documentId = 'document2' as DocumentIndex; + const localOp = new Operation( + OperationType.Insert, + Index.text([{ blockIndex: 0, + dataKey: 'text' as DataKey, + textRange: [0, 0], + documentId: 'document2' as DocumentId }]), + { payload: 'def' }, + 'user' + ); const transformedOp = receivedOp.transform(localOp); expect(transformedOp).toEqual(receivedOp); @@ -52,9 +50,14 @@ describe('Operation', () => { it('should not change operation if data keys are different', () => { const receivedOp = createOperation(OperationType.Insert, 0, 'abc'); - const localOp = createOperation(OperationType.Insert, 0, 'def'); - - localOp.index.dataKey = 'dataKey2' as DataKey; + const localOp = new Operation( + OperationType.Insert, + Index.text([{ blockIndex: 0, + dataKey: 'dataKey2' as DataKey, + textRange: [0, 0] }]), + { payload: 'def' }, + 'user' + ); const transformedOp = receivedOp.transform(localOp); @@ -63,9 +66,12 @@ describe('Operation', () => { it('should throw Unsupppoted index type error if op is not Block or Text operation', () => { const receivedOp = createOperation(OperationType.Insert, 0, 'abc'); - const localOp = createOperation(OperationType.Insert, 0, 'def'); - - localOp.index.textRange = undefined; + const localOp = new Operation( + OperationType.Insert, + Index.data(0, 'text' as DataKey), + { payload: 'def' }, + 'user' + ); try { receivedOp.transform(localOp); @@ -105,7 +111,7 @@ describe('Operation', () => { const localOp = createOperation(OperationType.Insert, 0, 'abc'); const transformedOp = receivedOp.transform(localOp); - expect(transformedOp.index.textRange).toEqual([6, 6]); + expect((transformedOp.index as TextIndex).textRange).toEqual([6, 6]); }); it('should not transform a received operation if it is at the same position as a local one', () => { @@ -113,7 +119,7 @@ describe('Operation', () => { const localOp = createOperation(OperationType.Insert, 0, 'def'); const transformedOp = receivedOp.transform(localOp); - expect(transformedOp.index.textRange).toEqual([0, 0]); + expect((transformedOp.index as TextIndex).textRange).toEqual([0, 0]); }); it('should not change the text index if local op is a Block operation', () => { @@ -124,7 +130,7 @@ describe('Operation', () => { }]); const transformedOp = receivedOp.transform(localOp); - expect(transformedOp.index.textRange).toEqual([0, 0]); + expect((transformedOp.index as TextIndex).textRange).toEqual([0, 0]); }); it('should not change the operation if local op is a Block operation after a received one', () => { @@ -154,7 +160,7 @@ describe('Operation', () => { const transformedOp = receivedOp.transform(localOp); - expect(transformedOp.index.blockIndex).toEqual(2); + expect((transformedOp.index as BlockIndex).blockIndex).toEqual(2); }); it('should adjust the block index if local op is a Block operation at the same index as a received one', () => { @@ -169,7 +175,7 @@ describe('Operation', () => { const transformedOp = receivedOp.transform(localOp); - expect(transformedOp.index.blockIndex).toEqual(1); + expect((transformedOp.index as BlockIndex).blockIndex).toEqual(1); }); }); @@ -187,7 +193,7 @@ describe('Operation', () => { const localOp = createOperation(OperationType.Delete, 0, 'abc'); const transformedOp = receivedOp.transform(localOp); - expect(transformedOp.index.textRange).toEqual([0, 0]); + expect((transformedOp.index as TextIndex).textRange).toEqual([0, 0]); }); it('should transform a received operation if it is at the same position as a local one', () => { @@ -195,7 +201,7 @@ describe('Operation', () => { const localOp = createOperation(OperationType.Delete, 0, 'def'); const transformedOp = receivedOp.transform(localOp); - expect(transformedOp.index.textRange).toEqual([0, 0]); + expect((transformedOp.index as TextIndex).textRange).toEqual([0, 0]); }); it('should not change the text index if local op is a Block operation', () => { @@ -206,7 +212,7 @@ describe('Operation', () => { }]); const transformedOp = receivedOp.transform(localOp); - expect(transformedOp.index.textRange).toEqual([1, 1]); + expect((transformedOp.index as TextIndex).textRange).toEqual([1, 1]); }); it('should not change the text index if local op is a Block operation', () => { @@ -217,7 +223,7 @@ describe('Operation', () => { }]); const transformedOp = receivedOp.transform(localOp); - expect(transformedOp.index.textRange).toEqual([0, 0]); + expect((transformedOp.index as TextIndex).textRange).toEqual([0, 0]); }); it('should not change the operation if local op is a Block operation after a received one', () => { @@ -247,7 +253,7 @@ describe('Operation', () => { const transformedOp = receivedOp.transform(localOp); - expect(transformedOp.index.blockIndex).toEqual(0); + expect((transformedOp.index as BlockIndex).blockIndex).toEqual(0); }); it('should return Neutral operation if local op is a Block operation at the same index as a received one', () => { diff --git a/packages/collaboration-manager/src/Operation.ts b/packages/collaboration-manager/src/Operation.ts index 8f5eae0e..dbda5c8f 100644 --- a/packages/collaboration-manager/src/Operation.ts +++ b/packages/collaboration-manager/src/Operation.ts @@ -1,5 +1,5 @@ import type { TextRange } from '@editorjs/sdk'; -import { IndexBuilder, type Index, type BlockNodeSerialized } from '@editorjs/sdk'; +import { Index, type TextIndex, type BlockNodeSerialized } from '@editorjs/sdk'; import { OperationsTransformer } from './OperationsTransformer.js'; /** @@ -169,11 +169,9 @@ export class Operation { * Because of TypeScript guards we need to use an if statement here */ if (typeof opOrJSON.index === 'string') { - index = new IndexBuilder().from(opOrJSON.index) - .build(); + index = Index.parse(opOrJSON.index); } else { - index = new IndexBuilder().from(opOrJSON.index) - .build(); + index = opOrJSON.index.clone(); } return new Operation(opOrJSON.type, index, opOrJSON.data, opOrJSON.userId, opOrJSON.rev); @@ -185,11 +183,13 @@ export class Operation { * @returns effective text range of the operation */ public getEffectiveRange(): TextRange { + const textIndex = this.index as TextIndex; + if (this.type === OperationType.Insert) { - return [this.index.textRange![0], Math.max(this.index.textRange![1], this.index.textRange![0] + this.data.payload!.length)]; + return [textIndex.textRange![0], Math.max(textIndex.textRange![1], textIndex.textRange![0] + this.data.payload!.length)]; } - return this.index.textRange!; + return textIndex.textRange!; } /** diff --git a/packages/collaboration-manager/src/OperationsTransformer.spec.ts b/packages/collaboration-manager/src/OperationsTransformer.spec.ts index 40d5860f..c4aa97fb 100644 --- a/packages/collaboration-manager/src/OperationsTransformer.spec.ts +++ b/packages/collaboration-manager/src/OperationsTransformer.spec.ts @@ -1,5 +1,5 @@ -import type { DocumentId, Index } from '@editorjs/sdk'; -import { createDataKey, IndexBuilder } from '@editorjs/sdk'; +import type { BlockIndex, DocumentId, TextIndex } from '@editorjs/sdk'; +import { createDataKey, Index } from '@editorjs/sdk'; import { Operation, OperationType } from './Operation.js'; import { OperationsTransformer } from './OperationsTransformer.js'; @@ -13,18 +13,12 @@ describe('OperationsTransformer', () => { describe('transform', () => { const dataIndex = (blockIndex: number, dataKey: ReturnType): Index => - new IndexBuilder() - .addDocumentId('doc1' as DocumentId) - .addBlockIndex(blockIndex) - .addDataKey(dataKey) - .build(); + Index.data(blockIndex, dataKey, 'doc1' as DocumentId); it('should not transform operations on different documents', () => { const operation = new Operation( OperationType.Insert, - new IndexBuilder().addDocumentId('doc1' as DocumentId) - .addBlockIndex(0) - .build(), + Index.block(0, 'doc1' as DocumentId), { payload: 'test' }, 'user1', 1 @@ -32,9 +26,7 @@ describe('OperationsTransformer', () => { const againstOp = new Operation( OperationType.Insert, - new IndexBuilder().addDocumentId('doc2' as DocumentId) - .addBlockIndex(0) - .build(), + Index.block(0, 'doc2' as DocumentId), { payload: 'test' }, 'user2', 1 @@ -50,9 +42,7 @@ describe('OperationsTransformer', () => { it('should increase block index when transforming against Insert operation', () => { const operation = new Operation( OperationType.Insert, - new IndexBuilder().addDocumentId('doc1' as DocumentId) - .addBlockIndex(2) - .build(), + Index.block(2, 'doc1' as DocumentId), { payload: 'test' }, 'user1', 1 @@ -60,9 +50,7 @@ describe('OperationsTransformer', () => { const againstOp = new Operation( OperationType.Insert, - new IndexBuilder().addDocumentId('doc1' as DocumentId) - .addBlockIndex(1) - .build(), + Index.block(1, 'doc1' as DocumentId), { payload: 'test' }, 'user2', 1 @@ -70,15 +58,13 @@ describe('OperationsTransformer', () => { const result = transformer.transform(operation, againstOp); - expect(result.index.blockIndex).toBe(3); + expect((result.index as BlockIndex).blockIndex).toBe(3); }); it('should decrease block index when transforming against Delete operation before current block', () => { const operation = new Operation( OperationType.Insert, - new IndexBuilder().addDocumentId('doc1' as DocumentId) - .addBlockIndex(2) - .build(), + Index.block(2, 'doc1' as DocumentId), { payload: 'test' }, 'user1', 1 @@ -86,9 +72,7 @@ describe('OperationsTransformer', () => { const againstOp = new Operation( OperationType.Delete, - new IndexBuilder().addDocumentId('doc1' as DocumentId) - .addBlockIndex(1) - .build(), + Index.block(1, 'doc1' as DocumentId), { payload: 'test' }, 'user2', 1 @@ -96,15 +80,13 @@ describe('OperationsTransformer', () => { const result = transformer.transform(operation, againstOp); - expect(result.index.blockIndex).toBe(1); + expect((result.index as BlockIndex).blockIndex).toBe(1); }); it('should return Neutral operation when transforming against Delete operation of the same block', () => { const operation = new Operation( OperationType.Modify, - new IndexBuilder().addDocumentId('doc1' as DocumentId) - .addBlockIndex(1) - .build(), + Index.block(1, 'doc1' as DocumentId), { payload: 'test' }, 'user1', 1 @@ -112,9 +94,7 @@ describe('OperationsTransformer', () => { const againstOp = new Operation( OperationType.Delete, - new IndexBuilder().addDocumentId('doc1' as DocumentId) - .addBlockIndex(1) - .build(), + Index.block(1, 'doc1' as DocumentId), { payload: 'test' }, 'user2', 1 @@ -128,9 +108,7 @@ describe('OperationsTransformer', () => { it('should not change block operation when against block operation is Modify', () => { const operation = new Operation( OperationType.Insert, - new IndexBuilder().addDocumentId('doc1' as DocumentId) - .addBlockIndex(2) - .build(), + Index.block(2, 'doc1' as DocumentId), { payload: 'test' }, 'user1', 1 @@ -138,9 +116,7 @@ describe('OperationsTransformer', () => { const againstOp = new Operation( OperationType.Modify, - new IndexBuilder().addDocumentId('doc1' as DocumentId) - .addBlockIndex(1) - .build(), + Index.block(1, 'doc1' as DocumentId), { payload: 'x', prevPayload: null }, 'user2', @@ -149,7 +125,7 @@ describe('OperationsTransformer', () => { const result = transformer.transform(operation, againstOp); - expect(result.index.blockIndex).toBe(2); + expect((result.index as BlockIndex).blockIndex).toBe(2); }); }); @@ -157,9 +133,7 @@ describe('OperationsTransformer', () => { it('should not transform block operation against text operation', () => { const operation = new Operation( OperationType.Insert, - new IndexBuilder().addDocumentId('doc1' as DocumentId) - .addBlockIndex(1) - .build(), + Index.block(1, 'doc1' as DocumentId), { payload: 'test' }, 'user1', 1 @@ -167,12 +141,10 @@ describe('OperationsTransformer', () => { const againstOp = new Operation( OperationType.Insert, - new IndexBuilder() - .addDocumentId('doc1' as DocumentId) - .addBlockIndex(1) - .addDataKey(createDataKey('text')) - .addTextRange([0, 1]) - .build(), + Index.text([{ blockIndex: 1, + dataKey: createDataKey('text'), + textRange: [0, 1], + documentId: 'doc1' as DocumentId }]), { payload: 'a' }, 'user2', 1 @@ -188,9 +160,7 @@ describe('OperationsTransformer', () => { it('should not change block operation when against operation is data operation', () => { const operation = new Operation( OperationType.Insert, - new IndexBuilder().addDocumentId('doc1' as DocumentId) - .addBlockIndex(1) - .build(), + Index.block(1, 'doc1' as DocumentId), { payload: 'test' }, 'user1', 1 @@ -218,12 +188,10 @@ describe('OperationsTransformer', () => { const textKey = createDataKey('text'); const operation = new Operation( OperationType.Insert, - new IndexBuilder() - .addDocumentId('doc1' as DocumentId) - .addBlockIndex(1) - .addDataKey(textKey) - .addTextRange([2, 5]) - .build(), + Index.text([{ blockIndex: 1, + dataKey: textKey, + textRange: [2, 5], + documentId: 'doc1' as DocumentId }]), { payload: 'abc' }, 'user1', 4 @@ -248,12 +216,10 @@ describe('OperationsTransformer', () => { it('should shift right text range when Insert is before current operation', () => { const operation = new Operation( OperationType.Insert, - new IndexBuilder() - .addDocumentId('doc1' as DocumentId) - .addBlockIndex(1) - .addDataKey(createDataKey('text')) - .addTextRange([5, 8]) - .build(), + Index.text([{ blockIndex: 1, + dataKey: createDataKey('text'), + textRange: [5, 8], + documentId: 'doc1' as DocumentId }]), { payload: 'test' }, 'user1', 1 @@ -261,12 +227,10 @@ describe('OperationsTransformer', () => { const againstOp = new Operation( OperationType.Insert, - new IndexBuilder() - .addDocumentId('doc1' as DocumentId) - .addBlockIndex(1) - .addDataKey(createDataKey('text')) - .addTextRange([2, 2]) - .build(), + Index.text([{ blockIndex: 1, + dataKey: createDataKey('text'), + textRange: [2, 2], + documentId: 'doc1' as DocumentId }]), { payload: 'abc' }, 'user2', 1 @@ -274,18 +238,16 @@ describe('OperationsTransformer', () => { const result = transformer.transform(operation, againstOp); - expect(result.index.textRange).toEqual([8, 11]); + expect((result.index as TextIndex).textRange).toEqual([8, 11]); }); it('should extend text range when Insert is inside current operation range', () => { const operation = new Operation( OperationType.Modify, - new IndexBuilder() - .addDocumentId('doc1' as DocumentId) - .addBlockIndex(1) - .addDataKey(createDataKey('text')) - .addTextRange([2, 8]) - .build(), + Index.text([{ blockIndex: 1, + dataKey: createDataKey('text'), + textRange: [2, 8], + documentId: 'doc1' as DocumentId }]), { payload: 'test' }, 'user1', 1 @@ -293,12 +255,10 @@ describe('OperationsTransformer', () => { const againstOp = new Operation( OperationType.Insert, - new IndexBuilder() - .addDocumentId('doc1' as DocumentId) - .addBlockIndex(1) - .addDataKey(createDataKey('text')) - .addTextRange([4, 4]) - .build(), + Index.text([{ blockIndex: 1, + dataKey: createDataKey('text'), + textRange: [4, 4], + documentId: 'doc1' as DocumentId }]), { payload: 'abc' }, 'user2', 1 @@ -306,7 +266,7 @@ describe('OperationsTransformer', () => { const result = transformer.transform(operation, againstOp); - expect(result.index.textRange).toEqual([2, 11]); + expect((result.index as TextIndex).textRange).toEqual([2, 11]); }); }); @@ -314,12 +274,10 @@ describe('OperationsTransformer', () => { it('should shift text range left when Delete is before current operation', () => { const operation = new Operation( OperationType.Insert, - new IndexBuilder() - .addDocumentId('doc1' as DocumentId) - .addBlockIndex(1) - .addDataKey(createDataKey('text')) - .addTextRange([8, 10]) - .build(), + Index.text([{ blockIndex: 1, + dataKey: createDataKey('text'), + textRange: [8, 10], + documentId: 'doc1' as DocumentId }]), { payload: 'test' }, 'user1', 1 @@ -327,12 +285,10 @@ describe('OperationsTransformer', () => { const againstOp = new Operation( OperationType.Delete, - new IndexBuilder() - .addDocumentId('doc1' as DocumentId) - .addBlockIndex(1) - .addDataKey(createDataKey('text')) - .addTextRange([2, 5]) - .build(), + Index.text([{ blockIndex: 1, + dataKey: createDataKey('text'), + textRange: [2, 5], + documentId: 'doc1' as DocumentId }]), { payload: 'abc' }, 'user2', 1 @@ -340,18 +296,16 @@ describe('OperationsTransformer', () => { const result = transformer.transform(operation, againstOp); - expect(result.index.textRange).toEqual([5, 7]); + expect((result.index as TextIndex).textRange).toEqual([5, 7]); }); it('should return Neutral operation when Delete fully covers current operation', () => { const operation = new Operation( OperationType.Modify, - new IndexBuilder() - .addDocumentId('doc1' as DocumentId) - .addBlockIndex(1) - .addDataKey(createDataKey('text')) - .addTextRange([3, 5]) - .build(), + Index.text([{ blockIndex: 1, + dataKey: createDataKey('text'), + textRange: [3, 5], + documentId: 'doc1' as DocumentId }]), { payload: 'test' }, 'user1', 1 @@ -359,12 +313,10 @@ describe('OperationsTransformer', () => { const againstOp = new Operation( OperationType.Delete, - new IndexBuilder() - .addDocumentId('doc1' as DocumentId) - .addBlockIndex(1) - .addDataKey(createDataKey('text')) - .addTextRange([2, 8]) - .build(), + Index.text([{ blockIndex: 1, + dataKey: createDataKey('text'), + textRange: [2, 8], + documentId: 'doc1' as DocumentId }]), { payload: 'abcdef' }, 'user2', 1 @@ -378,12 +330,10 @@ describe('OperationsTransformer', () => { it('should apply Left intersection when delete removes the left part of the current range', () => { const operation = new Operation( OperationType.Insert, - new IndexBuilder() - .addDocumentId('doc1' as DocumentId) - .addBlockIndex(1) - .addDataKey(createDataKey('text')) - .addTextRange([8, 10]) - .build(), + Index.text([{ blockIndex: 1, + dataKey: createDataKey('text'), + textRange: [8, 10], + documentId: 'doc1' as DocumentId }]), { payload: 'ab' }, 'user1', 1 @@ -391,12 +341,10 @@ describe('OperationsTransformer', () => { const againstOp = new Operation( OperationType.Delete, - new IndexBuilder() - .addDocumentId('doc1' as DocumentId) - .addBlockIndex(1) - .addDataKey(createDataKey('text')) - .addTextRange([5, 9]) - .build(), + Index.text([{ blockIndex: 1, + dataKey: createDataKey('text'), + textRange: [5, 9], + documentId: 'doc1' as DocumentId }]), { payload: 'xxxx' }, 'user2', 1 @@ -404,18 +352,16 @@ describe('OperationsTransformer', () => { const result = transformer.transform(operation, againstOp); - expect(result.index.textRange).toEqual([5, 6]); + expect((result.index as TextIndex).textRange).toEqual([5, 6]); }); it('should apply Right intersection when delete removes the right part of the current range', () => { const operation = new Operation( OperationType.Insert, - new IndexBuilder() - .addDocumentId('doc1' as DocumentId) - .addBlockIndex(1) - .addDataKey(createDataKey('text')) - .addTextRange([5, 8]) - .build(), + Index.text([{ blockIndex: 1, + dataKey: createDataKey('text'), + textRange: [5, 8], + documentId: 'doc1' as DocumentId }]), { payload: 'abc' }, 'user1', 1 @@ -423,12 +369,10 @@ describe('OperationsTransformer', () => { const againstOp = new Operation( OperationType.Delete, - new IndexBuilder() - .addDocumentId('doc1' as DocumentId) - .addBlockIndex(1) - .addDataKey(createDataKey('text')) - .addTextRange([7, 11]) - .build(), + Index.text([{ blockIndex: 1, + dataKey: createDataKey('text'), + textRange: [7, 11], + documentId: 'doc1' as DocumentId }]), { payload: 'abcd' }, 'user2', 1 @@ -436,19 +380,17 @@ describe('OperationsTransformer', () => { const result = transformer.transform(operation, againstOp); - expect(result.index.textRange).toEqual([5, 7]); + expect((result.index as TextIndex).textRange).toEqual([5, 7]); expect(result.data.payload).toEqual('ab'); }); it('should shrink range when delete is strictly inside the current text range', () => { const operation = new Operation( OperationType.Modify, - new IndexBuilder() - .addDocumentId('doc1' as DocumentId) - .addBlockIndex(1) - .addDataKey(createDataKey('text')) - .addTextRange([4, 14]) - .build(), + Index.text([{ blockIndex: 1, + dataKey: createDataKey('text'), + textRange: [4, 14], + documentId: 'doc1' as DocumentId }]), { payload: { tool: 'bold' } }, 'user1', 1 @@ -456,12 +398,10 @@ describe('OperationsTransformer', () => { const againstOp = new Operation( OperationType.Delete, - new IndexBuilder() - .addDocumentId('doc1' as DocumentId) - .addBlockIndex(1) - .addDataKey(createDataKey('text')) - .addTextRange([6, 9]) - .build(), + Index.text([{ blockIndex: 1, + dataKey: createDataKey('text'), + textRange: [6, 9], + documentId: 'doc1' as DocumentId }]), { payload: 'abc' }, 'user2', 1 @@ -469,7 +409,7 @@ describe('OperationsTransformer', () => { const result = transformer.transform(operation, againstOp); - expect(result.index.textRange).toEqual([4, 11]); + expect((result.index as TextIndex).textRange).toEqual([4, 11]); }); }); @@ -477,12 +417,10 @@ describe('OperationsTransformer', () => { it('should leave text operation unchanged when against operation is Modify', () => { const operation = new Operation( OperationType.Insert, - new IndexBuilder() - .addDocumentId('doc1' as DocumentId) - .addBlockIndex(1) - .addDataKey(createDataKey('text')) - .addTextRange([2, 5]) - .build(), + Index.text([{ blockIndex: 1, + dataKey: createDataKey('text'), + textRange: [2, 5], + documentId: 'doc1' as DocumentId }]), { payload: 'abc' }, 'user1', 1 @@ -490,12 +428,10 @@ describe('OperationsTransformer', () => { const againstOp = new Operation( OperationType.Modify, - new IndexBuilder() - .addDocumentId('doc1' as DocumentId) - .addBlockIndex(1) - .addDataKey(createDataKey('text')) - .addTextRange([3, 4]) - .build(), + Index.text([{ blockIndex: 1, + dataKey: createDataKey('text'), + textRange: [3, 4], + documentId: 'doc1' as DocumentId }]), { payload: { tool: 'bold' }, prevPayload: null }, 'user2', @@ -505,7 +441,7 @@ describe('OperationsTransformer', () => { const result = transformer.transform(operation, againstOp); expect(result.type).toBe(operation.type); - expect(result.index.textRange).toEqual(operation.index.textRange); + expect((result.index as TextIndex).textRange).toEqual((operation.index as TextIndex).textRange); expect(result.data).toEqual(operation.data); }); }); diff --git a/packages/collaboration-manager/src/OperationsTransformer.ts b/packages/collaboration-manager/src/OperationsTransformer.ts index 4c0e3c23..6930d6e3 100644 --- a/packages/collaboration-manager/src/OperationsTransformer.ts +++ b/packages/collaboration-manager/src/OperationsTransformer.ts @@ -1,4 +1,4 @@ -import { IndexBuilder } from '@editorjs/sdk'; +import { BlockIndex, DataIndex, TextIndex } from '@editorjs/sdk'; import { Operation, OperationType } from './Operation.js'; import { getRangesIntersectionType, RangeIntersectionType } from './utils/getRangesIntersectionType.js'; @@ -52,13 +52,13 @@ export class OperationsTransformer { const againstIndex = againstOp.index; switch (true) { - case (againstIndex.isBlockIndex): + case (againstIndex instanceof BlockIndex): return this.#transformAgainstBlockOperation(operation, againstOp); - case (againstIndex.isTextIndex): + case (againstIndex instanceof TextIndex): return this.#transformAgainstTextOperation(operation, againstOp); - case (againstIndex.isDataIndex): + case (againstIndex instanceof DataIndex): return this.#transformAgainstDataOperation(operation, againstOp); default: @@ -86,44 +86,49 @@ export class OperationsTransformer { * @returns new operation */ #transformAgainstBlockOperation(operation: Operation, againstOp: Operation): Operation | Operation { - const newIndexBuilder = new IndexBuilder().from(operation.index); + const opIndex = operation.index as { blockIndex?: number }; + const againstBlockIndex = (againstOp.index as BlockIndex).blockIndex; /** * If current operation has no block index, return copy of the current operation */ - if (operation.index.blockIndex === undefined) { + if (opIndex.blockIndex === undefined) { return Operation.from(operation); } /** * Check that againstOp affects current operation */ - if (againstOp.index.blockIndex! > operation.index.blockIndex) { + if (againstBlockIndex > opIndex.blockIndex) { return Operation.from(operation); } - /** - * Update the index of the current operation - */ switch (againstOp.type) { /** * Cover case 1 */ - case OperationType.Insert: - newIndexBuilder.addBlockIndex(operation.index.blockIndex + 1); - break; + case OperationType.Insert: { + const newOp = Operation.from(operation); + + newOp.index = operation.index.withBlockIndex(opIndex.blockIndex + 1); + + return newOp; + } /** * Cover case 2 */ - case OperationType.Delete: - if (againstOp.index.blockIndex! === operation.index.blockIndex) { - return new Operation(OperationType.Neutral, newIndexBuilder.build(), { payload: [] }, operation.userId, operation.rev); + case OperationType.Delete: { + if (againstBlockIndex === opIndex.blockIndex) { + return new Operation(OperationType.Neutral, operation.index.clone(), { payload: [] }, operation.userId, operation.rev); } - newIndexBuilder.addBlockIndex(operation.index.blockIndex - 1); + const newOp = Operation.from(operation); - break; + newOp.index = operation.index.withBlockIndex(opIndex.blockIndex - 1); + + return newOp; + } /** * Cover case 3 @@ -131,15 +136,6 @@ export class OperationsTransformer { default: return Operation.from(operation); } - - /** - * Return new operation with the updated index - */ - const newOp = Operation.from(operation); - - newOp.index = newIndexBuilder.build(); - - return newOp; } /** @@ -162,17 +158,18 @@ export class OperationsTransformer { */ #transformAgainstTextOperation(operation: Operation, againstOp: Operation): Operation | Operation { const index = operation.index; - const againstIndex = againstOp.index; + const againstIndex = againstOp.index as TextIndex; /** * Cover case 1 */ - if (index.isBlockIndex || index.isDataIndex) { + if (index instanceof BlockIndex || index instanceof DataIndex) { return Operation.from(operation); } - const sameInput = index.dataKey === againstIndex.dataKey; - const sameBlock = index.blockIndex === againstIndex.blockIndex; + const textIndex = index as TextIndex; + const sameInput = textIndex.dataKey === againstIndex.dataKey; + const sameBlock = textIndex.blockIndex === againstIndex.blockIndex; /** * Check that againstOp affects current operation @@ -217,7 +214,6 @@ export class OperationsTransformer { */ #transformAgainstDataOperation(operation: Operation, againstOp: Operation): Operation | Operation { const index = operation.index; - const againstIndex = againstOp.index; const revision = operation.rev!; /** @@ -227,12 +223,14 @@ export class OperationsTransformer { const againstRevision = againstOp.rev === undefined ? Infinity : againstOp.rev; // Cover case 1 - if (!index.isDataIndex) { + if (!(index instanceof DataIndex)) { return Operation.from(operation); } + const againstDataIndex = againstOp.index as DataIndex; + // Cover case 2 - if (index.blockIndex !== againstIndex.blockIndex || index.dataKey !== againstIndex.dataKey) { + if (index.blockIndex !== againstDataIndex.blockIndex || index.dataKey !== againstDataIndex.dataKey) { return Operation.from(operation); } @@ -264,13 +262,12 @@ export class OperationsTransformer { * @returns new operation */ #transformAgainstTextInsert(operation: Operation, againstOp: Operation): Operation | Operation { - const newIndexBuilder = new IndexBuilder().from(operation.index); let newPayload = operation.data.payload as string; const insertedLength = againstOp.data.payload!.length; - const index = operation.index; - const againstIndex = againstOp.index; + const index = operation.index as TextIndex; + const againstIndex = againstOp.index as TextIndex; /** * Cover case 1 @@ -280,10 +277,12 @@ export class OperationsTransformer { const effectiveIntersectionType = getRangesIntersectionType(textRange, againstIndex.textRange!); + const newOp = Operation.from(operation); + switch (effectiveIntersectionType) { case RangeIntersectionType.None: case RangeIntersectionType.Left: - newIndexBuilder.addTextRange([index.textRange![0] + insertedLength, index.textRange![1] + insertedLength]); + newOp.index = index.withTextRange([index.textRange![0] + insertedLength, index.textRange![1] + insertedLength]); break; case RangeIntersectionType.Includes: /** @@ -295,12 +294,6 @@ export class OperationsTransformer { break; } - /** - * Return new operation with the updated index - */ - const newOp = Operation.from(operation); - - newOp.index = newIndexBuilder.build(); newOp.data.payload = newPayload; return newOp; @@ -312,26 +305,21 @@ export class OperationsTransformer { * - None - inserted text is on the left side of the current operation * - Includes - inserted text is inside of the current operation text range */ - const intersectionType = getRangesIntersectionType(operation.index.textRange!, againstOp.index.textRange!); + const intersectionType = getRangesIntersectionType(index.textRange!, againstIndex.textRange!); + + const newOp = Operation.from(operation); switch (intersectionType) { case (RangeIntersectionType.None): case (RangeIntersectionType.Left): - newIndexBuilder.addTextRange([index.textRange![0] + insertedLength, index.textRange![1] + insertedLength]); + newOp.index = index.withTextRange([index.textRange![0] + insertedLength, index.textRange![1] + insertedLength]); break; case (RangeIntersectionType.Includes): - newIndexBuilder.addTextRange([index.textRange![0], index.textRange![1] + insertedLength]); + newOp.index = index.withTextRange([index.textRange![0], index.textRange![1] + insertedLength]); break; } - /** - * Return new operation with the updated index - */ - const newOp = Operation.from(operation); - - newOp.index = newIndexBuilder.build(); - return newOp; } @@ -363,31 +351,32 @@ export class OperationsTransformer { * @returns new operation */ #transformAgainstTextDelete(operation: Operation, againstOp: Operation): Operation | Operation { - const newIndexBuilder = new IndexBuilder().from(operation.index); let newPayload = operation.data.payload as string; const deletedAmount = againstOp.data.payload!.length; const textRange = operation.getEffectiveRange(); const againstTextRange = againstOp.getEffectiveRange(); - const index = operation.index; - const againstIndex = againstOp.index; + const index = operation.index as TextIndex; + const againstIndex = againstOp.index as TextIndex; const intersectionType = getRangesIntersectionType(textRange, againstTextRange); + const newOp = Operation.from(operation); + switch (intersectionType) { /** * Cover case 1 */ case (RangeIntersectionType.None): - newIndexBuilder.addTextRange([index.textRange![0] - deletedAmount, index.textRange![1] - deletedAmount]); + newOp.index = index.withTextRange([index.textRange![0] - deletedAmount, index.textRange![1] - deletedAmount]); break; /** * Cover case 2.1 */ case (RangeIntersectionType.Left): - newIndexBuilder.addTextRange([againstIndex.textRange![0], index.textRange![1] - deletedAmount]); + newOp.index = index.withTextRange([againstIndex.textRange![0], index.textRange![1] - deletedAmount]); newPayload = typeof newPayload === 'string' ? newPayload.slice(againstTextRange[1] - textRange[0]) : newPayload; break; @@ -395,7 +384,7 @@ export class OperationsTransformer { * Cover case 2.2 */ case (RangeIntersectionType.Right): - newIndexBuilder.addTextRange([index.textRange![0], againstIndex.textRange![0]]); + newOp.index = index.withTextRange([index.textRange![0], againstIndex.textRange![0]]); newPayload = typeof newPayload === 'string' ? newPayload.slice(0, againstTextRange[0] - textRange[0]) : newPayload; break; @@ -403,7 +392,7 @@ export class OperationsTransformer { * Cover case 3 */ case (RangeIntersectionType.Includes): - newIndexBuilder.addTextRange([index.textRange![0], index.textRange![1] - deletedAmount]); + newOp.index = index.withTextRange([index.textRange![0], index.textRange![1] - deletedAmount]); newPayload = typeof newPayload === 'string' ? newPayload.slice(0, againstTextRange[0] - textRange[0]) + newPayload.slice(againstTextRange[1] - textRange[0]) : newPayload; @@ -413,15 +402,9 @@ export class OperationsTransformer { * Cover case 4 */ case (RangeIntersectionType.IncludedBy): - return new Operation(OperationType.Neutral, newIndexBuilder.build(), { payload: [] }, operation.userId, operation.rev); + return new Operation(OperationType.Neutral, index.clone(), { payload: [] }, operation.userId, operation.rev); } - /** - * Return new operation with updated index - */ - const newOp = Operation.from(operation); - - newOp.index = newIndexBuilder.build(); newOp.data.payload = newPayload; return newOp; diff --git a/packages/collaboration-manager/src/UndoRedoManager.spec.ts b/packages/collaboration-manager/src/UndoRedoManager.spec.ts index 09393402..276f0d18 100644 --- a/packages/collaboration-manager/src/UndoRedoManager.spec.ts +++ b/packages/collaboration-manager/src/UndoRedoManager.spec.ts @@ -1,5 +1,5 @@ -import type { DocumentId } from '@editorjs/sdk'; -import { createDataKey, IndexBuilder } from '@editorjs/sdk'; +import type { BlockIndex, DocumentId, TextIndex } from '@editorjs/sdk'; +import { createDataKey, Index } from '@editorjs/sdk'; import { describe } from '@jest/globals'; import { jest } from '@jest/globals'; import { Operation, OperationType } from './Operation.js'; @@ -16,9 +16,7 @@ const userId = 'user'; function createOperation(index: number, text: string, type: OperationType = OperationType.Insert): Operation { return new Operation( type, - new IndexBuilder() - .addBlockIndex(index) - .build(), + Index.block(index), { payload: [{ name: 'paragraph', @@ -35,9 +33,7 @@ describe('UndoRedoManager', () => { const op = new Operation( OperationType.Insert, - new IndexBuilder() - .addBlockIndex(0) - .build(), + Index.block(0), { payload: [{ name: 'paragraph', @@ -71,9 +67,7 @@ describe('UndoRedoManager', () => { const op = new Operation( OperationType.Insert, - new IndexBuilder() - .addBlockIndex(0) - .build(), + Index.block(0), { payload: [{ name: 'paragraph', @@ -97,9 +91,7 @@ describe('UndoRedoManager', () => { const op = new Operation( OperationType.Insert, - new IndexBuilder() - .addBlockIndex(0) - .build(), + Index.block(0), { payload: [{ name: 'paragraph', @@ -111,9 +103,7 @@ describe('UndoRedoManager', () => { const newOp = new Operation( OperationType.Insert, - new IndexBuilder() - .addBlockIndex(0) - .build(), + Index.block(0), { payload: [{ name: 'paragraph', @@ -192,8 +182,8 @@ describe('UndoRedoManager', () => { const redoOp1 = manager.redo(); const redoOp2 = manager.redo(); - expect(redoOp1?.index.blockIndex?.toString()).toBe('2'); // transformed op2 - expect(redoOp2?.index.blockIndex?.toString()).toBe('3'); // transformed op3 + expect((redoOp1?.index as BlockIndex | undefined)?.blockIndex?.toString()).toBe('2'); // transformed op2 + expect((redoOp2?.index as BlockIndex | undefined)?.blockIndex?.toString()).toBe('3'); // transformed op3 }); it('should transform stacked text operations against a remote text insert', () => { @@ -202,14 +192,12 @@ describe('UndoRedoManager', () => { const stacked = new Operation( OperationType.Insert, - new IndexBuilder() - .addDocumentId(doc) - .addBlockIndex(0) - .addDataKey(createDataKey('text')) - /* eslint-disable @typescript-eslint/no-magic-numbers */ - .addTextRange([2, 5]) - /* eslint-enable @typescript-eslint/no-magic-numbers */ - .build(), + /* eslint-disable @typescript-eslint/no-magic-numbers */ + Index.text([{ blockIndex: 0, + dataKey: createDataKey('text'), + textRange: [2, 5], + documentId: doc }]), + /* eslint-enable @typescript-eslint/no-magic-numbers */ { payload: 'abc' }, userId ); @@ -218,12 +206,10 @@ describe('UndoRedoManager', () => { const remoteInsert = new Operation( OperationType.Insert, - new IndexBuilder() - .addDocumentId(doc) - .addBlockIndex(0) - .addDataKey(createDataKey('text')) - .addTextRange([0, 0]) - .build(), + Index.text([{ blockIndex: 0, + dataKey: createDataKey('text'), + textRange: [0, 0], + documentId: doc }]), { payload: 'xx' }, 'remote' ); @@ -234,7 +220,7 @@ describe('UndoRedoManager', () => { expect(undone?.type).toBe(OperationType.Delete); /* eslint-disable @typescript-eslint/no-magic-numbers */ - expect(undone?.index.textRange).toEqual([4, 7]); + expect((undone?.index as TextIndex | undefined)?.textRange).toEqual([4, 7]); /* eslint-enable @typescript-eslint/no-magic-numbers */ }); }); diff --git a/packages/collaboration-manager/src/client/OTClient.spec.ts b/packages/collaboration-manager/src/client/OTClient.spec.ts index 1155df13..018ff399 100644 --- a/packages/collaboration-manager/src/client/OTClient.spec.ts +++ b/packages/collaboration-manager/src/client/OTClient.spec.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-magic-numbers */ import type { DocumentId, EditorDocumentSerialized } from '@editorjs/sdk'; -import { createDataKey, IndexBuilder } from '@editorjs/sdk'; +import { createDataKey, Index } from '@editorjs/sdk'; import { beforeEach, afterEach, jest, describe, it, expect } from '@jest/globals'; import { OTClient } from './OTClient.js'; import { Operation, OperationType } from '../Operation.js'; @@ -41,12 +41,10 @@ describe('OTClient', () => { await client.connectDocument(stubDocument); - const index = new IndexBuilder() - .addDocumentId(documentId) - .addBlockIndex(0) - .addDataKey(createDataKey('text')) - .addTextRange([0, 5]) - .build(); + const index = Index.text([{ blockIndex: 0, + dataKey: createDataKey('text'), + textRange: [0, 5], + documentId }]); const remoteOp = new Operation(OperationType.Insert, index, { payload: 'hello' }, remoteUserId, 1); @@ -65,12 +63,10 @@ describe('OTClient', () => { await client.connectDocument(stubDocument); - const index = new IndexBuilder() - .addDocumentId(documentId) - .addBlockIndex(0) - .addDataKey(createDataKey('text')) - .addTextRange([0, 5]) - .build(); + const index = Index.text([{ blockIndex: 0, + dataKey: createDataKey('text'), + textRange: [0, 5], + documentId }]); // Same userId as the local user — should be ignored const ownOp = new Operation(OperationType.Insert, index, { payload: 'hello' }, userId, 1); @@ -89,11 +85,7 @@ describe('OTClient', () => { await client.connectDocument(stubDocument); - const index = new IndexBuilder() - .addDocumentId(documentId) - .addBlockIndex(1) - .addDataKey(createDataKey('valueA')) - .build(); + const index = Index.data(1, createDataKey('valueA'), documentId); const localOp = new Operation(OperationType.Modify, index, { payload: { n: 1 }, @@ -163,9 +155,7 @@ describe('OTClient', () => { // Awaiting send() lets the handshake-reply microtask run and #handshake resolve, // which triggers onHandshake. - const index = new IndexBuilder().addDocumentId(documentId) - .addBlockIndex(0) - .build(); + const index = Index.block(0, documentId); await client.send(new Operation(OperationType.Insert, index, { payload: [] }, userId)); @@ -181,12 +171,10 @@ describe('OTClient', () => { const sendSpy = jest.spyOn(MockWebSocket.lastInstance!, 'send'); - const index = new IndexBuilder() - .addDocumentId(documentId) - .addBlockIndex(0) - .addDataKey(createDataKey('text')) - .addTextRange([0, 0]) - .build(); + const index = Index.text([{ blockIndex: 0, + dataKey: createDataKey('text'), + textRange: [0, 0], + documentId }]); const op = new Operation(OperationType.Insert, index, { payload: 'x' }, userId); @@ -207,12 +195,10 @@ describe('OTClient', () => { const sendSpy = jest.spyOn(MockWebSocket.lastInstance!, 'send'); - const index = new IndexBuilder() - .addDocumentId(documentId) - .addBlockIndex(0) - .addDataKey(createDataKey('text')) - .addTextRange([0, 0]) - .build(); + const index = Index.text([{ blockIndex: 0, + dataKey: createDataKey('text'), + textRange: [0, 0], + documentId }]); const op1 = new Operation(OperationType.Insert, index, { payload: 'a' }, userId); const op2 = new Operation(OperationType.Insert, index, { payload: 'b' }, userId); diff --git a/packages/core/src/components/BlockManager.spec.ts b/packages/core/src/components/BlockManager.spec.ts index f5b0a940..3c89e057 100644 --- a/packages/core/src/components/BlockManager.spec.ts +++ b/packages/core/src/components/BlockManager.spec.ts @@ -14,6 +14,7 @@ jest.unstable_mockModule('@editorjs/sdk', () => ({ createBlockId: jest.fn((id: string) => id as never), set: jest.fn(() => undefined), renumberKeys: jest.fn(() => new Map()), + TextIndex: class TextIndex {}, })); // Register ESM mocks before importing the module under test @@ -53,7 +54,7 @@ jest.unstable_mockModule('../tools/ToolsManager', () => ({ // Now import the modules (they will receive the mocks registered above) const { EditorJSModel, mergeTextNodes, sliceFragments } = await import('@editorjs/model'); -const { EventBus, set, renumberKeys } = await import('@editorjs/sdk'); +const { EventBus, set, renumberKeys, TextIndex } = await import('@editorjs/sdk'); const ToolsManager = (await import('../tools/ToolsManager')).default; const { BlocksManager } = await import('./BlockManager.js'); @@ -268,7 +269,7 @@ describe('BlocksManager (unit, mocked deps)', () => { it('should call model.getCaret with the configured userId to resolve current block', () => { // @ts-expect-error - mock return value does not need full Caret shape - model.getCaret = jest.fn(() => ({ index: { blockIndex: 2 } })); + model.getCaret = jest.fn(() => ({ index: Object.assign(new TextIndex(), { blockIndex: 2 }) })); blocksManager.deleteBlock(); @@ -280,7 +281,7 @@ describe('BlocksManager (unit, mocked deps)', () => { describe('.move()', () => { it('should call removeBlock and addBlock when moving current block forward', () => { // @ts-expect-error - mock return value does not need full Caret shape - model.getCaret = jest.fn(() => ({ index: { blockIndex: 0 } })); + model.getCaret = jest.fn(() => ({ index: Object.assign(new TextIndex(), { blockIndex: 0 }) })); // @ts-expect-error - mock return value does not need full BlockNodeSerialized shape model.getBlockSerialized = jest.fn(() => ({ name: 'a' })); diff --git a/packages/core/src/components/BlockManager.ts b/packages/core/src/components/BlockManager.ts index 5be21a24..d53a0697 100644 --- a/packages/core/src/components/BlockManager.ts +++ b/packages/core/src/components/BlockManager.ts @@ -9,7 +9,8 @@ import { type InlineTreeNodeSerialized, NODE_TYPE_HIDDEN_PROP, renumberKeys, - set + set, + TextIndex } from '@editorjs/sdk'; import 'reflect-metadata'; import { inject, injectable } from 'inversify'; @@ -435,6 +436,6 @@ export class BlocksManager { const userCaret = this.#model.getCaret(this.#config.userId); const caretIndex = userCaret?.index; - return caretIndex?.blockIndex; + return caretIndex instanceof TextIndex ? caretIndex.blockIndex : undefined; } } diff --git a/packages/core/src/components/BlockRenderer.spec.ts b/packages/core/src/components/BlockRenderer.spec.ts index 6864e323..4647ec1f 100644 --- a/packages/core/src/components/BlockRenderer.spec.ts +++ b/packages/core/src/components/BlockRenderer.spec.ts @@ -1,7 +1,7 @@ /* eslint-disable jsdoc/require-jsdoc, @stylistic/comma-dangle,@typescript-eslint/naming-convention */ import { beforeEach, jest } from '@jest/globals'; import type { BlockToolFacade, EditorJSAdapterPlugin } from '@editorjs/sdk'; -import type { BlockId, Index } from '@editorjs/sdk'; +import type { BlockId, BlockIndex, Index } from '@editorjs/sdk'; const USER_ID = 'user'; @@ -110,7 +110,7 @@ describe('BlockRenderer (unit, mocked deps)', () => { } as unknown as BlockToolFacade); const event = new BlockAddedEvent( - { blockIndex: 0 } as Index, + { blockIndex: 0 } as unknown as BlockIndex, { name: 'tool', id: 'test-block-id' as BlockId, @@ -131,7 +131,7 @@ describe('BlockRenderer (unit, mocked deps)', () => { it('should throw when blockIndex is undefined', async () => { const event = new BlockAddedEvent( - {} as Index, + {} as unknown as BlockIndex, { name: 'tool', id: 'test-block-id' as BlockId, @@ -151,7 +151,7 @@ describe('BlockRenderer (unit, mocked deps)', () => { jest.spyOn(toolsManager.blockTools, 'get').mockReturnValue(undefined); const event = new BlockAddedEvent( - { blockIndex: 0 } as Index, + { blockIndex: 0 } as unknown as BlockIndex, { name: 'missing-tool', id: 'test-block-id' as BlockId, @@ -179,7 +179,7 @@ describe('BlockRenderer (unit, mocked deps)', () => { } as unknown as BlockToolFacade); const event = new BlockAddedEvent( - { blockIndex: 0 } as Index, + { blockIndex: 0 } as unknown as BlockIndex, { name: 'tool', id: 'test-block-id' as BlockId, @@ -202,7 +202,7 @@ describe('BlockRenderer (unit, mocked deps)', () => { describe('BlockRemovedEvent handling', () => { it('should dispatch BlockRemovedCoreEvent via EventBus', () => { const event = new BlockRemovedEvent( - { blockIndex: 1 } as Index, + { blockIndex: 1 } as unknown as BlockIndex, { name: 'tool', id: 'test-block-id' as BlockId, @@ -219,7 +219,7 @@ describe('BlockRenderer (unit, mocked deps)', () => { it('should call destroyBlockToolAdapter with the block id', () => { const blockId = 'test-block-id' as unknown as BlockId; const event = new BlockRemovedEvent( - { blockIndex: 1 } as Index, + { blockIndex: 1 } as unknown as BlockIndex, { name: 'tool', id: blockId, @@ -235,7 +235,7 @@ describe('BlockRenderer (unit, mocked deps)', () => { it('should throw when blockIndex is undefined', () => { const event = new BlockRemovedEvent( - {} as Index, + {} as unknown as BlockIndex, { name: 'tool', id: 'test-block-id' as BlockId, diff --git a/packages/core/src/components/BlockRenderer.ts b/packages/core/src/components/BlockRenderer.ts index 479ea086..84ca594d 100644 --- a/packages/core/src/components/BlockRenderer.ts +++ b/packages/core/src/components/BlockRenderer.ts @@ -99,9 +99,10 @@ export class BlockRenderer { * @param event - BlockAddedEvent */ async #handleBlockAddedEvent(event: BlockAddedEvent): Promise { - const { index, data } = event.detail; + const { data } = event.detail; + const blockIndex = event.detail.index.blockIndex; - if (index.blockIndex === undefined) { + if (blockIndex === undefined) { throw new Error('[BlockRenderer] Block index should be defined. Probably something wrong with the Editor Model. Please, report this issue'); } @@ -127,7 +128,7 @@ export class BlockRenderer { tool: tool.name, data: data.data, ui: blockElement, - index: index.blockIndex, + index: blockIndex, })); } catch (error) { console.error(`[BlockRenderer] Block Tool ${data.name} failed to render`, error); @@ -141,9 +142,10 @@ export class BlockRenderer { * @param event - BlockRemovedEvent */ #handleBlockRemovedEvent(event: BlockRemovedEvent): void { - const { data, index } = event.detail; + const { data } = event.detail; + const blockIndex = event.detail.index.blockIndex; - if (index.blockIndex === undefined) { + if (blockIndex === undefined) { throw new Error('[BlockRenderer] Block index should be defined. Probably something wrong with the Editor Model. Please, report this issue'); } @@ -151,7 +153,7 @@ export class BlockRenderer { this.#eventBus.dispatchEvent(new BlockRemovedCoreEvent({ tool: data.name, - index: index.blockIndex, + index: blockIndex, })); /** diff --git a/packages/core/src/components/SelectionManager.spec.ts b/packages/core/src/components/SelectionManager.spec.ts index 74620fe8..a73b6078 100644 --- a/packages/core/src/components/SelectionManager.spec.ts +++ b/packages/core/src/components/SelectionManager.spec.ts @@ -23,12 +23,6 @@ jest.unstable_mockModule('@editorjs/model', () => { }); jest.unstable_mockModule('@editorjs/sdk', () => { - class IndexBuilderMock { - public from = jest.fn(() => this); - public addTextRange = jest.fn(() => this); - public build = jest.fn(() => ({ getTextSegments: jest.fn(() => []) })); - } - return { CoreEventType: { ToolLoaded: 'tool-loaded' }, SelectionChangedCoreEvent: jest.fn(function (this: { detail: unknown }, detail: unknown) { @@ -43,7 +37,6 @@ jest.unstable_mockModule('@editorjs/sdk', () => { this.detail = detail; }, Index: { parse: jest.fn() }, - IndexBuilder: IndexBuilderMock, EventType: { CaretManagerUpdated: 'caret-updated' }, createInlineToolData: (data: Record) => data, createInlineToolName: (name: string) => name, @@ -51,6 +44,7 @@ jest.unstable_mockModule('@editorjs/sdk', () => { Format: 'format', Unformat: 'unformat', }, + TextIndex: class TextIndex {}, }; }); @@ -61,7 +55,7 @@ jest.unstable_mockModule('../tools/ToolsManager', () => ({ })); const { EditorJSModel } = await import('@editorjs/model'); -const { CaretManagerCaretUpdatedEvent, EventType, Index, SelectionChangedCoreEvent, EventBus } = await import('@editorjs/sdk'); +const { CaretManagerCaretUpdatedEvent, EventType, Index, SelectionChangedCoreEvent, EventBus, TextIndex } = await import('@editorjs/sdk'); const ToolsManager = (await import('../tools/ToolsManager')).default; const { SelectionManager } = await import('./SelectionManager.js'); @@ -159,12 +153,12 @@ describe('SelectionManager', () => { textRange: [1, 3], }; - jest.spyOn(Index, 'parse').mockReturnValue({ + jest.spyOn(Index, 'parse').mockReturnValue(Object.assign(new (TextIndex as unknown as new () => object)(), { ...segment, getTextSegments() { return [segment]; }, - } as unknown as Index); + }) as unknown as Index); caretEventsListener(event); @@ -383,6 +377,7 @@ describe('SelectionManager', () => { getTextSegments: jest.fn(() => [{ blockIndex: 0, dataKey: 'text', textRange: [0, 3] }]), + withTextRange: jest.fn(() => ({ getTextSegments: jest.fn(() => []) })), }; jest.spyOn(model, 'getCaret') diff --git a/packages/core/src/components/SelectionManager.ts b/packages/core/src/components/SelectionManager.ts index 354c6540..def4fbb1 100644 --- a/packages/core/src/components/SelectionManager.ts +++ b/packages/core/src/components/SelectionManager.ts @@ -10,11 +10,11 @@ import { EventType, FormattingAction, Index, - IndexBuilder, IndexError, InlineToolFormatData, InlineToolName, - SelectionChangedCoreEvent + SelectionChangedCoreEvent, + TextIndex } from '@editorjs/sdk'; import { inject, injectable } from 'inversify'; import { TOKENS } from '../tokens.js'; @@ -82,7 +82,7 @@ export class SelectionManager { const index = serializedIndex !== null ? Index.parse(serializedIndex) : null; let fragments: InlineFragment[] = []; - if (index !== null) { + if (index !== null && index instanceof TextIndex) { for (const segment of index.getTextSegments()) { if (segment.blockIndex !== undefined && segment.dataKey !== undefined && segment.textRange !== undefined) { fragments.push( @@ -154,7 +154,12 @@ export class SelectionManager { * @todo do not store middle segments in the index, use only the first and last segments * Also, we need to sort inputs inside first/last block by document order to restore selection */ - const segments = caretIndex.getTextSegments(); + /** + * @todo caretIndex is typed as Readonly | null, so this cast is technically unsound. + * Not currently reachable — caret.index is only ever set to null or a TextIndex (CaretAdapter/BlockToolAdapter); + * there's no block-level caret concept that would store a BlockIndex/DataIndex here. Guard with instanceof if that changes. + */ + const segments = (caretIndex as TextIndex).getTextSegments(); if (segments.length === 0) { throw new IndexError('SelectionManager[applyInlineTool]: caret index is outside of the input'); @@ -210,15 +215,10 @@ export class SelectionManager { } else { // For composite selections, don't try to add textRange since composite indices // must not have root-level textRange. Only set textRange for single-segment selections. - const selectedSegments = caretIndex.getTextSegments(); + const selectedSegments = (caretIndex as TextIndex).getTextSegments(); if (selectedSegments.length === 1 && selectedSegments[0].textRange !== undefined) { - caret?.update( - new IndexBuilder() - .from(caretIndex) - .addTextRange([selectedSegments[0].textRange[1], selectedSegments[0].textRange[1]]) - .build() - ); + caret?.update(caretIndex.withTextRange([selectedSegments[0].textRange[1], selectedSegments[0].textRange[1]])); } else { caret?.update(caretIndex); } diff --git a/packages/core/src/components/UndoRedoManager.spec.ts b/packages/core/src/components/UndoRedoManager.spec.ts index 3fc2a56d..a1893e4a 100644 --- a/packages/core/src/components/UndoRedoManager.spec.ts +++ b/packages/core/src/components/UndoRedoManager.spec.ts @@ -38,10 +38,11 @@ jest.unstable_mockModule('@editorjs/sdk', () => ({ Removed: 'removed', Modified: 'modified', }, + TextIndex: class TextIndex {}, })); const { EditorJSModel } = await import('@editorjs/model'); -const { EventType, EventAction, EventBus } = await import('@editorjs/sdk'); +const { EventType, EventAction, EventBus, TextIndex } = await import('@editorjs/sdk'); const { UndoRedoManager } = await import('./UndoRedoManager.js'); // ─── helpers ──────────────────────────────────────────────────────────────── @@ -71,12 +72,20 @@ function createIndex(options: { dataKey?: string; textRange?: [number, number] | undefined; } = {}) { - return { - isTextIndex: options.isTextIndex ?? true, + const isTextIndex = options.isTextIndex ?? true; + + const fields = { + isTextIndex, blockIndex: options.blockIndex ?? 0, dataKey: options.dataKey ?? 'text', textRange: 'textRange' in options ? options.textRange : ([0, 0] as [number, number]), }; + + // Non-text indices must NOT be instances of TextIndex, so #canAddToBatch's + // `instanceof TextIndex` guard correctly rejects them. + return isTextIndex + ? Object.assign(new (TextIndex as unknown as new () => object)(), fields) + : fields; } /** diff --git a/packages/core/src/components/UndoRedoManager.ts b/packages/core/src/components/UndoRedoManager.ts index 0fba6c66..3bdf018d 100644 --- a/packages/core/src/components/UndoRedoManager.ts +++ b/packages/core/src/components/UndoRedoManager.ts @@ -12,7 +12,8 @@ import { type ModelEvents, ModifiedEventData, RedoCoreEvent, - UndoCoreEvent + UndoCoreEvent, + TextIndex } from '@editorjs/sdk'; import { TOKENS } from '../tokens.js'; @@ -305,7 +306,7 @@ export class UndoRedoManager { const index = payload.index; const prevIndex = lastEvent.index; - if (!index.isTextIndex || !prevIndex.isTextIndex) { + if (!(index instanceof TextIndex) || !(prevIndex instanceof TextIndex)) { return false; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index be421223..dd101995 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -177,11 +177,16 @@ export default class Core { this.#iocContainer.get(SelectionManager); this.#iocContainer.get(BlocksManager); this.#iocContainer.get(BlockRenderer); - this.#iocContainer.get(UndoRedoManager); this.#initializePlugins(); await this.#initializeTools(); + /** + * UndoRedoManager should go after plugins as it uses defaultPrevented on undo/redo events which can be set by plugins + * @todo Figure out how to make initialization less complex + */ + this.#iocContainer.get(UndoRedoManager); + this.#model.initializeDocument({ blocks }); const eventBus = this.#iocContainer.get(EventBus); diff --git a/packages/dom-adapters/src/BlockToolAdapter/index.ts b/packages/dom-adapters/src/BlockToolAdapter/index.ts index b455f351..a232b887 100644 --- a/packages/dom-adapters/src/BlockToolAdapter/index.ts +++ b/packages/dom-adapters/src/BlockToolAdapter/index.ts @@ -1,9 +1,10 @@ import { createDataKey, EventAction, - IndexBuilder, + Index, type ModelEvents, TextAddedEvent, + TextIndex, TextRemovedEvent } from '@editorjs/sdk'; import type { @@ -347,11 +348,11 @@ export class DOMBlockToolAdapter extends BlockToolAdapter { if (newCaretIndex !== null) { this.#caretAdapter.updateIndex( - new IndexBuilder() - .addBlockIndex(this.#api.blocks.getIndexById(this.blockId)) - .addDataKey(createDataKey(key)) - .addTextRange([newCaretIndex, newCaretIndex]) - .build() + Index.text([{ + blockIndex: this.#api.blocks.getIndexById(this.blockId), + dataKey: createDataKey(key), + textRange: [newCaretIndex, newCaretIndex], + }]) ); } } @@ -378,7 +379,7 @@ export class DOMBlockToolAdapter extends BlockToolAdapter { /** * In all cases (except formatting commands) we need to handle delete selected text if range is not collapsed. */ - if (range.collapsed === false && !isFormattingInputType) { + if (!range.collapsed && !isFormattingInputType) { this.#handleDeleteInContentEditable(input, key, range); } @@ -493,11 +494,11 @@ export class DOMBlockToolAdapter extends BlockToolAdapter { */ requestAnimationFrame(() => { this.#caretAdapter.updateIndex( - new IndexBuilder() - .addBlockIndex(currentBlockIndex + 1) - .addDataKey(createDataKey(key)) - .addTextRange([0, 0]) - .build() + Index.text([{ + blockIndex: currentBlockIndex + 1, + dataKey: createDataKey(key), + textRange: [0, 0], + }]) ); }); } @@ -508,9 +509,13 @@ export class DOMBlockToolAdapter extends BlockToolAdapter { * @param input - input element * @param key - data key input is attached to */ - #handleModelUpdateForContentEditableElement(event: ModelEvents, input: HTMLElement, key: string): void { - const { userId, index, action } = event.detail; - const { textRange, blockIndex: eventBlockIndex } = index; + #handleModelUpdateForContentEditableElement(event: TextAddedEvent | TextRemovedEvent, input: HTMLElement, key: string): void { + const { userId, action, index, data } = event.detail; + const { textRange, blockIndex } = index; + + if (blockIndex === undefined) { + return; + } const [start, end] = textRange!; @@ -520,15 +525,11 @@ export class DOMBlockToolAdapter extends BlockToolAdapter { range.setStart(startNode, startOffset); - const builder = new IndexBuilder(); - - builder.addDataKey(createDataKey(key)).addBlockIndex(eventBlockIndex); - let newCaretIndex: number | null = null; switch (action) { case EventAction.Added: { - const text = event.detail.data as string; + const text = data; const textNode = document.createTextNode(text); range.insertNode(textNode); @@ -548,8 +549,14 @@ export class DOMBlockToolAdapter extends BlockToolAdapter { input.normalize(); if (newCaretIndex !== null) { - builder.addTextRange([newCaretIndex, newCaretIndex]); - this.#caretAdapter.updateIndex(builder.build(), userId); + this.#caretAdapter.updateIndex( + Index.text([{ + blockIndex: blockIndex, + dataKey: createDataKey(key), + textRange: [newCaretIndex, newCaretIndex], + }]), + userId + ); } }; diff --git a/packages/dom-adapters/src/CaretAdapter/index.ts b/packages/dom-adapters/src/CaretAdapter/index.ts index c3f2d780..a545c8a6 100644 --- a/packages/dom-adapters/src/CaretAdapter/index.ts +++ b/packages/dom-adapters/src/CaretAdapter/index.ts @@ -3,7 +3,7 @@ import { type Caret, type CaretManagerEvents, Index, - IndexBuilder, + TextIndex, createDataKey, createBlockId } from '@editorjs/sdk'; @@ -124,12 +124,12 @@ export class CaretAdapter { /** * Restores a multi-block selection from a composite caret index (cross-input selection). - * @param index - composite index with {@link Index.compositeSegments} + * @param index - composite index */ - #restoreDomSelectionFromCompositeIndex(index: Index): void { - const segments = index.compositeSegments; + #restoreDomSelectionFromCompositeIndex(index: TextIndex): void { + const segments = index.getTextSegments(); - if (segments === undefined || segments.length === 0) { + if (segments.length === 0) { return; } @@ -217,14 +217,9 @@ export class CaretAdapter { continue; } - const builder = new IndexBuilder(); - - builder - .addBlockIndex(blockIndex) - .addDataKey(createDataKey(dataKeyStr)) - .addTextRange(textRange); - - segments.push(builder.build()); + segments.push(Index.text([{ blockIndex, + dataKey: createDataKey(dataKeyStr), + textRange }])); } /** @@ -259,8 +254,10 @@ export class CaretAdapter { */ #sortCompositeSegmentsInDocumentOrder(segments: Index[]): void { segments.sort((a, b) => { - const blockA = a.blockIndex; - const blockB = b.blockIndex; + const textA = a as TextIndex; + const textB = b as TextIndex; + const blockA = textA.blockIndex; + const blockB = textB.blockIndex; if (blockA !== blockB) { return (blockA ?? 0) - (blockB ?? 0); @@ -268,9 +265,9 @@ export class CaretAdapter { const blockIndex = blockA ?? 0; const inputA - = a.dataKey !== undefined ? this.findInput(blockIndex, String(a.dataKey)) : undefined; + = textA.dataKey !== undefined ? this.findInput(blockIndex, String(textA.dataKey)) : undefined; const inputB - = b.dataKey !== undefined ? this.findInput(blockIndex, String(b.dataKey)) : undefined; + = textB.dataKey !== undefined ? this.findInput(blockIndex, String(textB.dataKey)) : undefined; if (inputA !== undefined && inputB !== undefined && inputA !== inputB) { const position = inputA.compareDocumentPosition(inputB); @@ -325,13 +322,13 @@ export class CaretAdapter { return; } - if (index.compositeSegments !== undefined && index.compositeSegments.length > 0) { + if (index instanceof TextIndex && index.isComposite) { this.#restoreDomSelectionFromCompositeIndex(index); return; } - const { textRange, dataKey, blockIndex } = index; + const { textRange, dataKey, blockIndex } = index as TextIndex; if (textRange === undefined || dataKey === undefined || blockIndex === undefined) { return; diff --git a/packages/model-types/src/BaseDocumentEvent.ts b/packages/model-types/src/BaseDocumentEvent.ts index 77050290..78e8db15 100644 --- a/packages/model-types/src/BaseDocumentEvent.ts +++ b/packages/model-types/src/BaseDocumentEvent.ts @@ -1,6 +1,7 @@ import type { EventAction } from './EventAction.js'; import { EventType } from './EventType.js'; -import type { Index } from './Index/index.js'; +import type { IndexBase } from './Index/IndexBase.js'; +import type { PartialIndex } from './Index/PartialIndex.js'; import type { ModifiedEventData } from './EventBus.js'; export type { ModifiedEventData }; @@ -8,11 +9,11 @@ export type { ModifiedEventData }; /** * Common fields for all events related to the document model */ -export interface EventPayloadBase { +export interface EventPayloadBase { /** * The index of changed information */ - index: Index; + index: I; /** * The action that was performed on the information @@ -33,7 +34,7 @@ export interface EventPayloadBase { /** * BaseDocumentEvent Custom Event */ -export class BaseDocumentEvent extends CustomEvent> { +export class BaseDocumentEvent extends CustomEvent> { /** * BaseDocumentEvent class constructor * @param index - index of the modified value in the document @@ -41,10 +42,10 @@ export class BaseDocumentEvent exten * @param data - payload describing the change * @param userId - user identifier */ - constructor(index: Index, action: Action, data: Data, userId: string | number) { + constructor(index: I | PartialIndex, action: Action, data: Data, userId: string | number) { super(EventType.Changed, { detail: { - index, + index: index as I, action, data, userId, diff --git a/packages/model-types/src/Index/BlockIndex.ts b/packages/model-types/src/Index/BlockIndex.ts new file mode 100644 index 00000000..4216bceb --- /dev/null +++ b/packages/model-types/src/Index/BlockIndex.ts @@ -0,0 +1,67 @@ +import type { DocumentId } from '../indexing.js'; +import { IndexBase, IndexKind } from './IndexBase.js'; + +/** + * Index scoped to a block + */ +export class BlockIndex extends IndexBase { + readonly #blockIndex: number; + readonly #documentId?: DocumentId; + + /** + * @param blockIndex - zero-based block position + * @param documentId - optional document identifier + */ + constructor(blockIndex: number, documentId?: DocumentId) { + super(IndexKind.Block); + + this.#blockIndex = blockIndex; + this.#documentId = documentId; + } + + /** + * The zero-based position of the indexed block + */ + public get blockIndex(): number { + return this.#blockIndex; + } + + /** + * The unique identifier of the indexed document + */ + public override get documentId(): DocumentId | undefined { + return this.#documentId; + } + + /** + * @param blockIndex - updated block position + */ + public override withBlockIndex(blockIndex: number): BlockIndex { + return new BlockIndex(blockIndex, this.#documentId); + } + + /** + * @param documentId - updated document identifier + */ + public override withDocumentId(documentId: DocumentId): BlockIndex { + return new BlockIndex(this.#blockIndex, documentId); + } + + /** + * Creates a deep copy + */ + public clone(): BlockIndex { + return new BlockIndex(this.#blockIndex, this.#documentId); + } + + /** + * Serializes to JSON + */ + public serialize(): string { + return JSON.stringify({ + k: 'block', + b: this.#blockIndex, + ...(this.#documentId !== undefined && { id: this.#documentId }), + }); + } +} diff --git a/packages/model-types/src/Index/DataIndex.ts b/packages/model-types/src/Index/DataIndex.ts new file mode 100644 index 00000000..c80e6867 --- /dev/null +++ b/packages/model-types/src/Index/DataIndex.ts @@ -0,0 +1,78 @@ +import type { DataKey } from '../DataKey.js'; +import type { DocumentId } from '../indexing.js'; +import { IndexBase, IndexKind } from './IndexBase.js'; + +/** + * Index scoped to a block data key + */ +export class DataIndex extends IndexBase { + readonly #blockIndex: number; + readonly #dataKey: DataKey; + readonly #documentId?: DocumentId; + + /** + * @param blockIndex - zero-based block position + * @param dataKey - key identifying block data + * @param documentId - optional document identifier + */ + constructor(blockIndex: number, dataKey: DataKey, documentId?: DocumentId) { + super(IndexKind.Data); + this.#blockIndex = blockIndex; + this.#dataKey = dataKey; + this.#documentId = documentId; + } + + /** + * The zero-based position of the indexed block + */ + public get blockIndex(): number { + return this.#blockIndex; + } + + /** + * The key of the indexed block data property + */ + public get dataKey(): DataKey { + return this.#dataKey; + } + + /** + * The unique identifier of the indexed document + */ + public override get documentId(): DocumentId | undefined { + return this.#documentId; + } + + /** + * @param blockIndex - updated block position + */ + public override withBlockIndex(blockIndex: number): DataIndex { + return new DataIndex(blockIndex, this.#dataKey, this.#documentId); + } + + /** + * @param documentId - updated document identifier + */ + public override withDocumentId(documentId: DocumentId): DataIndex { + return new DataIndex(this.#blockIndex, this.#dataKey, documentId); + } + + /** + * Creates a deep copy + */ + public clone(): DataIndex { + return new DataIndex(this.#blockIndex, this.#dataKey, this.#documentId); + } + + /** + * Serializes to JSON + */ + public serialize(): string { + return JSON.stringify({ + k: 'data', + b: this.#blockIndex, + data: this.#dataKey, + ...(this.#documentId !== undefined && { id: this.#documentId }), + }); + } +} diff --git a/packages/model-types/src/Index/DocumentIndex.ts b/packages/model-types/src/Index/DocumentIndex.ts new file mode 100644 index 00000000..b3b2c8d1 --- /dev/null +++ b/packages/model-types/src/Index/DocumentIndex.ts @@ -0,0 +1,48 @@ +import type { DocumentId } from '../indexing.js'; +import { IndexBase, IndexKind } from './IndexBase.js'; + +/** + * Index scoped to an entire document + */ +export class DocumentIndex extends IndexBase { + readonly #documentId: DocumentId; + + /** + * @param documentId - unique document identifier + */ + constructor(documentId: DocumentId) { + super(IndexKind.Document); + this.#documentId = documentId; + } + + /** + * The unique identifier of the indexed document + */ + public override get documentId(): DocumentId { + return this.#documentId; + } + + /** + * @param documentId - updated document identifier + */ + public override withDocumentId(documentId: DocumentId): DocumentIndex { + return new DocumentIndex(documentId); + } + + /** + * Creates a deep copy + */ + public clone(): DocumentIndex { + return new DocumentIndex(this.#documentId); + } + + /** + * Serializes to JSON + */ + public serialize(): string { + return JSON.stringify({ + k: 'doc', + id: this.#documentId, + }); + } +} diff --git a/packages/model-types/src/Index/Index.spec.ts b/packages/model-types/src/Index/Index.spec.ts index 657de9c5..3aed9f19 100644 --- a/packages/model-types/src/Index/Index.spec.ts +++ b/packages/model-types/src/Index/Index.spec.ts @@ -1,581 +1,507 @@ -import type { DataKey, DocumentIndex, BlockTuneName } from '@editorjs/model-types'; -import { IndexBuilder } from './IndexBuilder.js'; -import { Index } from './index.js'; +/* eslint-disable @typescript-eslint/no-magic-numbers */ +import type { DataKey, BlockTuneName, DocumentId } from '@editorjs/model-types'; +import { + Index, + IndexKind, + BlockIndex, + DataIndex, + DocumentIndex, + PropertyIndex, + TextIndex, + TuneIndex +} from './index.js'; describe('Index', () => { - it('should serialize index', () => { - const index = new Index(); - - index.documentId = 'documentId' as DocumentIndex; - index.propertyName = 'propertyName'; - index.blockIndex = 1; - index.tuneName = 'tuneName' as BlockTuneName; - index.tuneKey = 'tuneKey'; - index.dataKey = 'dataKey' as DataKey; - index.textRange = [ - 1, - 2, - ]; - - expect(index.serialize()) - .toBe(`"doc@documentId:prop@propertyName:block@1:tune@tuneName:tuneKey@tuneKey:data@dataKey:[1,2]"`); - }); - - it('should filter out undefined values when serializing index', () => { - const index = new Index(); - - index.documentId = 'documentId' as DocumentIndex; - index.propertyName = 'propertyName'; - index.blockIndex = 1; - - expect(index.serialize()) - .toBe(`"doc@documentId:prop@propertyName:block@1"`); - }); - - it('should parse index from string', () => { - const serialized = `"doc@documentId:prop@propertyName:block@1:tune@tuneName:tuneKey@tuneKey:data@dataKey:[1,2]"`; - - const index = Index.parse(serialized); + describe('factory methods', () => { + describe('Index.document()', () => { + it('creates a DocumentIndex with the given id', () => { + const idx = Index.document('doc1' as DocumentId); + + expect(idx).toBeInstanceOf(DocumentIndex); + expect(idx.kind).toBe(IndexKind.Document); + expect(idx.documentId).toBe('doc1'); + }); + }); - expect(index.documentId).toBe('documentId'); - expect(index.propertyName).toBe('propertyName'); - expect(index.blockIndex).toBe(1); - expect(index.tuneName).toBe('tuneName'); - expect(index.tuneKey).toBe('tuneKey'); - expect(index.dataKey).toBe('dataKey'); - expect(index.textRange).toEqual([1, 2]); - }); + describe('Index.property()', () => { + it('creates a PropertyIndex with the given name', () => { + const idx = Index.property('time'); - it('should throw when parsed JSON root is not a legacy string and not a composite object', () => { - expect(() => Index.parse('null')).toThrow('Invalid serialized index'); - expect(() => Index.parse('0')).toThrow('Invalid serialized index'); - expect(() => Index.parse('true')).toThrow('Invalid serialized index'); - expect(() => Index.parse('false')).toThrow('Invalid serialized index'); - expect(() => Index.parse('{}')).toThrow('Invalid serialized index'); - }); - - it('should clone index', () => { - const index = new Index(); + expect(idx).toBeInstanceOf(PropertyIndex); + expect(idx.kind).toBe(IndexKind.Property); + expect(idx.propertyName).toBe('time'); + expect(idx.documentId).toBeUndefined(); + }); - index.documentId = 'documentId' as DocumentIndex; - index.propertyName = 'propertyName'; - index.blockIndex = 1; - index.tuneName = 'tuneName' as BlockTuneName; - index.tuneKey = 'tuneKey'; - index.dataKey = 'dataKey' as DataKey; - index.textRange = [ - 1, - 2, - ]; + it('optionally accepts documentId', () => { + const idx = Index.property('time', 'doc1' as DocumentId); - const cloned = index.clone(); + expect(idx.documentId).toBe('doc1'); + }); + }); - expect(cloned).not.toBe(index); - expect(cloned).toEqual(index); - }); + describe('Index.block()', () => { + it('creates a BlockIndex with the given block number', () => { + const idx = Index.block(3); - describe('.validate()', () => { - it('should throw an error if index includes data key AND tune name', () => { - const index = new Index(); + expect(idx).toBeInstanceOf(BlockIndex); + expect(idx.kind).toBe(IndexKind.Block); + expect(idx.blockIndex).toBe(3); + expect(idx.documentId).toBeUndefined(); + }); - index.tuneName = 'tuneName' as BlockTuneName; - index.tuneKey = 'tuneKey'; - index.dataKey = 'dataKey' as DataKey; + it('optionally accepts documentId', () => { + const idx = Index.block(3, 'doc1' as DocumentId); - expect(() => index.validate()).toThrow('Invalid index'); + expect(idx.documentId).toBe('doc1'); + }); }); - it('should throw an error if index includes text range AND tune name', () => { - const index = new Index(); - - index.tuneName = 'tuneName' as BlockTuneName; - index.tuneKey = 'tuneKey'; - index.textRange = [0, 0]; - - expect(() => index.validate()).toThrow('Invalid index'); - }); + describe('Index.tune()', () => { + it('creates a TuneIndex with block, tune name, and tune key', () => { + const idx = Index.tune(3, 'header' as BlockTuneName, 'level'); - it('should throw an error if index includes tune name but NOT tune key', () => { - const index = new Index(); + expect(idx).toBeInstanceOf(TuneIndex); + expect(idx.kind).toBe(IndexKind.Tune); + expect(idx.blockIndex).toBe(3); + expect(idx.tuneName).toBe('header'); + expect(idx.tuneKey).toBe('level'); + }); - index.tuneName = 'tuneName' as BlockTuneName; + it('optionally accepts documentId', () => { + const idx = Index.tune(3, 'header' as BlockTuneName, 'level', 'doc1' as DocumentId); - expect(() => index.validate()).toThrow('Invalid index'); + expect(idx.documentId).toBe('doc1'); + }); }); - it('should throw an error if index includes property name AND block index', () => { - const index = new Index(); + describe('Index.data()', () => { + it('creates a DataIndex with block and data key', () => { + const idx = Index.data(3, 'content' as DataKey); - index.propertyName = 'propertyName'; - index.blockIndex = 1; - - expect(() => index.validate()).toThrow('Invalid index'); - }); - - it('should throw an error if index includes property name AND data key', () => { - const index = new Index(); + expect(idx).toBeInstanceOf(DataIndex); + expect(idx.kind).toBe(IndexKind.Data); + expect(idx.blockIndex).toBe(3); + expect(idx.dataKey).toBe('content'); + }); - index.propertyName = 'propertyName'; - index.dataKey = 'dataKey' as DataKey; + it('optionally accepts documentId', () => { + const idx = Index.data(3, 'content' as DataKey, 'doc1' as DocumentId); - expect(() => index.validate()).toThrow('Invalid index'); + expect(idx.documentId).toBe('doc1'); + }); }); - it('should throw an error if index includes property name AND tune name', () => { - const index = new Index(); + describe('Index.text()', () => { + it('creates a single-segment TextIndex', () => { + const idx = Index.text([{ blockIndex: 3, + dataKey: 'content' as DataKey, + textRange: [0, 5] }]); - index.propertyName = 'propertyName'; - index.tuneName = 'tuneName' as BlockTuneName; - - expect(() => index.validate()).toThrow('Invalid index'); - }); - - it('should throw an error if index includes property name AND tune key', () => { - const index = new Index(); + expect(idx).toBeInstanceOf(TextIndex); + expect(idx.kind).toBe(IndexKind.Text); + expect(idx.blockIndex).toBe(3); + expect(idx.dataKey).toBe('content'); + expect(idx.textRange).toEqual([0, 5]); + }); - index.propertyName = 'propertyName'; - index.tuneKey = 'tuneKey'; + it('creates a composite TextIndex when given multiple segments', () => { + const idx = Index.text([ + { blockIndex: 0, + dataKey: 'key' as DataKey, + textRange: [0, 3] }, + { blockIndex: 1, + dataKey: 'key' as DataKey, + textRange: [0, 5] }, + ]); + + expect(idx.isComposite).toBe(true); + }); - expect(() => index.validate()).toThrow('Invalid index'); + it('throws when given an empty segment array', () => { + expect(() => Index.text([])).toThrow(); + }); }); + }); - it('should throw an error if index includes property name AND text range', () => { - const index = new Index(); - - index.propertyName = 'propertyName'; - index.textRange = [0, 0]; - - expect(() => index.validate()).toThrow('Invalid index'); + describe('BlockIndex identity', () => { + it('is instanceof BlockIndex', () => { + expect(Index.block(0)).toBeInstanceOf(BlockIndex); }); - it('should throw an error if index includes block index AND text range but NOT data key', () => { - const index = new Index(); - - index.blockIndex = 1; - index.textRange = [0, 0]; - - expect(() => index.validate()).toThrow('Invalid index'); + it('kind is IndexKind.Block', () => { + expect(Index.block(0).kind).toBe(IndexKind.Block); }); - it('should throw an error if index includes document id AND data key but NOT block index', () => { - const index = new Index(); - - index.documentId = 'documentId' as DocumentIndex; - index.dataKey = 'dataKey' as DataKey; - - expect(() => index.validate()).toThrow('Invalid index'); + it('DataIndex kind is not Block', () => { + expect(Index.data(0, 'key' as DataKey).kind).not.toBe(IndexKind.Block); }); - it('should throw an error if index includes document id AND tune name but NOT block index', () => { - const index = new Index(); - - index.documentId = 'documentId' as DocumentIndex; - index.tuneName = 'tuneName' as BlockTuneName; - index.tuneKey = 'tuneKey'; - - expect(() => index.validate()).toThrow('Invalid index'); + it('TuneIndex kind is not Block', () => { + expect(Index.tune(0, 'tune' as BlockTuneName, 'key').kind).not.toBe(IndexKind.Block); }); + }); - it('should throw an error if index includes document id AND tune key but NOT block index', () => { - const index = new Index(); - - index.documentId = 'documentId' as DocumentIndex; - index.tuneKey = 'tuneKey'; - - expect(() => index.validate()).toThrow('Invalid index'); + describe('DataIndex identity', () => { + it('is instanceof DataIndex', () => { + expect(Index.data(0, 'key' as DataKey)).toBeInstanceOf(DataIndex); }); - it('should throw an error if index includes document id AND text range but NOT block index', () => { - const index = new Index(); - - index.documentId = 'documentId' as DocumentIndex; - index.textRange = [0, 0]; - - expect(() => index.validate()).toThrow('Invalid index'); + it('kind is IndexKind.Data', () => { + expect(Index.data(0, 'key' as DataKey).kind).toBe(IndexKind.Data); }); - it('should return true if index is valid', () => { - const index = new Index(); - - index.documentId = 'documentId' as DocumentIndex; - index.blockIndex = 1; - index.dataKey = 'dataKey' as DataKey; - index.textRange = [0, 0]; - - expect(index.validate()).toBe(true); + it('BlockIndex kind is not Data', () => { + expect(Index.block(0).kind).not.toBe(IndexKind.Data); }); }); - describe('.isBlockIndex', () => { - it('should return true if index points to the block', () => { - const index = new IndexBuilder().addBlockIndex(0) - .build(); + describe('.isTextIndex', () => { + it('is true for a single-segment TextIndex', () => { + expect(Index.text([{ blockIndex: 0, + dataKey: 'key' as DataKey, + textRange: [0, 1] }]).isTextIndex).toBe(true); + }); + + it('is false for a composite TextIndex', () => { + const composite = Index.text([ + { blockIndex: 0, + dataKey: 'key' as DataKey, + textRange: [0, 1] }, + { blockIndex: 1, + dataKey: 'key' as DataKey, + textRange: [0, 2] }, + ]); - expect(index.isBlockIndex).toBe(true); + expect(composite.isTextIndex).toBe(false); }); - it('should return false if index does not include block index', () => { - const index = new Index(); + it('kind is not Text for DataIndex', () => { + expect(Index.data(0, 'key' as DataKey).kind).not.toBe(IndexKind.Text); + }); - expect(index.isBlockIndex).toBe(false); + it('kind is not Text for BlockIndex', () => { + expect(Index.block(0).kind).not.toBe(IndexKind.Text); }); + }); - it('should return false if index points to the text range', () => { - const index = new IndexBuilder().addBlockIndex(0) - .addDataKey('dataKey' as DataKey) - .addTextRange([0, 0]) - .build(); + describe('.isComposite', () => { + it('is true for a multi-segment TextIndex', () => { + const composite = Index.text([ + { blockIndex: 0, + dataKey: 'key' as DataKey, + textRange: [0, 1] }, + { blockIndex: 1, + dataKey: 'key' as DataKey, + textRange: [0, 2] }, + ]); - expect(index.isBlockIndex).toBe(false); + expect(composite.isComposite).toBe(true); }); - it('should return false if index points to the tune data', () => { - const index = new IndexBuilder().addBlockIndex(0) - .addTuneName('tuneName' as BlockTuneName) - .addTuneKey('tuneKey') - .build(); - - expect(index.isBlockIndex).toBe(false); + it('is false for a single-segment TextIndex', () => { + expect(Index.text([{ blockIndex: 0, + dataKey: 'key' as DataKey, + textRange: [0, 1] }]).isComposite).toBe(false); }); }); - describe('.isDataIndex', () => { - const dataKey = 'key' as DataKey; - - it('should return true if index points to the data node', () => { - const index = new IndexBuilder() - .addBlockIndex(0) - .addDataKey(dataKey) - .build(); - - expect(index.isDataIndex).toBe(true); + describe('.getTextSegments()', () => { + it('returns a single-element array for a text index', () => { + const idx = Index.text([{ blockIndex: 3, + dataKey: 'key' as DataKey, + textRange: [0, 5] }]); + const segs = idx.getTextSegments(); + + expect(segs).toHaveLength(1); + expect(segs[0].blockIndex).toBe(3); + expect(segs[0].textRange).toEqual([0, 5]); + }); + + it('returns individual single-segment TextIndex instances for a composite', () => { + const composite = Index.text([ + { blockIndex: 0, + dataKey: 'key' as DataKey, + textRange: [0, 1] }, + { blockIndex: 1, + dataKey: 'key' as DataKey, + textRange: [0, 2] }, + ]); + const segs = composite.getTextSegments(); + + expect(segs).toHaveLength(2); + expect(segs[0].blockIndex).toBe(0); + expect(segs[1].blockIndex).toBe(1); + segs.forEach(s => expect(s.isTextIndex).toBe(true)); }); + }); - it('should return false if index does not data key', () => { - const index = new Index(); + describe('Index.fromCompositeSegments()', () => { + it('builds a composite TextIndex from multiple single-segment text indices', () => { + const a = Index.text([{ blockIndex: 0, + dataKey: 'key' as DataKey, + textRange: [0, 1] }]); + const b = Index.text([{ blockIndex: 1, + dataKey: 'key' as DataKey, + textRange: [0, 2] }]); + const composite = Index.fromCompositeSegments([a, b]); - expect(index.isDataIndex).toBe(false); + expect(composite.isComposite).toBe(true); + expect(composite.getTextSegments()).toHaveLength(2); }); - it('should return false if index points to the text range', () => { - const index = new IndexBuilder().addBlockIndex(0) - .addDataKey('dataKey' as DataKey) - .addTextRange([0, 0]) - .build(); + it('returns a single-segment TextIndex when given one segment', () => { + const a = Index.text([{ blockIndex: 0, + dataKey: 'key' as DataKey, + textRange: [0, 1] }]); + const result = Index.fromCompositeSegments([a]); - expect(index.isDataIndex).toBe(false); + expect(result.isComposite).toBe(false); + expect(result.isTextIndex).toBe(true); }); - it('should return false if index points to the tune data', () => { - const index = new IndexBuilder().addBlockIndex(0) - .addTuneName('tuneName' as BlockTuneName) - .addTuneKey('tuneKey') - .build(); + it('throws when given an empty array', () => { + expect(() => Index.fromCompositeSegments([])).toThrow(); + }); - expect(index.isDataIndex).toBe(false); + it('throws when passed non-TextIndex instances', () => { + expect(() => Index.fromCompositeSegments([Index.block(0), Index.block(1)])).toThrow(); }); }); - describe('.isTextIndex', () => { - it('should return true if index points to the text', () => { - const index = new IndexBuilder().addBlockIndex(0) - .addDataKey('dataKey' as DataKey) - .addTextRange([0, 0]) - .build(); + describe('.clone()', () => { + it('produces a distinct but equal instance', () => { + const idx = Index.data(3, 'key' as DataKey, 'doc1' as DocumentId); + const cloned = idx.clone(); + + expect(cloned).not.toBe(idx); + expect(cloned.blockIndex).toBe(idx.blockIndex); + expect(cloned.dataKey).toBe(idx.dataKey); + expect(cloned.documentId).toBe(idx.documentId); + }); + + it('deep-copies segments of a composite TextIndex', () => { + const composite = Index.text([ + { blockIndex: 0, + dataKey: 'key' as DataKey, + textRange: [0, 1] }, + { blockIndex: 1, + dataKey: 'key' as DataKey, + textRange: [0, 2] }, + ]); + const cloned = composite.clone(); - expect(index.isTextIndex).toBe(true); + expect(cloned.isComposite).toBe(true); + expect(cloned.getTextSegments()[0]).not.toBe(composite.getTextSegments()[0]); + expect(cloned.getTextSegments()[0].blockIndex).toBe(composite.getTextSegments()[0].blockIndex); }); + }); - it('should return false if index does not include text range', () => { - const index = new IndexBuilder().addBlockIndex(0) - .addDataKey('dataKey' as DataKey) - .build(); + describe('.withBlockIndex()', () => { + it('returns a new index with the updated block number', () => { + const idx = Index.block(3); + const updated = idx.withBlockIndex(7); - expect(index.isTextIndex).toBe(false); + expect(updated.blockIndex).toBe(7); + expect(idx.blockIndex).toBe(3); }); - it('should return false if index points to the tune data', () => { - const index = new IndexBuilder().addBlockIndex(0) - .addTuneName('tuneName' as BlockTuneName) - .addTuneKey('tuneKey') - .build(); + it('updates blockIndex across all segments of a composite TextIndex', () => { + const composite = Index.text([ + { blockIndex: 0, + dataKey: 'key' as DataKey, + textRange: [0, 1] }, + { blockIndex: 0, + dataKey: 'key' as DataKey, + textRange: [2, 3] }, + ]); + const updated = composite.withBlockIndex(5); - expect(index.isTextIndex).toBe(false); + updated.getTextSegments().forEach(seg => expect(seg.blockIndex).toBe(5)); }); }); - describe('.getTextSegments', () => { - it('should return [self] for a text index when compositeSegments is undefined', () => { - const index = new IndexBuilder().addBlockIndex(0) - .addDataKey('dataKey' as DataKey) - .addTextRange([0, 1]) - .build(); + describe('.withTextRange()', () => { + it('returns a new TextIndex with the updated range', () => { + const idx = Index.text([{ blockIndex: 3, + dataKey: 'key' as DataKey, + textRange: [0, 5] }]); + const updated = idx.withTextRange([2, 8]); - expect(index.getTextSegments()).toEqual([index]); + expect(updated.textRange).toEqual([2, 8]); + expect(idx.textRange).toEqual([0, 5]); }); - it('should return empty array when index is neither composite nor text', () => { - const index = new IndexBuilder() - .addBlockIndex(0) - .build(); + it('throws when called on a composite TextIndex', () => { + const composite = Index.text([ + { blockIndex: 0, + dataKey: 'key' as DataKey, + textRange: [0, 1] }, + { blockIndex: 1, + dataKey: 'key' as DataKey, + textRange: [0, 2] }, + ]); - expect(index.getTextSegments()).toEqual([]); + expect(() => composite.withTextRange([0, 1])).toThrow(); }); }); - describe('composite index', () => { - const compositeSecondSegmentEnd = 5; - - it('should serialize and parse composite', () => { - const a = new IndexBuilder().addBlockIndex(0) - .addDataKey('a' as DataKey) - .addTextRange([1, 2]) - .build(); - const b = new IndexBuilder().addBlockIndex(1) - .addDataKey('a' as DataKey) - .addTextRange([0, compositeSecondSegmentEnd]) - .build(); - const composite = Index.fromCompositeSegments([a, b]); - - expect(composite.validate()).toBe(true); - - const round = Index.parse(composite.serialize()); + describe('.withDocumentId()', () => { + it('attaches documentId while preserving other fields', () => { + const idx = Index.block(3); + const updated = idx.withDocumentId('doc1' as DocumentId); - expect(round.compositeSegments).toHaveLength(2); - expect(round.compositeSegments![0].blockIndex).toBe(0); - expect(round.compositeSegments![1].blockIndex).toBe(1); + expect(updated.documentId).toBe('doc1'); + expect(updated.blockIndex).toBe(3); + expect(idx.documentId).toBeUndefined(); }); + }); - it('should validate legacy rules when compositeSegments is empty (not a composite index)', () => { - const index = new Index(); - - index.compositeSegments = []; + describe('.serialize() and Index.parse()', () => { + it('round-trips a DocumentIndex', () => { + const idx = Index.document('doc1' as DocumentId); + const parsed = Index.parse(idx.serialize()) as DocumentIndex; - expect(index.validate()).toBe(true); + expect(parsed.documentId).toBe('doc1'); }); - it('should throw on validate when composite has only one segment', () => { - const a = new IndexBuilder().addBlockIndex(0) - .addDataKey('a' as DataKey) - .addTextRange([1, 2]) - .build(); - const index = new Index(); - - index.compositeSegments = [a]; + it('round-trips a PropertyIndex without documentId', () => { + const idx = Index.property('time'); + const parsed = Index.parse(idx.serialize()) as PropertyIndex; - expect(() => index.validate()).toThrow('Invalid index'); + expect(parsed.propertyName).toBe('time'); + expect(parsed.documentId).toBeUndefined(); }); - it('should throw on validate when composite segments are not text indices', () => { - const blockOnlyA = new IndexBuilder() - .addBlockIndex(0) - .build(); - const blockOnlyB = new IndexBuilder() - .addBlockIndex(1) - .build(); - const index = new Index(); + it('round-trips a PropertyIndex with documentId', () => { + const idx = Index.property('time', 'doc1' as DocumentId); + const parsed = Index.parse(idx.serialize()) as PropertyIndex; - index.compositeSegments = [blockOnlyA, blockOnlyB]; - - expect(() => index.validate()).toThrow('Invalid index'); + expect(parsed.propertyName).toBe('time'); + expect(parsed.documentId).toBe('doc1'); }); - /** - * Each case sets exactly one root field so `hasOtherFields` is true; LogicalOperator mutants - * that turn a specific `||` into `&&` would clear the whole chain and miss the throw. - */ - describe('validate rejects any root-level field alongside composite segments', () => { - const validTextA = (): Index => - new IndexBuilder().addBlockIndex(0) - .addDataKey('a' as DataKey) - .addTextRange([1, 2]) - .build(); - const validTextB = (): Index => - new IndexBuilder().addBlockIndex(1) - .addDataKey('a' as DataKey) - .addTextRange([0, compositeSecondSegmentEnd]) - .build(); - - const baseComposite = (): Index => { - const index = new Index(); - - index.compositeSegments = [validTextA(), validTextB()]; - - return index; - }; - - it('throws when root has only textRange set', () => { - const index = baseComposite(); - - index.textRange = [0, 1]; - - expect(() => index.validate()).toThrow('Invalid index'); - }); - - it('throws when root has only dataKey set', () => { - const index = baseComposite(); - - index.dataKey = 'root' as DataKey; - - expect(() => index.validate()).toThrow('Invalid index'); - }); - - it('throws when root has only blockIndex set', () => { - const index = baseComposite(); - - index.blockIndex = 99; + it('round-trips a BlockIndex', () => { + const idx = Index.block(7, 'doc1' as DocumentId); + const parsed = Index.parse(idx.serialize()) as BlockIndex; - expect(() => index.validate()).toThrow('Invalid index'); - }); - - it('throws when root has only tuneName set', () => { - const index = baseComposite(); - - index.tuneName = 't' as BlockTuneName; - - expect(() => index.validate()).toThrow('Invalid index'); - }); - - it('throws when root has only tuneKey set', () => { - const index = baseComposite(); - - index.tuneKey = 'k'; - - expect(() => index.validate()).toThrow('Invalid index'); - }); - - it('throws when root has only propertyName set', () => { - const index = baseComposite(); - - index.propertyName = 'p'; - - expect(() => index.validate()).toThrow('Invalid index'); - }); - - it('throws when root has only documentId set', () => { - const index = baseComposite(); + expect(parsed.blockIndex).toBe(7); + expect(parsed.documentId).toBe('doc1'); + }); - index.documentId = 'doc' as DocumentIndex; + it('round-trips a TuneIndex', () => { + const idx = Index.tune(3, 'header' as BlockTuneName, 'level'); + const parsed = Index.parse(idx.serialize()) as TuneIndex; - expect(() => index.validate()).toThrow('Invalid index'); - }); + expect(parsed.blockIndex).toBe(3); + expect(parsed.tuneName).toBe('header'); + expect(parsed.tuneKey).toBe('level'); }); - it('should return false for isTextIndex on composite', () => { - const a = new IndexBuilder().addBlockIndex(0) - .addDataKey('a' as DataKey) - .addTextRange([1, 2]) - .build(); - const b = new IndexBuilder().addBlockIndex(1) - .addDataKey('a' as DataKey) - .addTextRange([0, compositeSecondSegmentEnd]) - .build(); - const composite = Index.fromCompositeSegments([a, b]); + it('round-trips a DataIndex', () => { + const idx = Index.data(3, 'content' as DataKey); + const parsed = Index.parse(idx.serialize()) as DataIndex; - expect(composite.isTextIndex).toBe(false); + expect(parsed.blockIndex).toBe(3); + expect(parsed.dataKey).toBe('content'); }); - it('should return false for isTextIndex on composite even when root has block, data key, and text range', () => { - const a = new IndexBuilder().addBlockIndex(0) - .addDataKey('a' as DataKey) - .addTextRange([1, 2]) - .build(); - const b = new IndexBuilder().addBlockIndex(1) - .addDataKey('a' as DataKey) - .addTextRange([0, compositeSecondSegmentEnd]) - .build(); - const index = new Index(); - - index.compositeSegments = [a, b]; - index.blockIndex = 0; - index.dataKey = 'a' as DataKey; - index.textRange = [0, 1]; - - expect(index.isTextIndex).toBe(false); - }); - - it('should return segments from getTextSegments', () => { - const a = new IndexBuilder().addBlockIndex(0) - .addDataKey('a' as DataKey) - .addTextRange([1, 2]) - .build(); - const b = new IndexBuilder().addBlockIndex(1) - .addDataKey('a' as DataKey) - .addTextRange([0, compositeSecondSegmentEnd]) - .build(); - const composite = Index.fromCompositeSegments([a, b]); + it('round-trips a single-segment TextIndex', () => { + const idx = Index.text([{ blockIndex: 3, + dataKey: 'content' as DataKey, + textRange: [0, 5] }]); + const parsed = Index.parse(idx.serialize()) as TextIndex; - expect(composite.getTextSegments()).toHaveLength(2); + expect(parsed.blockIndex).toBe(3); + expect(parsed.dataKey).toBe('content'); + expect(parsed.textRange).toEqual([0, 5]); + expect(parsed.isTextIndex).toBe(true); }); - it('should use text index path when compositeSegments is empty array (getTextSegments returns [self])', () => { - const index = new IndexBuilder().addBlockIndex(0) - .addDataKey('a' as DataKey) - .addTextRange([1, 2]) - .build(); + it('round-trips a composite TextIndex', () => { + const composite = Index.fromCompositeSegments([ + Index.text([{ blockIndex: 0, + dataKey: 'key' as DataKey, + textRange: [1, 2] }]), + Index.text([{ blockIndex: 1, + dataKey: 'key' as DataKey, + textRange: [0, 5] }]), + ]); + const parsed = Index.parse(composite.serialize()) as TextIndex; - index.compositeSegments = []; + expect(parsed.isComposite).toBe(true); + const segs = parsed.getTextSegments(); - expect(index.validate()).toBe(true); - expect(index.getTextSegments()).toHaveLength(1); - expect(index.getTextSegments()[0]).toBe(index); + expect(segs).toHaveLength(2); + expect(segs[0].blockIndex).toBe(0); + expect(segs[1].blockIndex).toBe(1); }); - it('should serialize as legacy when compositeSegments is empty array', () => { - const index = new IndexBuilder().addBlockIndex(0) - .addDataKey('a' as DataKey) - .addTextRange([1, 2]) - .build(); - - index.compositeSegments = []; + it('emits the expected wire object for BlockIndex', () => { + expect(JSON.parse(Index.block(3, 'doc1' as DocumentId).serialize())).toEqual({ k: 'block', + b: 3, + id: 'doc1' }); + }); - expect(typeof JSON.parse(index.serialize())).toBe('string'); + it('emits the expected wire object for TuneIndex', () => { + expect(JSON.parse(Index.tune(3, 'header' as BlockTuneName, 'level').serialize())).toEqual({ k: 'tune', + b: 3, + tune: 'header', + key: 'level' }); }); - it('should throw if composite has less than two segments', () => { - const a = new IndexBuilder().addBlockIndex(0) - .addDataKey('a' as DataKey) - .addTextRange([1, 2]) - .build(); + it('emits the expected wire object for DataIndex', () => { + expect(JSON.parse(Index.data(3, 'content' as DataKey).serialize())).toEqual({ k: 'data', + b: 3, + data: 'content' }); + }); - expect(() => Index.fromCompositeSegments([a])).toThrow('Invalid index'); + it('emits the expected wire object for single-segment TextIndex', () => { + expect(JSON.parse(Index.text([{ blockIndex: 3, + dataKey: 'key' as DataKey, + textRange: [0, 5] }]).serialize())).toEqual({ k: 'text', + b: 3, + data: 'key', + r: [0, 5] }); }); - it('should throw when parsing composite JSON with fewer than two string segments', () => { - const a = new IndexBuilder().addBlockIndex(0) - .addDataKey('a' as DataKey) - .addTextRange([1, 2]) - .build(); - const payload = JSON.stringify({ composite: [a.serialize()] }); + it('emits the expected wire object for composite TextIndex', () => { + const composite = Index.fromCompositeSegments([ + Index.text([{ blockIndex: 0, + dataKey: 'key' as DataKey, + textRange: [1, 2] }]), + Index.text([{ blockIndex: 1, + dataKey: 'key' as DataKey, + textRange: [0, 5] }]), + ]); - expect(() => Index.parse(payload)).toThrow('Invalid index'); + expect(JSON.parse(composite.serialize())).toEqual({ + k: 'composite', + segs: [ + { b: 0, + data: 'key', + r: [1, 2] }, + { b: 1, + data: 'key', + r: [0, 5] }, + ], + }); }); - it('should throw when composite property is not an array', () => { - expect(() => Index.parse(JSON.stringify({ composite: null }))).toThrow('Invalid composite index'); - expect(() => Index.parse(JSON.stringify({ composite: {} }))).toThrow('Invalid composite index'); + it('omits documentId from wire format when undefined', () => { + expect(JSON.parse(Index.block(3).serialize())).not.toHaveProperty('id'); + expect(JSON.parse(Index.data(3, 'key' as DataKey).serialize())).not.toHaveProperty('id'); }); - it('should throw when composite array contains a non-string segment', () => { - const payload = JSON.stringify({ composite: [0, 1] }); - - expect(() => Index.parse(payload)).toThrow('Invalid composite index: each segment must be a serialized index string'); + it('throws when parsing input that is not a JSON object with a k field', () => { + expect(() => Index.parse('null')).toThrow(); + expect(() => Index.parse('"string"')).toThrow(); + expect(() => Index.parse('42')).toThrow(); + expect(() => Index.parse('{}')).toThrow('Invalid serialized index'); }); - it('should clone composite segments', () => { - const a = new IndexBuilder().addBlockIndex(0) - .addDataKey('a' as DataKey) - .addTextRange([1, 2]) - .build(); - const b = new IndexBuilder().addBlockIndex(1) - .addDataKey('a' as DataKey) - .addTextRange([0, compositeSecondSegmentEnd]) - .build(); - const composite = Index.fromCompositeSegments([a, b]); - const cloned = composite.clone(); - - expect(cloned.compositeSegments).toEqual(composite.compositeSegments); - expect(cloned.compositeSegments![0]).not.toBe(composite.compositeSegments![0]); + it('throws when parsing an unknown kind', () => { + expect(() => Index.parse('{"k":"unknown"}')).toThrow('Unknown index kind'); }); }); }); diff --git a/packages/model-types/src/Index/IndexBase.ts b/packages/model-types/src/Index/IndexBase.ts new file mode 100644 index 00000000..fda548a6 --- /dev/null +++ b/packages/model-types/src/Index/IndexBase.ts @@ -0,0 +1,118 @@ +import type { BlockTuneName } from '../BlockTune.js'; +import type { DataKey } from '../DataKey.js'; +import type { DocumentId } from '../indexing.js'; +import type { TextRange } from '../Text.js'; + +/** + * Discriminant values identifying what each concrete Index subclass represents + */ +export enum IndexKind { + /** Root-level document scope */ + Document = 'document', + /** Document property scope */ + Property = 'property', + /** Block scope */ + Block = 'block', + /** Block tune scope */ + Tune = 'tune', + /** Block data-key scope */ + Data = 'data', + /** Inline text scope (single or composite) */ + Text = 'text' +} + +/** + * Describes a single text segment within a TextIndex + */ +export interface TextSegment { + /** Zero-based block index within the document */ + blockIndex: number; + /** Data key of the block's text property */ + dataKey: DataKey; + /** Start/end character offsets within the text value */ + textRange: TextRange; + /** Optional document identifier */ + documentId?: DocumentId; +} + +/** + * Internal bag of optional fields used when building or copying an Index + */ +export interface IndexFields { + /** Text range within the data property */ + textRange?: TextRange; + /** Data key within the block */ + dataKey?: DataKey; + /** Tune name within the block */ + tuneName?: BlockTuneName; + /** Tune key within the block tune */ + tuneKey?: string; + /** Block index within the document */ + blockIndex?: number; + /** Property name within the document */ + propertyName?: string; + /** Document identifier */ + documentId?: DocumentId; + /** Complete text segments for multi-segment TextIndex construction */ + segments?: TextSegment[]; +} + +/** + * Abstract base class for all document model index types. + * Use the static factory methods on Index (re-exported from the index barrel) to create instances. + * Use the `kind` discriminant to narrow to a concrete subclass before accessing type-specific fields. + */ +export abstract class IndexBase { + /** + * Discriminant identifying the concrete subclass + */ + public readonly kind: IndexKind; + + /** + * @param kind - value identifying the concrete subclass + */ + protected constructor(kind: IndexKind) { + this.kind = kind; + } + + /** + * Returns a copy of this index with the block index replaced. + * Override in subclasses that carry a block position. + * @param _blockIndex - updated block position + */ + public withBlockIndex(_blockIndex: number): IndexBase { + throw new Error(`withBlockIndex is not supported for ${this.kind} index`); + } + + /** + * Returns a copy of this index with the text range replaced. + * Override in TextIndex. + * @param _textRange - updated character range + */ + public withTextRange(_textRange: TextRange): IndexBase { + throw new Error(`withTextRange is not supported for ${this.kind} index`); + } + + /** + * Returns a copy of this index with the document id replaced. + * @param _documentId - updated document identifier + */ + public withDocumentId(_documentId: DocumentId): IndexBase { + throw new Error(`withDocumentId is not supported for ${this.kind} index`); + } + + /** + * Optional document identifier carried by every index type + */ + public abstract get documentId(): DocumentId | undefined; + + /** + * Creates a deep copy of this index + */ + public abstract clone(): IndexBase; + + /** + * Serializes this index to a JSON string + */ + public abstract serialize(): string; +} diff --git a/packages/model-types/src/Index/IndexBuilder.spec.ts b/packages/model-types/src/Index/IndexBuilder.spec.ts deleted file mode 100644 index fcceade5..00000000 --- a/packages/model-types/src/Index/IndexBuilder.spec.ts +++ /dev/null @@ -1,93 +0,0 @@ -import type { DataKey, DocumentIndex, BlockTuneName } from '@editorjs/model-types'; -import { IndexBuilder } from './IndexBuilder.js'; - -describe('IndexBuilder', () => { - it('should add text range to the index', () => { - const builder = new IndexBuilder(); - - builder.addTextRange([0, 1]); - - expect(builder.build().textRange).toEqual([0, 1]); - }); - - it('should add data key to the index', () => { - const builder = new IndexBuilder(); - - builder.addDataKey('dataKey' as DataKey); - - expect(builder.build().dataKey).toEqual('dataKey'); - }); - - it('should add tune key to the index', () => { - const builder = new IndexBuilder(); - - builder.addTuneKey('tuneKey'); - - expect(builder.build().tuneKey).toEqual('tuneKey'); - }); - - it('should add tune name to the index', () => { - const builder = new IndexBuilder(); - - builder.addTuneName('tuneName' as BlockTuneName); - builder.addTuneKey('tuneKey'); - - expect(builder.build().tuneName).toEqual('tuneName'); - }); - - it('should add block index to the index', () => { - const builder = new IndexBuilder(); - - builder.addBlockIndex(1); - - expect(builder.build().blockIndex).toEqual(1); - }); - - it('should add property name to the index', () => { - const builder = new IndexBuilder(); - - builder.addPropertyName('propertyName'); - - expect(builder.build().propertyName).toEqual('propertyName'); - }); - - it('should add document id to the index', () => { - const builder = new IndexBuilder(); - - builder.addDocumentId('documentId' as DocumentIndex); - - expect(builder.build().documentId).toEqual('documentId'); - }); - - it('should throw an error if index is invalid', () => { - const builder = new IndexBuilder(); - - builder.addBlockIndex(1).addTextRange([0, 1]); - - expect(() => builder.build().validate()).toThrow(); - }); - - it('should clone the index with .from() method', () => { - const builder = new IndexBuilder(); - - const index = builder - .addBlockIndex(1) - .addDataKey('dataKey' as DataKey) - .addTextRange([0, 1]) - .build(); - - const cloned = builder.from(index).build(); - - expect(cloned).toEqual(index); - }); - - it('should create an index from serialized string', () => { - const builder = new IndexBuilder(); - - const index = builder.from(`"block@1:data@dataKey:[0,1]"`).build(); - - expect(index.blockIndex).toEqual(1); - expect(index.dataKey).toEqual('dataKey'); - expect(index.textRange).toEqual([0, 1]); - }); -}); diff --git a/packages/model-types/src/Index/IndexBuilder.ts b/packages/model-types/src/Index/IndexBuilder.ts deleted file mode 100644 index 1cf0b345..00000000 --- a/packages/model-types/src/Index/IndexBuilder.ts +++ /dev/null @@ -1,117 +0,0 @@ -import type { BlockTuneName } from '../BlockTune.js'; -import type { DataKey } from '../DataKey.js'; -import type { DocumentId } from '../indexing.js'; -import type { TextRange } from '../Text.js'; -import { Index } from './index.js'; - -/** - * Builder for the Index class - */ -export class IndexBuilder { - #index = new Index(); - - /** - * Sets the character range the index points to within a text data property - * @param range - start/end offsets of the text range - */ - public addTextRange(range?: TextRange): this { - this.#index.textRange = range; - - return this; - } - - /** - * Sets the data property the index points to within a block node - * @param key - identifier of the data property - */ - public addDataKey(key?: DataKey): this { - this.#index.dataKey = key; - - return this; - } - - /** - * Sets the tune-specific property the index points to - * @param key - identifier of the tune property - */ - public addTuneKey(key?: string): this { - this.#index.tuneKey = key; - - return this; - } - - /** - * Sets the block tune the index points to - * @param name - identifier of the tune - */ - public addTuneName(name?: BlockTuneName): this { - this.#index.tuneName = name; - - return this; - } - - /** - * Sets the position of the block node the index points to - * @param index - zero-based position of the block within the document - */ - public addBlockIndex(index?: number): this { - this.#index.blockIndex = index; - - return this; - } - - /** - * Sets the document-level property the index points to - * @param name - identifier of the property - */ - public addPropertyName(name?: string): this { - this.#index.propertyName = name; - - return this; - } - - /** - * Sets the document the index points to - * @param id - identifier of the document - */ - public addDocumentId(id?: DocumentId): this { - this.#index.documentId = id; - - return this; - } - - /** - * Validates the accumulated fields and returns the resulting Index - */ - public build(): Index { - this.#index.validate(); - - return this.#index; - } - - /** - * Resets the builder to a previously serialized index - * @param json - result of Index.serialize() - */ - public from(json: string): this; - /** - * Resets the builder to a copy of an existing index - * @param index - source index to copy - */ - public from(index: Index): this; - /** - * Resets the builder to a copy of an existing index, or one parsed from its serialized form - * @param indexOrJSON - source index, or its serialized form - */ - public from(indexOrJSON: Index | string): this { - if (typeof indexOrJSON === 'string') { - this.#index = Index.parse(indexOrJSON); - - return this; - } - - this.#index = indexOrJSON.clone(); - - return this; - } -} diff --git a/packages/model-types/src/Index/PartialIndex.ts b/packages/model-types/src/Index/PartialIndex.ts new file mode 100644 index 00000000..9acfede2 --- /dev/null +++ b/packages/model-types/src/Index/PartialIndex.ts @@ -0,0 +1,241 @@ +import type { BlockTuneName } from '../BlockTune.js'; +import type { DataKey } from '../DataKey.js'; +import type { DocumentId } from '../indexing.js'; +import type { TextRange } from '../Text.js'; +import { IndexBase, IndexKind, type IndexFields } from './IndexBase.js'; +import { BlockIndex } from './BlockIndex.js'; +import { DataIndex } from './DataIndex.js'; +import { DocumentIndex } from './DocumentIndex.js'; +import { PropertyIndex } from './PropertyIndex.js'; +import { TextIndex } from './TextIndex.js'; +import { TuneIndex } from './TuneIndex.js'; + +/** + * Internal placeholder used during event bubbling. + * Stores arbitrary field combinations before the full context (blockIndex, documentId) is available. + * Never exposed to external consumers — EditorDocument converts these to real concrete classes. + */ +export class PartialIndex extends IndexBase { + readonly #fields: Partial; + + /** + * @param fields - partial set of index fields + */ + constructor(fields: Partial) { + super(IndexKind.Document); + this.#fields = fields; + } + + /** + * Text range if set + */ + public get textRange(): TextRange | undefined { + return this.#fields.textRange; + } + + /** + * Data key if set + */ + public get dataKey(): DataKey | undefined { + return this.#fields.dataKey; + } + + /** + * Tune name if set + */ + public get tuneName(): BlockTuneName | undefined { + return this.#fields.tuneName; + } + + /** + * Tune key if set + */ + public get tuneKey(): string | undefined { + return this.#fields.tuneKey; + } + + /** + * Block index if set + */ + public get blockIndex(): number | undefined { + return this.#fields.blockIndex; + } + + /** + * Property name if set + */ + public get propertyName(): string | undefined { + return this.#fields.propertyName; + } + + /** + * Document identifier if set + */ + public override get documentId(): DocumentId | undefined { + return this.#fields.documentId; + } + + /** + * True when all text index fields are present + */ + public get isTextIndex(): boolean { + return this.#fields.blockIndex !== undefined + && this.#fields.dataKey !== undefined + && this.#fields.textRange !== undefined; + } + + /** + * True when only block index fields are present + */ + public get isBlockIndex(): boolean { + return this.#fields.blockIndex !== undefined + && this.#fields.tuneName === undefined + && this.#fields.dataKey === undefined + && this.#fields.textRange === undefined; + } + + /** + * True when only data index fields are present + */ + public get isDataIndex(): boolean { + return this.#fields.blockIndex !== undefined + && this.#fields.tuneName === undefined + && this.#fields.dataKey !== undefined + && this.#fields.textRange === undefined; + } + + /** + * @param blockIndex - updated block position + */ + public override withBlockIndex(blockIndex: number): PartialIndex { + return new PartialIndex({ + ...this.#fields, + blockIndex, + }); + } + + /** + * @param textRange - updated character range + */ + public override withTextRange(textRange: TextRange): PartialIndex { + return new PartialIndex({ + ...this.#fields, + textRange, + }); + } + + /** + * @param documentId - updated document identifier + */ + public override withDocumentId(documentId: DocumentId): PartialIndex { + return new PartialIndex({ + ...this.#fields, + documentId, + }); + } + + /** + * Resolves the accumulated fields into a concrete Index instance. + * Call after all context (blockIndex, documentId) has been added via with* methods. + */ + public resolve(): IndexBase { + const { + textRange, + dataKey, + tuneName, + tuneKey, + blockIndex, + propertyName, + documentId, + segments, + } = this.#fields; + + if (segments !== undefined) { + return new TextIndex(segments); + } + + if (propertyName !== undefined) { + if ( + blockIndex !== undefined + || dataKey !== undefined + || tuneName !== undefined + || textRange !== undefined + ) { + throw new Error( + 'PropertyIndex cannot be combined with block-related fields' + ); + } + + return new PropertyIndex(propertyName, documentId); + } + + if (textRange !== undefined) { + if (blockIndex === undefined) { + throw new Error('TextIndex requires blockIndex'); + } + if (dataKey === undefined) { + throw new Error('TextIndex requires dataKey'); + } + + return new TextIndex([ + { + blockIndex, + dataKey, + textRange, + documentId, + }, + ]); + } + + if (dataKey !== undefined) { + if (tuneName !== undefined) { + throw new Error('DataIndex cannot be combined with tuneName'); + } + if (blockIndex === undefined) { + throw new Error('DataIndex requires blockIndex'); + } + + return new DataIndex(blockIndex, dataKey, documentId); + } + + if (tuneName !== undefined || tuneKey !== undefined) { + if (blockIndex === undefined) { + throw new Error('TuneIndex requires blockIndex'); + } + if (tuneName === undefined || tuneName.length === 0) { + throw new Error('TuneIndex requires tuneName'); + } + if (tuneKey === undefined) { + throw new Error('TuneIndex requires tuneKey'); + } + + return new TuneIndex(blockIndex, tuneName, tuneKey, documentId); + } + + if (blockIndex !== undefined) { + return new BlockIndex(blockIndex, documentId); + } + + if (documentId !== undefined) { + return new DocumentIndex(documentId); + } + + throw new Error('Cannot construct an index with no fields set'); + } + + /** + * Creates a deep copy + */ + public clone(): PartialIndex { + return new PartialIndex({ ...this.#fields }); + } + + /** + * Not supported — partial indices must be resolved before serialization + */ + public serialize(): string { + throw new Error( + 'PartialIndex cannot be serialized — ensure EditorDocument converts it to a concrete Index before serializing' + ); + } +} diff --git a/packages/model-types/src/Index/PropertyIndex.ts b/packages/model-types/src/Index/PropertyIndex.ts new file mode 100644 index 00000000..bd87aa60 --- /dev/null +++ b/packages/model-types/src/Index/PropertyIndex.ts @@ -0,0 +1,59 @@ +import type { DocumentId } from '../indexing.js'; +import { IndexBase, IndexKind } from './IndexBase.js'; + +/** + * Index scoped to a document property + */ +export class PropertyIndex extends IndexBase { + readonly #propertyName: string; + readonly #documentId?: DocumentId; + + /** + * @param propertyName - name of the document property + * @param documentId - optional document identifier + */ + constructor(propertyName: string, documentId?: DocumentId) { + super(IndexKind.Property); + this.#propertyName = propertyName; + this.#documentId = documentId; + } + + /** + * The name of the indexed document property + */ + public get propertyName(): string { + return this.#propertyName; + } + + /** + * The unique identifier of the indexed document + */ + public override get documentId(): DocumentId | undefined { + return this.#documentId; + } + + /** + * @param documentId - updated document identifier + */ + public override withDocumentId(documentId: DocumentId): PropertyIndex { + return new PropertyIndex(this.#propertyName, documentId); + } + + /** + * Creates a deep copy + */ + public clone(): PropertyIndex { + return new PropertyIndex(this.#propertyName, this.#documentId); + } + + /** + * Serializes to JSON + */ + public serialize(): string { + return JSON.stringify({ + k: 'prop', + name: this.#propertyName, + ...(this.#documentId !== undefined && { id: this.#documentId }), + }); + } +} diff --git a/packages/model-types/src/Index/TextIndex.ts b/packages/model-types/src/Index/TextIndex.ts new file mode 100644 index 00000000..d046e2e2 --- /dev/null +++ b/packages/model-types/src/Index/TextIndex.ts @@ -0,0 +1,181 @@ +import type { DataKey } from '../DataKey.js'; +import type { DocumentId } from '../indexing.js'; +import type { TextRange } from '../Text.js'; +import { IndexBase, IndexKind, type TextSegment } from './IndexBase.js'; + +/** + * Index scoped to one or more inline text ranges. + * A single-segment instance represents a simple text index; + * a multi-segment instance is a composite covering disjoint ranges. + */ +export class TextIndex extends IndexBase { + #segments: TextSegment[]; + + /** + * @param segments - one or more text segments + */ + constructor(segments: TextSegment[]) { + if (segments.length === 0) { + throw new Error('TextIndex requires at least one segment'); + } + super(IndexKind.Text); + this.#segments = segments; + } + + /** + * Read-only view of all segments in this index + */ + public get segments(): readonly TextSegment[] { + return this.#segments; + } + + /** + * True when this index has exactly one segment + */ + public get isTextIndex(): boolean { + return this.#segments.length === 1; + } + + /** + * True when this index has more than one segment + */ + public get isComposite(): boolean { + return this.#segments.length > 1; + } + + /** + * Block index of the sole segment; undefined for composite instances + */ + public get blockIndex(): number | undefined { + return this.#segments.length === 1 + ? this.#segments[0].blockIndex + : undefined; + } + + /** + * Data key of the sole segment; undefined for composite instances + */ + public get dataKey(): DataKey | undefined { + return this.#segments.length === 1 + ? this.#segments[0].dataKey + : undefined; + } + + /** + * Text range of the sole segment; undefined for composite instances + */ + public get textRange(): TextRange | undefined { + return this.#segments.length === 1 + ? this.#segments[0].textRange + : undefined; + } + + /** + * Document identifier of the sole segment; undefined for composite instances + */ + public override get documentId(): DocumentId | undefined { + return this.#segments.length === 1 + ? this.#segments[0].documentId + : undefined; + } + + /** + * Expands each segment into its own single-segment TextIndex + */ + public getTextSegments(): TextIndex[] { + return this.#segments.map(seg => new TextIndex([seg])); + } + + /** + * Returns a new TextIndex with the block index updated across all segments + * @param blockIndex - updated block position + */ + public override withBlockIndex(blockIndex: number): TextIndex { + return new TextIndex( + this.#segments.map(seg => ({ + ...seg, + blockIndex, + })) + ); + } + + /** + * Returns a new single-segment TextIndex with the text range replaced. + * Throws if this is a composite (multi-segment) index. + * @param textRange - updated character range + */ + public override withTextRange(textRange: TextRange): TextIndex { + if (this.#segments.length !== 1) { + throw new Error('withTextRange requires a single-segment TextIndex'); + } + + return new TextIndex([ + { + ...this.#segments[0], + textRange, + }, + ]); + } + + /** + * Returns a new TextIndex with the document id updated across all segments + * @param documentId - updated document identifier + */ + public override withDocumentId(documentId: DocumentId): TextIndex { + return new TextIndex( + this.#segments.map(seg => ({ + ...seg, + documentId, + })) + ); + } + + /** + * Creates a deep copy + */ + public clone(): TextIndex { + return new TextIndex(this.#segments.map(seg => ({ ...seg }))); + } + + /** + * Serializes to JSON. Single-segment instances produce a `text` object; + * composite instances produce a `composite` object with a `segs` array. + */ + public serialize(): string { + if (this.#segments.length === 1) { + return this.#serializeSegment(this.#segments[0]); + } + + return JSON.stringify({ + k: 'composite', + segs: this.#segments.map(seg => this.#segmentToJSON(seg)), + }); + } + + /** + * Converts a segment to its JSON representation (used inside composite serialization) + * @param seg - segment to convert + */ + #segmentToJSON(seg: TextSegment): object { + return { + b: seg.blockIndex, + data: seg.dataKey, + r: seg.textRange, + ...(seg.documentId !== undefined && { id: seg.documentId }), + }; + } + + /** + * Serializes a single segment to a JSON string + * @param seg - segment to serialize + */ + #serializeSegment(seg: TextSegment): string { + return JSON.stringify({ + k: 'text', + b: seg.blockIndex, + data: seg.dataKey, + r: seg.textRange, + ...(seg.documentId !== undefined && { id: seg.documentId }), + }); + } +} diff --git a/packages/model-types/src/Index/TuneIndex.ts b/packages/model-types/src/Index/TuneIndex.ts new file mode 100644 index 00000000..e9b0fbf3 --- /dev/null +++ b/packages/model-types/src/Index/TuneIndex.ts @@ -0,0 +1,99 @@ +import type { BlockTuneName } from '../BlockTune.js'; +import type { DocumentId } from '../indexing.js'; +import { IndexBase, IndexKind } from './IndexBase.js'; + +/** + * Index scoped to a block tune + */ +export class TuneIndex extends IndexBase { + readonly #blockIndex: number; + readonly #tuneName: BlockTuneName; + readonly #tuneKey: string; + readonly #documentId?: DocumentId; + + /** + * @param blockIndex - zero-based block position + * @param tuneName - name of the block tune + * @param tuneKey - key within the block tune + * @param documentId - optional document identifier + */ + constructor( + blockIndex: number, + tuneName: BlockTuneName, + tuneKey: string, + documentId?: DocumentId + ) { + super(IndexKind.Tune); + this.#blockIndex = blockIndex; + this.#tuneName = tuneName; + this.#tuneKey = tuneKey; + this.#documentId = documentId; + } + + /** + * The zero-based position of the indexed block + */ + public get blockIndex(): number { + return this.#blockIndex; + } + + /** + * The name of the indexed block tune + */ + public get tuneName(): BlockTuneName { + return this.#tuneName; + } + + /** + * The key within the indexed block tune + */ + public get tuneKey(): string { + return this.#tuneKey; + } + + /** + * The unique identifier of the indexed document + */ + public override get documentId(): DocumentId | undefined { + return this.#documentId; + } + + /** + * @param blockIndex - updated block position + */ + public override withBlockIndex(blockIndex: number): TuneIndex { + return new TuneIndex(blockIndex, this.#tuneName, this.#tuneKey, this.#documentId); + } + + /** + * @param documentId - updated document identifier + */ + public override withDocumentId(documentId: DocumentId): TuneIndex { + return new TuneIndex(this.#blockIndex, this.#tuneName, this.#tuneKey, documentId); + } + + /** + * Creates a deep copy + */ + public clone(): TuneIndex { + return new TuneIndex( + this.#blockIndex, + this.#tuneName, + this.#tuneKey, + this.#documentId + ); + } + + /** + * Serializes to JSON + */ + public serialize(): string { + return JSON.stringify({ + k: 'tune', + b: this.#blockIndex, + tune: this.#tuneName, + key: this.#tuneKey, + ...(this.#documentId !== undefined && { id: this.#documentId }), + }); + } +} diff --git a/packages/model-types/src/Index/index.ts b/packages/model-types/src/Index/index.ts index 0eaae773..30650d15 100644 --- a/packages/model-types/src/Index/index.ts +++ b/packages/model-types/src/Index/index.ts @@ -1,288 +1,224 @@ +export { IndexBase, IndexKind, type IndexFields, type TextSegment } from './IndexBase.js'; +export { DocumentIndex } from './DocumentIndex.js'; +export { PropertyIndex } from './PropertyIndex.js'; +export { BlockIndex } from './BlockIndex.js'; +export { TuneIndex } from './TuneIndex.js'; +export { DataIndex } from './DataIndex.js'; +export { TextIndex } from './TextIndex.js'; +export { PartialIndex } from './PartialIndex.js'; + +import { IndexBase, IndexKind, type TextSegment } from './IndexBase.js'; +import type { DocumentId } from '../indexing.js'; import type { BlockTuneName } from '../BlockTune.js'; import type { DataKey } from '../DataKey.js'; -import type { DocumentId } from '../indexing.js'; import type { TextRange } from '../Text.js'; +import { DocumentIndex } from './DocumentIndex.js'; +import { PropertyIndex } from './PropertyIndex.js'; +import { BlockIndex } from './BlockIndex.js'; +import { TuneIndex } from './TuneIndex.js'; +import { DataIndex } from './DataIndex.js'; +import { TextIndex } from './TextIndex.js'; /** - * Shape of the JSON root object produced for a composite serialized index (see {@link Index.serialize}) + * Shape of the JSON object produced when serializing a single-segment TextIndex */ -interface CompositeIndexRoot { - /** - * Serialized form of each text index segment - */ - composite: unknown; +interface SerializedTextSegment { + /** Block index */ + b: number; + /** Data key */ + data: DataKey; + /** Text range */ + r: TextRange; + /** Document identifier */ + id?: DocumentId; } /** - * Class representing index in the document model tree + * Parsed serialized index structure used in {@link Index.parse} */ -export class Index { - /** - * Text range in the text data property - */ - public textRange?: TextRange; - - /** - * Data key in the block node - */ - public dataKey?: DataKey; - - /** - * Tune name in the block node - */ - public tuneName?: BlockTuneName; - - /** - * Tune key of the tune node - */ - public tuneKey?: string; - - /** - * Index of the block node - */ - public blockIndex?: number; - - /** - * Document property name - */ - public propertyName?: string; +interface ParsedIndex { + /** Kind discriminator */ + k: string; + [key: string]: unknown; +} - /** - * Identifier of the document this index belongs to - */ - public documentId?: DocumentId; +/** + * Parses a single serialized text segment into a {@link TextSegment} + * @param seg - serialized segment object + */ +function parseSegmentObject(seg: SerializedTextSegment): TextSegment { + return { + blockIndex: seg.b, + dataKey: seg.data, + textRange: seg.r, + documentId: seg.id, + }; +} +/** + * Factory class for all document model index types. + * Extends IndexBase with static factory methods; concrete subclasses extend IndexBase directly. + * + * Symbol.hasInstance is overridden so that `value instanceof Index` returns true + * for any IndexBase instance, preserving the expected runtime behaviour even + * though concrete classes extend IndexBase rather than Index. + */ +export abstract class Index extends IndexBase { /** - * Cross-input selection: one text index per affected input, in document order + * Returns true for any IndexBase instance, keeping `instanceof Index` consistent + * with the expected public API even though concrete classes extend IndexBase directly. + * @param instance - value to test */ - public compositeSegments?: Index[]; + public static [Symbol.hasInstance](instance: unknown): boolean { + return instance instanceof IndexBase; + } /** - * Parse serialized index - * @param serialized - JSON string produced by Index.serialize() + * Creates a DocumentIndex + * @param documentId - unique document identifier */ - public static parse(serialized: string): Index { - const outer = JSON.parse(serialized) as unknown; - - if (typeof outer === 'object' && outer !== null && 'composite' in outer) { - return Index.parseCompositeIndexFromObject(outer); - } - - if (typeof outer !== 'string') { - throw new Error('Invalid serialized index: root must be a JSON string or a composite object'); - } - - const arrayIndex = outer.split(':'); - - const index = new Index(); - - for (const value of arrayIndex) { - const [type, data] = value.split('@'); - - switch (type) { - case 'doc': - index.documentId = data as DocumentId; - break; - case 'prop': - index.propertyName = data; - break; - case 'block': - index.blockIndex = Number(data); - break; - case 'tune': - index.tuneName = data as BlockTuneName; - break; - case 'tuneKey': - index.tuneKey = data; - break; - case 'data': - index.dataKey = data as DataKey; - break; - default: - index.textRange = JSON.parse(type) as TextRange; - break; - } - } - - return index; + public static document(documentId: DocumentId): DocumentIndex { + return new DocumentIndex(documentId); } /** - * Builds a composite index from at least two text indices (cross-input selection). - * @param segments - text indices for each covered input, in document order + * Creates a PropertyIndex + * @param propertyName - name of the document property + * @param documentId - optional document identifier */ - public static fromCompositeSegments(segments: Index[]): Index { - const index = new Index(); - - index.compositeSegments = segments.map(segment => segment.clone()); - index.validate(); - - return index; + public static property( + propertyName: string, + documentId?: DocumentId + ): PropertyIndex { + return new PropertyIndex(propertyName, documentId); } /** - * Parses a composite index from the JSON root object (see {@link Index.serialize}). - * @param outer - value returned by `JSON.parse` for a composite serialized index + * Creates a BlockIndex + * @param blockIndex - zero-based block position + * @param documentId - optional document identifier */ - private static parseCompositeIndexFromObject(outer: object): Index { - const composite = (outer as CompositeIndexRoot).composite; - - if (!Array.isArray(composite)) { - throw new Error('Invalid composite index'); - } - - const index = new Index(); - - index.compositeSegments = composite.map((segment) => { - if (typeof segment !== 'string') { - throw new Error('Invalid composite index: each segment must be a serialized index string'); - } - - return Index.parse(segment); - }); - - index.validate(); - - return index; + public static block( + blockIndex: number, + documentId?: DocumentId + ): BlockIndex { + return new BlockIndex(blockIndex, documentId); } /** - * Returns text segments for this index: either composite segments or a single text index. + * Creates a TuneIndex + * @param blockIndex - zero-based block position + * @param tuneName - name of the block tune + * @param tuneKey - key within the block tune + * @param documentId - optional document identifier */ - public getTextSegments(): Index[] { - if (this.compositeSegments !== undefined && this.compositeSegments.length > 0) { - return this.compositeSegments; - } - - if (this.isTextIndex) { - return [this]; - } - - return []; + public static tune( + blockIndex: number, + tuneName: BlockTuneName, + tuneKey: string, + documentId?: DocumentId + ): TuneIndex { + return new TuneIndex(blockIndex, tuneName, tuneKey, documentId); } /** - * Creates new Index object with copied values + * Creates a DataIndex + * @param blockIndex - zero-based block position + * @param dataKey - key identifying block data + * @param documentId - optional document identifier */ - public clone(): Index { - const index = new Index(); - - index.textRange = this.textRange; - index.dataKey = this.dataKey; - index.tuneName = this.tuneName; - index.tuneKey = this.tuneKey; - index.blockIndex = this.blockIndex; - index.propertyName = this.propertyName; - index.documentId = this.documentId; - index.compositeSegments = this.compositeSegments?.map(segment => segment.clone()); - - return index; + public static data( + blockIndex: number, + dataKey: DataKey, + documentId?: DocumentId + ): DataIndex { + return new DataIndex(blockIndex, dataKey, documentId); } /** - * Serialize index to string + * Creates a TextIndex from one or more text segments + * @param segments - one or more text segments */ - public serialize(): string { - if (this.compositeSegments !== undefined && this.compositeSegments.length > 0) { - return JSON.stringify({ - composite: this.compositeSegments.map(segment => segment.serialize()), - }); - } - - const arrayIndex = [ - this.documentId ? `doc@${this.documentId}` : undefined, - this.propertyName !== undefined ? `prop@${this.propertyName}` : undefined, - this.blockIndex !== undefined ? `block@${this.blockIndex}` : undefined, - this.tuneName ? `tune@${this.tuneName}` : undefined, - this.tuneKey !== undefined ? `tuneKey@${this.tuneKey}` : undefined, - this.dataKey ? `data@${this.dataKey}` : undefined, - this.textRange ? JSON.stringify(this.textRange) : undefined, - ] as const; - - return JSON.stringify(arrayIndex.filter(value => value !== undefined).join(':')); + public static text(segments: TextSegment[]): TextIndex { + return new TextIndex(segments); } /** - * Validates index + * Deserializes a JSON string produced by {@link IndexBase.serialize} back into an Index instance + * @param serialized - JSON string produced by serialize() */ - public validate(): boolean { - if (this.compositeSegments !== undefined && this.compositeSegments.length > 0) { - if (this.compositeSegments.length < 2) { - throw new Error('Invalid index'); - } - - const hasOtherFields - = this.textRange !== undefined - || this.dataKey !== undefined - || this.blockIndex !== undefined - || this.tuneName !== undefined - || this.tuneKey !== undefined - || this.propertyName !== undefined - || this.documentId !== undefined; - - if (hasOtherFields) { - throw new Error('Invalid index'); - } - - for (const segment of this.compositeSegments) { - segment.validate(); + public static parse(serialized: string): IndexBase { + const obj = JSON.parse(serialized) as ParsedIndex; - if (!segment.isTextIndex) { - throw new Error('Invalid index'); - } - } - - return true; + if (typeof obj !== 'object' || obj === null || typeof obj.k !== 'string') { + throw new Error( + 'Invalid serialized index: must be a JSON object with a "k" field' + ); } - const includesTextRange = !!this.textRange; - const includesDataKey = !!this.dataKey; - const includesTuneName = !!this.tuneName; - const includesTuneKey = this.tuneKey !== undefined; - const includesBlockIndex = this.blockIndex !== undefined; - const includesPropertyName = this.propertyName !== undefined; - const includesDocumentId = !!this.documentId; - - const includesSomethingBlockRelated = includesBlockIndex || includesTuneName || includesTuneKey || includesDataKey || includesTextRange; - - switch (true) { - case includesTuneName && (includesDataKey || includesTextRange): - case includesTuneName && !includesTuneKey: - case includesPropertyName && includesSomethingBlockRelated: - case includesBlockIndex && includesTextRange && !includesDataKey: - case includesDocumentId && includesDataKey && !includesBlockIndex: - case includesDocumentId && includesTuneName && !includesBlockIndex: - case includesDocumentId && includesTextRange && !includesBlockIndex: - case includesDocumentId && includesTuneKey && !includesBlockIndex: - throw new Error('Invalid index'); + switch (obj.k) { + case 'doc': + return new DocumentIndex(obj.id as DocumentId); + + case 'prop': + return new PropertyIndex( + obj.name as string, + obj.id as DocumentId | undefined + ); + + case 'block': + return new BlockIndex( + obj.b as number, + obj.id as DocumentId | undefined + ); + + case 'tune': + return new TuneIndex( + obj.b as number, + obj.tune as BlockTuneName, + obj.key as string, + obj.id as DocumentId | undefined + ); + + case 'data': + return new DataIndex( + obj.b as number, + obj.data as DataKey, + obj.id as DocumentId | undefined + ); + + case 'text': + return new TextIndex([ + parseSegmentObject(obj as unknown as SerializedTextSegment), + ]); + + case 'composite': + return new TextIndex( + (obj.segs as SerializedTextSegment[]).map(parseSegmentObject) + ); default: - return true; + throw new Error(`Unknown index kind: ${obj.k}`); } } /** - * Returns true if index points to the text data + * Merges one or more TextIndex instances into a single composite TextIndex + * @param segments - array of Index instances (must all be TextIndex) */ - public get isTextIndex(): boolean { - if (this.compositeSegments !== undefined && this.compositeSegments.length > 0) { - return false; + public static fromCompositeSegments(segments: IndexBase[]): TextIndex { + if (segments.length === 0) { + throw new Error('fromCompositeSegments requires at least one segment'); } - return this.blockIndex !== undefined && this.dataKey !== undefined && this.textRange !== undefined; - } + const textSegments = segments.flatMap((s) => { + if (s.kind !== IndexKind.Text) { + throw new Error('fromCompositeSegments requires text index instances'); + } - /** - * Returns true if index points to the block node - */ - public get isBlockIndex(): boolean { - return this.blockIndex !== undefined && this.tuneName === undefined && this.dataKey === undefined && this.textRange === undefined; - } + return (s as TextIndex).segments as TextSegment[]; + }); - /** - * Returns true if index points to the block node data key - */ - public get isDataIndex(): boolean { - return this.blockIndex !== undefined && this.tuneName === undefined && this.dataKey !== undefined && this.textRange === undefined; + return new TextIndex(textSegments); } } diff --git a/packages/model-types/src/events/BlockAddedEvent.ts b/packages/model-types/src/events/BlockAddedEvent.ts index 13649472..b1ab7651 100644 --- a/packages/model-types/src/events/BlockAddedEvent.ts +++ b/packages/model-types/src/events/BlockAddedEvent.ts @@ -1,19 +1,20 @@ import { EventAction } from '../EventAction.js'; import { BaseDocumentEvent } from '../BaseDocumentEvent.js'; -import type { Index } from '../Index/index.js'; +import type { BlockIndex } from '../Index/BlockIndex.js'; +import type { PartialIndex } from '../Index/PartialIndex.js'; import type { BlockNodeSerialized } from '../BlockNode.js'; /** * BlockAdded Custom Event */ -export class BlockAddedEvent extends BaseDocumentEvent { +export class BlockAddedEvent extends BaseDocumentEvent { /** * BlockAddedEvent class constructor * @param index - index of the added BlockNode in the document * @param data - BlockNode serialized data * @param userId - user identifier */ - constructor(index: Index, data: BlockNodeSerialized, userId: string | number) { + constructor(index: BlockIndex | PartialIndex, data: BlockNodeSerialized, userId: string | number) { super(index, EventAction.Added, data, userId); } } diff --git a/packages/model-types/src/events/BlockRemovedEvent.ts b/packages/model-types/src/events/BlockRemovedEvent.ts index e177ed50..f6c6433f 100644 --- a/packages/model-types/src/events/BlockRemovedEvent.ts +++ b/packages/model-types/src/events/BlockRemovedEvent.ts @@ -1,19 +1,20 @@ import { EventAction } from '../EventAction.js'; import { BaseDocumentEvent } from '../BaseDocumentEvent.js'; -import type { Index } from '../Index/index.js'; +import type { BlockIndex } from '../Index/BlockIndex.js'; +import type { PartialIndex } from '../Index/PartialIndex.js'; import type { BlockNodeSerialized } from '../BlockNode.js'; /** * BlockRemoved Custom Event */ -export class BlockRemovedEvent extends BaseDocumentEvent { +export class BlockRemovedEvent extends BaseDocumentEvent { /** * BlockRemovedEvent class constructor * @param index - index of the removed BlockNode in the document * @param data - BlockNode serialized data * @param userId - user identifier */ - constructor(index: Index, data: BlockNodeSerialized, userId: string | number) { + constructor(index: BlockIndex | PartialIndex, data: BlockNodeSerialized, userId: string | number) { super(index, EventAction.Removed, data, userId); } } diff --git a/packages/model-types/src/events/DataNodeAddedEvent.ts b/packages/model-types/src/events/DataNodeAddedEvent.ts index 284bfa5c..c51a10eb 100644 --- a/packages/model-types/src/events/DataNodeAddedEvent.ts +++ b/packages/model-types/src/events/DataNodeAddedEvent.ts @@ -1,19 +1,20 @@ import type { BlockNodeDataSerializedValue } from '../BlockNode.js'; -import type { Index } from '../Index/index.js'; +import type { DataIndex } from '../Index/DataIndex.js'; +import type { PartialIndex } from '../Index/PartialIndex.js'; import { EventAction } from '../EventAction.js'; import { BaseDocumentEvent } from '../BaseDocumentEvent.js'; /** * DataNodeAdded Custom Event */ -export class DataNodeAddedEvent extends BaseDocumentEvent { +export class DataNodeAddedEvent extends BaseDocumentEvent { /** * DataNodeAdded class constructor * @param index - index of the added BlockNode in the document * @param data - data serialized value * @param userId - user identifier */ - constructor(index: Index, data: BlockNodeDataSerializedValue, userId: string | number) { + constructor(index: DataIndex | PartialIndex, data: BlockNodeDataSerializedValue, userId: string | number) { super(index, EventAction.Added, data, userId); } } diff --git a/packages/model-types/src/events/DataNodeRemovedEvent.ts b/packages/model-types/src/events/DataNodeRemovedEvent.ts index 525516c0..ab510079 100644 --- a/packages/model-types/src/events/DataNodeRemovedEvent.ts +++ b/packages/model-types/src/events/DataNodeRemovedEvent.ts @@ -1,19 +1,20 @@ import type { BlockNodeDataSerializedValue } from '../BlockNode.js'; -import type { Index } from '../Index/index.js'; +import type { DataIndex } from '../Index/DataIndex.js'; +import type { PartialIndex } from '../Index/PartialIndex.js'; import { EventAction } from '../EventAction.js'; import { BaseDocumentEvent } from '../BaseDocumentEvent.js'; /** * DataNodeRemoved Custom Event */ -export class DataNodeRemovedEvent extends BaseDocumentEvent { +export class DataNodeRemovedEvent extends BaseDocumentEvent { /** * DataNodeRemoved class constructor * @param index - index of the added BlockNode in the document * @param data - data serialized value * @param userId - user identifier */ - constructor(index: Index, data: BlockNodeDataSerializedValue, userId: string | number) { + constructor(index: DataIndex | PartialIndex, data: BlockNodeDataSerializedValue, userId: string | number) { super(index, EventAction.Removed, data, userId); } } diff --git a/packages/model-types/src/events/PropertyModifiedEvent.ts b/packages/model-types/src/events/PropertyModifiedEvent.ts index 2c26c11f..bc3ae553 100644 --- a/packages/model-types/src/events/PropertyModifiedEvent.ts +++ b/packages/model-types/src/events/PropertyModifiedEvent.ts @@ -1,19 +1,20 @@ import { EventAction } from '../EventAction.js'; import { BaseDocumentEvent } from '../BaseDocumentEvent.js'; -import type { Index } from '../Index/index.js'; +import type { PropertyIndex } from '../Index/PropertyIndex.js'; +import type { PartialIndex } from '../Index/PartialIndex.js'; import type { ModifiedEventData } from '../EventBus.js'; /** * PropertyModified Custom Event */ -export class PropertyModifiedEvent

extends BaseDocumentEvent> { +export class PropertyModifiedEvent

extends BaseDocumentEvent, PropertyIndex> { /** * PropertyModifiedEvent class constructor * @param index - index of the modified property in the document * @param data - event data with new and previous values * @param userId - user identifier */ - constructor(index: Index, data: ModifiedEventData

, userId: string | number) { + constructor(index: PropertyIndex | PartialIndex, data: ModifiedEventData

, userId: string | number) { super(index, EventAction.Modified, data, userId); } } diff --git a/packages/model-types/src/events/TextAddedEvent.ts b/packages/model-types/src/events/TextAddedEvent.ts index 76b56c40..84f843da 100644 --- a/packages/model-types/src/events/TextAddedEvent.ts +++ b/packages/model-types/src/events/TextAddedEvent.ts @@ -1,18 +1,19 @@ import { EventAction } from '../EventAction.js'; import { BaseDocumentEvent } from '../BaseDocumentEvent.js'; -import type { Index } from '../Index/index.js'; +import type { TextIndex } from '../Index/TextIndex.js'; +import type { PartialIndex } from '../Index/PartialIndex.js'; /** * TextAdded Custom Event */ -export class TextAddedEvent extends BaseDocumentEvent { +export class TextAddedEvent extends BaseDocumentEvent { /** * TextAddedEvent class constructor * @param index - index of the added text in the document * @param text - text content that was inserted * @param userId - identifier of the user making the change */ - constructor(index: Index, text: string, userId: string | number) { + constructor(index: TextIndex | PartialIndex, text: string, userId: string | number) { super(index, EventAction.Added, text, userId); } } diff --git a/packages/model-types/src/events/TextFormattedEvent.ts b/packages/model-types/src/events/TextFormattedEvent.ts index 0af86d8d..c2808371 100644 --- a/packages/model-types/src/events/TextFormattedEvent.ts +++ b/packages/model-types/src/events/TextFormattedEvent.ts @@ -1,6 +1,7 @@ import { EventAction } from '../EventAction.js'; import { BaseDocumentEvent } from '../BaseDocumentEvent.js'; -import type { Index } from '../Index/index.js'; +import type { TextIndex } from '../Index/TextIndex.js'; +import type { PartialIndex } from '../Index/PartialIndex.js'; import type { InlineToolData, InlineToolName } from '../InlineTool.js'; /** @@ -21,14 +22,14 @@ export interface TextFormattedEventData { /** * TextFormatted Custom Event */ -export class TextFormattedEvent extends BaseDocumentEvent { +export class TextFormattedEvent extends BaseDocumentEvent { /** * TextFormattedEvent class constructor * @param index - index of the formatted text in the document * @param data - formatting tool and its data * @param userId - identifier of the user making the change */ - constructor(index: Index, data: TextFormattedEventData, userId: string | number) { + constructor(index: TextIndex | PartialIndex, data: TextFormattedEventData, userId: string | number) { super(index, EventAction.Modified, data, userId); } } diff --git a/packages/model-types/src/events/TextRemovedEvent.ts b/packages/model-types/src/events/TextRemovedEvent.ts index 1126720e..50ec619b 100644 --- a/packages/model-types/src/events/TextRemovedEvent.ts +++ b/packages/model-types/src/events/TextRemovedEvent.ts @@ -1,18 +1,19 @@ import { EventAction } from '../EventAction.js'; import { BaseDocumentEvent } from '../BaseDocumentEvent.js'; -import type { Index } from '../Index/index.js'; +import type { TextIndex } from '../Index/TextIndex.js'; +import type { PartialIndex } from '../Index/PartialIndex.js'; /** * TextRemoved Custom Event */ -export class TextRemovedEvent extends BaseDocumentEvent { +export class TextRemovedEvent extends BaseDocumentEvent { /** * TextRemovedEvent class constructor * @param index - index of the removed text in the document * @param text - text content that was deleted * @param userId - identifier of the user making the change */ - constructor(index: Index, text: string, userId: string | number) { + constructor(index: TextIndex | PartialIndex, text: string, userId: string | number) { super(index, EventAction.Removed, text, userId); } } diff --git a/packages/model-types/src/events/TextUnformattedEvent.ts b/packages/model-types/src/events/TextUnformattedEvent.ts index de386f43..769c6421 100644 --- a/packages/model-types/src/events/TextUnformattedEvent.ts +++ b/packages/model-types/src/events/TextUnformattedEvent.ts @@ -1,6 +1,7 @@ import { EventAction } from '../EventAction.js'; import { BaseDocumentEvent } from '../BaseDocumentEvent.js'; -import type { Index } from '../Index/index.js'; +import type { TextIndex } from '../Index/TextIndex.js'; +import type { PartialIndex } from '../Index/PartialIndex.js'; import type { InlineToolData, InlineToolName } from '../InlineTool.js'; /** @@ -21,14 +22,14 @@ export interface TextUnformattedEventData { /** * TextUnformatted Custom Event */ -export class TextUnformattedEvent extends BaseDocumentEvent { +export class TextUnformattedEvent extends BaseDocumentEvent { /** * TextUnformattedEvent class constructor * @param index - index of the unformatted text in the document * @param data - formatting tool and its data * @param userId - identifier of the user making the change */ - constructor(index: Index, data: TextUnformattedEventData, userId: string | number) { + constructor(index: TextIndex | PartialIndex, data: TextUnformattedEventData, userId: string | number) { super(index, EventAction.Modified, data, userId); } } diff --git a/packages/model-types/src/events/TuneModifiedEvent.ts b/packages/model-types/src/events/TuneModifiedEvent.ts index 7af0e2fd..0e8b4e08 100644 --- a/packages/model-types/src/events/TuneModifiedEvent.ts +++ b/packages/model-types/src/events/TuneModifiedEvent.ts @@ -1,19 +1,20 @@ import { EventAction } from '../EventAction.js'; import { BaseDocumentEvent } from '../BaseDocumentEvent.js'; -import type { Index } from '../Index/index.js'; +import type { TuneIndex } from '../Index/TuneIndex.js'; +import type { PartialIndex } from '../Index/PartialIndex.js'; import type { ModifiedEventData } from '../EventBus.js'; /** * TuneModified Custom Event */ -export class TuneModifiedEvent extends BaseDocumentEvent> { +export class TuneModifiedEvent extends BaseDocumentEvent, TuneIndex> { /** * TuneModifiedEvent class constructor * @param index - index of the modified tune in the document * @param data - event data with new and previous values * @param userId - user identifier */ - constructor(index: Index, data: ModifiedEventData, userId: string | number) { + constructor(index: TuneIndex | PartialIndex, data: ModifiedEventData, userId: string | number) { super(index, EventAction.Modified, data, userId); } } diff --git a/packages/model-types/src/events/ValueModifiedEvent.ts b/packages/model-types/src/events/ValueModifiedEvent.ts index cd981715..59dc8955 100644 --- a/packages/model-types/src/events/ValueModifiedEvent.ts +++ b/packages/model-types/src/events/ValueModifiedEvent.ts @@ -1,4 +1,5 @@ -import type { Index } from '../Index/index.js'; +import type { DataIndex } from '../Index/DataIndex.js'; +import type { PartialIndex } from '../Index/PartialIndex.js'; import type { ModifiedEventData } from '../BaseDocumentEvent.js'; import { EventAction } from '../EventAction.js'; import { BaseDocumentEvent } from '../BaseDocumentEvent.js'; @@ -6,14 +7,14 @@ import { BaseDocumentEvent } from '../BaseDocumentEvent.js'; /** * ValueModified Custom Event */ -export class ValueModifiedEvent extends BaseDocumentEvent> { +export class ValueModifiedEvent extends BaseDocumentEvent, DataIndex> { /** * ValueModifiedEvent class constructor * @param index - index of the modified value in the document * @param data - event data with new and previous values * @param userId - user identifier */ - constructor(index: Index, data: ModifiedEventData, userId: string | number) { + constructor(index: DataIndex | PartialIndex, data: ModifiedEventData, userId: string | number) { super(index, EventAction.Modified, data, userId); } } diff --git a/packages/model-types/src/index.ts b/packages/model-types/src/index.ts index 2d428ad4..84c7b4e6 100644 --- a/packages/model-types/src/index.ts +++ b/packages/model-types/src/index.ts @@ -78,9 +78,20 @@ export { IntersectType } from './IntersectType.js'; export { EventAction } from './EventAction.js'; export { EventType } from './EventType.js'; -export type { DocumentId, DocumentIndex } from './indexing.js'; -export { Index } from './Index/index.js'; -export { IndexBuilder } from './Index/IndexBuilder.js'; +export type { DocumentId } from './indexing.js'; +export { + Index, + IndexBase, + IndexKind, + DocumentIndex, + PropertyIndex, + BlockIndex, + TuneIndex, + DataIndex, + TextIndex, + PartialIndex, + type TextSegment +} from './Index/index.js'; export { BaseDocumentEvent, type EventPayloadBase diff --git a/packages/model-types/src/indexing.ts b/packages/model-types/src/indexing.ts index ea2ee874..257b9181 100644 --- a/packages/model-types/src/indexing.ts +++ b/packages/model-types/src/indexing.ts @@ -4,8 +4,3 @@ import type { Nominal } from './Nominal.js'; * Nominal type for document identifiers */ export type DocumentId = Nominal; - -/** - * Alias for DocumentId used in event indexing - */ -export type DocumentIndex = DocumentId; diff --git a/packages/model/src/CaretManagement/Caret/Caret.spec.ts b/packages/model/src/CaretManagement/Caret/Caret.spec.ts index ae160d5f..ad55321f 100644 --- a/packages/model/src/CaretManagement/Caret/Caret.spec.ts +++ b/packages/model/src/CaretManagement/Caret/Caret.spec.ts @@ -11,7 +11,7 @@ describe('Caret', () => { }); it('should initialize with passed index', () => { - const index = new Index(); + const index = Index.block(0); const caret = new Caret('user', index); expect(caret.index).toBe(index); @@ -19,7 +19,7 @@ describe('Caret', () => { it('should update index', () => { const caret = new Caret('user'); - const index = new Index(); + const index = Index.block(0); caret.update(index); @@ -28,7 +28,7 @@ describe('Caret', () => { it('should dispatch updated event on index update', () => { const caret = new Caret('user'); - const index = new Index(); + const index = Index.block(0); const handler = jest.fn(); @@ -43,7 +43,7 @@ describe('Caret', () => { }); it('should serialize to JSON', () => { - const index = new Index(); + const index = Index.block(0); const caret = new Caret('user', index); expect(caret.toJSON()).toEqual({ diff --git a/packages/model/src/CaretManagement/CaretManager.spec.ts b/packages/model/src/CaretManagement/CaretManager.spec.ts index 82881d6f..30a8c54e 100644 --- a/packages/model/src/CaretManagement/CaretManager.spec.ts +++ b/packages/model/src/CaretManagement/CaretManager.spec.ts @@ -14,7 +14,7 @@ describe('CaretManager', () => { it('should create new caret with passed index', () => { const manager = new CaretManager(); - const index = new Index(); + const index = Index.block(0); const caret = manager.createCaret('userId', index); @@ -27,7 +27,7 @@ describe('CaretManager', () => { manager.addEventListener(EventType.CaretManagerUpdated, handler); - const index = new Index(); + const index = Index.block(0); const caret = manager.createCaret('userId', index); expect(handler).toHaveBeenCalledWith(expect.any(CaretManagerCaretAddedEvent)); @@ -43,7 +43,7 @@ describe('CaretManager', () => { const manager = new CaretManager(); const caret = manager.createCaret('userId'); - const index = new Index(); + const index = Index.block(0); caret.update(index); @@ -57,7 +57,7 @@ describe('CaretManager', () => { manager.addEventListener(EventType.CaretManagerUpdated, handler); - const index = new Index(); + const index = Index.block(0); caret.update(index); diff --git a/packages/model/src/EditorJSModel.spec.ts b/packages/model/src/EditorJSModel.spec.ts index c6f37472..3f579420 100644 --- a/packages/model/src/EditorJSModel.spec.ts +++ b/packages/model/src/EditorJSModel.spec.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-magic-numbers */ import { beforeEach, describe } from '@jest/globals'; import { EditorJSModel } from './EditorJSModel.js'; -import { createDataKey, IndexBuilder, type BlockId } from '@editorjs/model-types'; +import { createDataKey, Index, type TextIndex, type BlockIndex, type BlockId } from '@editorjs/model-types'; import type { DocumentId } from '@editorjs/model-types'; describe('EditorJSModel', () => { @@ -192,10 +192,7 @@ describe('EditorJSModel', () => { }); it('should return the caret after it has been created', () => { - const index = new IndexBuilder() - .addDocumentId(documentId as DocumentId) - .addBlockIndex(0) - .build(); + const index = Index.block(0, documentId as DocumentId); model.createCaret(userId, index); @@ -203,10 +200,7 @@ describe('EditorJSModel', () => { }); it('should return undefined for a different user id than the one that created the caret', () => { - const index = new IndexBuilder() - .addDocumentId(documentId as DocumentId) - .addBlockIndex(0) - .build(); + const index = Index.block(0, documentId as DocumentId); model.createCaret(userId, index); @@ -276,16 +270,11 @@ describe('EditorJSModel', () => { }, }, ]; - const currentCaretIndex = new IndexBuilder() - .addDocumentId(documentId as DocumentId) - .addBlockIndex(1) - .addDataKey(createDataKey('text')) - .addTextRange([3, 3]) - .build(); - const remoteOperationIndex = new IndexBuilder() - .from(currentCaretIndex) - .addTextRange([0, 0]) - .build(); + const currentCaretIndex = Index.text([{ blockIndex: 1, + dataKey: createDataKey('text'), + textRange: [3, 3], + documentId: documentId as DocumentId }]); + const remoteOperationIndex = currentCaretIndex.withTextRange([0, 0]); let model: EditorJSModel; @@ -302,7 +291,7 @@ describe('EditorJSModel', () => { model.insertData(remoteUserId, remoteOperationIndex, 'a'); - expect(caret.index!.textRange).toEqual([4, 4]); + expect((caret.index! as TextIndex).textRange).toEqual([4, 4]); }); it('should update user caret on remote text delete operation happened before caret', () => { @@ -310,7 +299,7 @@ describe('EditorJSModel', () => { model.removeData(remoteUserId, remoteOperationIndex, 'e'); - expect(caret.index!.textRange).toEqual([2, 2]); + expect((caret.index! as TextIndex).textRange).toEqual([2, 2]); }); it('should not update caret if remote operation happened after caret', () => { @@ -318,14 +307,11 @@ describe('EditorJSModel', () => { model.removeData( remoteUserId, - new IndexBuilder() - .from(remoteOperationIndex) - .addTextRange([4, 5]) - .build(), + remoteOperationIndex.withTextRange([4, 5]), 'e' ); - expect(caret.index!.textRange).toEqual([3, 3]); + expect((caret.index! as TextIndex).textRange).toEqual([3, 3]); }); it('should update user caret on remote block insert operation happened before caret', () => { @@ -333,12 +319,7 @@ describe('EditorJSModel', () => { model.insertData( remoteUserId, - new IndexBuilder() - .from(remoteOperationIndex) - .addBlockIndex(0) - .addDataKey(undefined) - .addTextRange(undefined) - .build(), + Index.block(0, documentId as DocumentId), [{ name: 'paragraph', data: { @@ -350,7 +331,7 @@ describe('EditorJSModel', () => { }] ); - expect(caret.index!.blockIndex).toEqual(2); + expect((caret.index! as BlockIndex).blockIndex).toEqual(2); }); it('should update user caret on remote block delete operation happened before caret', () => { @@ -358,12 +339,7 @@ describe('EditorJSModel', () => { model.removeData( remoteUserId, - new IndexBuilder() - .from(remoteOperationIndex) - .addBlockIndex(0) - .addDataKey(undefined) - .addTextRange(undefined) - .build(), + Index.block(0, documentId as DocumentId), [{ name: 'paragraph', data: { @@ -375,7 +351,7 @@ describe('EditorJSModel', () => { }] ); - expect(caret.index!.blockIndex).toEqual(0); + expect((caret.index! as BlockIndex).blockIndex).toEqual(0); }); it('should not update user caret on remote block operation happened after caret', () => { @@ -383,12 +359,7 @@ describe('EditorJSModel', () => { model.removeData( remoteUserId, - new IndexBuilder() - .from(remoteOperationIndex) - .addBlockIndex(1) - .addDataKey(undefined) - .addTextRange(undefined) - .build(), + Index.block(1, documentId as DocumentId), [{ name: 'paragraph', data: { @@ -400,7 +371,7 @@ describe('EditorJSModel', () => { }] ); - expect(caret.index!.blockIndex).toEqual(1); + expect((caret.index! as BlockIndex).blockIndex).toEqual(1); }); }); }); diff --git a/packages/model/src/EditorJSModel.ts b/packages/model/src/EditorJSModel.ts index 1453f310..8b9d7521 100644 --- a/packages/model/src/EditorJSModel.ts +++ b/packages/model/src/EditorJSModel.ts @@ -5,12 +5,13 @@ import { BlockAddedEvent, BlockRemovedEvent, TextAddedEvent, - TextRemovedEvent + TextRemovedEvent, + type TextIndex, + type BlockIndex } from '@editorjs/model-types'; import { type EditorDocumentSerialized, type Index, - IndexBuilder, type BlockId, type BlockIndexOrId, type BlockNodeInit, @@ -484,10 +485,7 @@ export class EditorJSModel extends EventBus { this.#updateUserCaretByRemoteChange(event); } - const index = new IndexBuilder() - .from(event.detail.index) - .addDocumentId(this.#document.identifier) - .build(); + const index = event.detail.index.withDocumentId(this.#document.identifier); /** * Here could be any logic to filter EditorDocument events; @@ -517,38 +515,43 @@ export class EditorJSModel extends EventBus { } const caretIndex = userCaret.index; - - const newIndex = new IndexBuilder().from(caretIndex); const index = event.detail.index; + let updatedIndex: Index | null = null; switch (true) { case (event instanceof TextAddedEvent): case (event instanceof TextRemovedEvent): { - if (index.blockIndex !== caretIndex.blockIndex || index.dataKey !== caretIndex.dataKey) { + const textIndex = index as TextIndex; + const textCaretIndex = caretIndex as TextIndex; + + if (textIndex.blockIndex !== textCaretIndex.blockIndex || textIndex.dataKey !== textCaretIndex.dataKey) { return; } - if (index.textRange![0] > caretIndex.textRange![0]) { + if (textIndex.textRange![0] > textCaretIndex.textRange![0]) { return; } const delta = event.detail.data.length * (event.detail.action === EventAction.Added ? 1 : -1); - newIndex.addTextRange([caretIndex.textRange![0] + delta, caretIndex.textRange![1] + delta]); + updatedIndex = textCaretIndex.withTextRange([textCaretIndex.textRange![0] + delta, textCaretIndex.textRange![1] + delta]); break; } case (event instanceof BlockRemovedEvent): case (event instanceof BlockAddedEvent): { - if (index.blockIndex! >= caretIndex.blockIndex!) { + const blockIndex = index as BlockIndex; + const blockCaretIndex = caretIndex as BlockIndex; + + if (blockIndex.blockIndex >= blockCaretIndex.blockIndex) { return; } /** * @todo if removed block is the one the caret currently in — move caret to the previous block */ - newIndex.addBlockIndex(caretIndex.blockIndex! + (event.detail.action === EventAction.Added ? 1 : -1)); + updatedIndex = blockCaretIndex.withBlockIndex(blockCaretIndex.blockIndex + (event.detail.action === EventAction.Added ? 1 : -1)); break; } @@ -557,6 +560,8 @@ export class EditorJSModel extends EventBus { return; } - userCaret.update(newIndex.build()); + if (updatedIndex !== null) { + userCaret.update(updatedIndex); + } } } diff --git a/packages/model/src/entities/BlockNode/BlockNode.spec.ts b/packages/model/src/entities/BlockNode/BlockNode.spec.ts index cc03ddbd..801ab728 100644 --- a/packages/model/src/entities/BlockNode/BlockNode.spec.ts +++ b/packages/model/src/entities/BlockNode/BlockNode.spec.ts @@ -1,6 +1,6 @@ import { EventAction } from '@editorjs/model-types'; import { Index } from '@editorjs/model-types'; -import { IndexBuilder } from '@editorjs/model-types'; +import { PartialIndex } from '@editorjs/model-types'; import { createDataKey, createBlockId, createBlockToolName, createBlockTuneName } from '@editorjs/model-types'; import { BlockNode } from './index.js'; import { NonExistingKeyError } from './errors/NonExistingKeyError.js'; @@ -1635,8 +1635,7 @@ describe('BlockNode', () => { node.addEventListener(EventType.Changed, handler); - textNode.dispatchEvent(new TextAddedEvent(new IndexBuilder().addTextRange(range) - .build(), 'Hello', 'user')); + textNode.dispatchEvent(new TextAddedEvent(new PartialIndex({ textRange: range }), 'Hello', 'user')); expect(event).toBeInstanceOf(TextAddedEvent); expect(event) @@ -1687,7 +1686,7 @@ describe('BlockNode', () => { valueNode.dispatchEvent( new ValueModifiedEvent( - new Index(), + Index.data(0, dataKey), { value: newValue, previous: value, @@ -1746,9 +1745,7 @@ describe('BlockNode', () => { tune.dispatchEvent( new TuneModifiedEvent( - new IndexBuilder() - .addTuneKey(key) - .build(), + new PartialIndex({ tuneKey: key }), { value: newValue, previous: value, diff --git a/packages/model/src/entities/BlockNode/index.ts b/packages/model/src/entities/BlockNode/index.ts index eba30a62..47e434bb 100644 --- a/packages/model/src/entities/BlockNode/index.ts +++ b/packages/model/src/entities/BlockNode/index.ts @@ -13,7 +13,7 @@ import { ValueNode } from '../ValueNode/index.js'; import { TextNode } from '../inline-fragments/index.js'; import type { InlineFragment, TextNodeSerialized, ValueSerialized } from '@editorjs/model-types'; import { - IndexBuilder, + PartialIndex, BlockChildType, createDataKey, createBlockId, @@ -216,9 +216,7 @@ export class BlockNode extends EventBus { set(this.#data, dataKey as string, mappedData); } - const index = new IndexBuilder() - .addDataKey(dataKey) - .build(); + const index = new PartialIndex({ dataKey }); /** * Capture the context synchronously before entering the microtask, @@ -252,9 +250,7 @@ export class BlockNode extends EventBus { remove(this.#data, dataKey as string); - const index = new IndexBuilder() - .addDataKey(dataKey) - .build(); + const index = new PartialIndex({ dataKey }); this.dispatchEvent(new DataNodeRemovedEvent(index, nodeData, getContext()!)); } @@ -550,13 +546,10 @@ export class BlockNode extends EventBus { return; } - const builder = new IndexBuilder(); - - builder.from(event.detail.index).addDataKey(key); - this.dispatchEvent( new (event.constructor as Constructor)( - builder.build(), + new PartialIndex({ textRange: (event.detail.index as PartialIndex).textRange, + dataKey: key }), event.detail.data ) ); @@ -580,13 +573,9 @@ export class BlockNode extends EventBus { return; } - const builder = new IndexBuilder(); - - builder.addDataKey(key); - this.dispatchEvent( new ValueModifiedEvent( - builder.build(), + new PartialIndex({ dataKey: key }), event.detail.data, getContext()! ) @@ -611,13 +600,10 @@ export class BlockNode extends EventBus { return; } - const builder = new IndexBuilder(); - - builder.from(event.detail.index).addTuneName(name); - this.dispatchEvent( new TuneModifiedEvent( - builder.build(), + new PartialIndex({ tuneKey: (event.detail.index as PartialIndex).tuneKey, + tuneName: name }), event.detail.data, 'user' ) diff --git a/packages/model/src/entities/BlockTune/index.ts b/packages/model/src/entities/BlockTune/index.ts index ffd02468..7f6267d3 100644 --- a/packages/model/src/entities/BlockTune/index.ts +++ b/packages/model/src/entities/BlockTune/index.ts @@ -1,7 +1,7 @@ import { getContext } from '../../utils/Context.js'; import type { BlockTuneConstructorParameters } from './types/index.js'; import { - IndexBuilder, + PartialIndex, type BlockTuneSerialized, type BlockTuneName, EventBus, @@ -46,12 +46,8 @@ export class BlockTune extends EventBus { this.#data[key] = value; - const builder = new IndexBuilder(); - - builder.addTuneKey(key); - this.dispatchEvent( - new TuneModifiedEvent(builder.build(), { + new TuneModifiedEvent(new PartialIndex({ tuneKey: key }), { value: this.#data[key], previous: previousValue, }, getContext()!) diff --git a/packages/model/src/entities/EditorDocument/EditorDocument.spec.ts b/packages/model/src/entities/EditorDocument/EditorDocument.spec.ts index 5dbaeaff..cc535305 100644 --- a/packages/model/src/entities/EditorDocument/EditorDocument.spec.ts +++ b/packages/model/src/entities/EditorDocument/EditorDocument.spec.ts @@ -1,4 +1,4 @@ -import { IndexBuilder } from '@editorjs/model-types'; +import { Index, PartialIndex } from '@editorjs/model-types'; import { EditorDocument } from './index.js'; import { createBlockId, type DataKey, type InlineToolData, type InlineToolName, type BlockToolName, type BlockTuneName } from '@editorjs/model-types'; import { BlockNode } from '../BlockNode/index.js'; @@ -1331,10 +1331,9 @@ describe('EditorDocument', () => { it('should call .insertText() method if text index provided', () => { const spy = jest.spyOn(document, 'insertText'); - const index = new IndexBuilder().addBlockIndex(blockIndex) - .addDataKey(dataKey) - .addTextRange([0, 0]) - .build(); + const index = Index.text([{ blockIndex, + dataKey, + textRange: [0, 0] }]); document.insertData(index, text); @@ -1344,10 +1343,7 @@ describe('EditorDocument', () => { it('should call .createDatNode() if data key index is provided', () => { const spy = jest.spyOn(document, 'createDataNode'); - const index = new IndexBuilder() - .addBlockIndex(blockIndex) - .addDataKey(dataKey) - .build(); + const index = Index.data(blockIndex, dataKey); const value = { value: text, $t: 't' }; @@ -1359,9 +1355,7 @@ describe('EditorDocument', () => { it('should call .addBlock() if block index is provided', () => { const spy = jest.spyOn(document, 'addBlock'); - const index = new IndexBuilder() - .addBlockIndex(blockIndex) - .build(); + const index = Index.block(blockIndex); document.insertData(index, [block.serialized]); @@ -1370,9 +1364,7 @@ describe('EditorDocument', () => { }); it('should throw an error if index is not supported', () => { - const index = new IndexBuilder() - .addPropertyName('readOnly') - .build(); + const index = Index.property('readOnly'); expect(() => document.insertData(index, 'data')) .toThrow('Unsupported index'); @@ -1405,11 +1397,9 @@ describe('EditorDocument', () => { it('should call .removeText() method if text index provided', () => { const spy = jest.spyOn(document, 'removeText'); const rangeEnd = 5; - const index = new IndexBuilder() - .addBlockIndex(blockIndex) - .addDataKey(dataKey) - .addTextRange([0, rangeEnd]) - .build(); + const index = Index.text([{ blockIndex, + dataKey, + textRange: [0, rangeEnd] }]); document.removeData(index, 'hello'); @@ -1419,10 +1409,7 @@ describe('EditorDocument', () => { it('should call .removeDatNode() if data key index is provided', () => { const spy = jest.spyOn(document, 'removeDataNode'); - const index = new IndexBuilder() - .addBlockIndex(blockIndex) - .addDataKey(dataKey) - .build(); + const index = Index.data(blockIndex, dataKey); document.removeData(index, {}); @@ -1432,9 +1419,7 @@ describe('EditorDocument', () => { it('should call .removeBlock() if block index is provided', () => { const spy = jest.spyOn(document, 'removeBlock'); - const index = new IndexBuilder() - .addBlockIndex(blockIndex) - .build(); + const index = Index.block(blockIndex); const blockData = { name: 'paragraph', @@ -1450,9 +1435,7 @@ describe('EditorDocument', () => { }); it('should throw an error if index is not supported', () => { - const index = new IndexBuilder() - .addPropertyName('readOnly') - .build(); + const index = Index.property('readOnly'); expect(() => document.removeData(index, 'data')) .toThrow('Unsupported index'); @@ -1485,11 +1468,9 @@ describe('EditorDocument', () => { it('should call .format() method if text index and modified value provided', () => { const spy = jest.spyOn(document, 'format'); const rangeEnd = 5; - const index = new IndexBuilder() - .addBlockIndex(blockIndex) - .addDataKey(dataKey) - .addTextRange([0, rangeEnd]) - .build(); + const index = Index.text([{ blockIndex, + dataKey, + textRange: [0, rangeEnd] }]); document.modifyData(index, { value: { @@ -1505,11 +1486,9 @@ describe('EditorDocument', () => { it('should call .unformat() method if text index and previous modified value provided', () => { const spy = jest.spyOn(document, 'unformat'); const rangeEnd = 5; - const index = new IndexBuilder() - .addBlockIndex(blockIndex) - .addDataKey(dataKey) - .addTextRange([0, rangeEnd]) - .build(); + const index = Index.text([{ blockIndex, + dataKey, + textRange: [0, rangeEnd] }]); document.modifyData(index, { previous: { @@ -1728,13 +1707,12 @@ describe('EditorDocument', () => { document.addEventListener(EventType.Changed, handler); - const builder = new IndexBuilder(); - - builder.addTuneKey('value').addTuneName('tune' as BlockTuneName); + const partial = new PartialIndex({ tuneKey: 'value', + tuneName: 'tune' as BlockTuneName }); blockNode.dispatchEvent( new TuneModifiedEvent( - builder.build(), + partial, { value: 'value', previous: 'previous', @@ -1755,13 +1733,12 @@ describe('EditorDocument', () => { document.addEventListener(EventType.Changed, handler); - const builder = new IndexBuilder(); - - builder.addTuneKey('value').addTuneName('tune' as BlockTuneName); + const partial = new PartialIndex({ tuneKey: 'value', + tuneName: 'tune' as BlockTuneName }); blockNode.dispatchEvent( new TuneModifiedEvent( - builder.build(), + partial, { value: 'value', previous: 'previous', diff --git a/packages/model/src/entities/EditorDocument/index.ts b/packages/model/src/entities/EditorDocument/index.ts index 67270400..62140506 100644 --- a/packages/model/src/entities/EditorDocument/index.ts +++ b/packages/model/src/entities/EditorDocument/index.ts @@ -2,7 +2,11 @@ import { getContext } from '../../utils/Context.js'; import { BlockNode } from '../BlockNode/index.js'; import { createDataKey, - IndexBuilder, + Index, + BlockIndex, + DataIndex, + TextIndex, + type PartialIndex, EventBus, EventType, type DocumentId, @@ -33,7 +37,6 @@ import { } from '@editorjs/model-types'; import type { Constructor } from '../../utils/types.js'; import { BaseDocumentEvent, type ModifiedEventData, type TextFormattedEventData, type TextUnformattedEventData } from '@editorjs/model-types'; -import type { Index } from '@editorjs/model-types'; import { BlockAlreadyExistsError } from './errors/BlockAlreadyExistsError.js'; export type * from './types/index.js'; @@ -155,16 +158,12 @@ export class EditorDocument extends EventBus { this.#blockById.set(blockNode.id, blockNode); this.#listenAndBubbleBlockEvent(blockNode); - const builder = new IndexBuilder(); - - builder.addBlockIndex(index); - /** * Dispatch BlockAddedEvent synchronously so it fires before any child DataNodeAddedEvents * (which are queued as microtasks during BlockNode construction), preserving root → leaves order * for add events. */ - this.dispatchEvent(new BlockAddedEvent(builder.build(), blockNode.serialized, getContext()!)); + this.dispatchEvent(new BlockAddedEvent(Index.block(index), blockNode.serialized, getContext()!)); } /** @@ -181,11 +180,7 @@ export class EditorDocument extends EventBus { this.#blockById.delete(blockNode.id); - const builder = new IndexBuilder(); - - builder.addBlockIndex(resolvedIndex); - - this.dispatchEvent(new BlockRemovedEvent(builder.build(), blockNode.serialized, getContext()!)); + this.dispatchEvent(new BlockRemovedEvent(Index.block(resolvedIndex), blockNode.serialized, getContext()!)); } /** @@ -307,13 +302,9 @@ export class EditorDocument extends EventBus { this.#properties[name] = value; - const builder = new IndexBuilder(); - - builder.addPropertyName(name); - this.dispatchEvent( new PropertyModifiedEvent( - builder.build(), + Index.property(name), { value, previous: previousValue, @@ -463,17 +454,20 @@ export class EditorDocument extends EventBus { */ public insertData(index: Index, data: string | BlockNodeInit[] | BlockNodeDataSerializedValue): void { switch (true) { - case index.isTextIndex: + /** + * @todo composite (multi-segment) TextIndex has undefined blockIndex/dataKey/textRange, so the assertions below would throw. + * Not currently reachable — index always comes from PartialIndex.resolve(), which only builds single-segment TextIndex. + * Guard explicitly if a composite index can ever reach this method. + */ + case index instanceof TextIndex: this.insertText(index.blockIndex!, index.dataKey!, data as string, index.textRange![0]); break; - - case index.isDataIndex: - this.createDataNode(index.blockIndex!, index.dataKey!, data); + case index instanceof DataIndex: + this.createDataNode(index.blockIndex, index.dataKey, data); break; - - case index.isBlockIndex: + case index instanceof BlockIndex: (data as BlockNodeSerialized[]) - .forEach((blockData, i) => this.addBlock(blockData, index.blockIndex! + i)); + .forEach((blockData, i) => this.addBlock(blockData, index.blockIndex + i)); break; default: throw new Error('Unsupported index'); @@ -487,16 +481,19 @@ export class EditorDocument extends EventBus { */ public removeData(index: Index, data: string | BlockNodeInit[] | BlockNodeDataSerializedValue): void { switch (true) { - case index.isTextIndex: + /** + * @todo composite (multi-segment) TextIndex has undefined blockIndex/dataKey/textRange, so the assertions below would throw. + * Not currently reachable — index always comes from PartialIndex.resolve(), which only builds single-segment TextIndex. + * Guard explicitly if a composite index can ever reach this method. + */ + case index instanceof TextIndex: this.removeText(index.blockIndex!, index.dataKey!, index.textRange![0], index.textRange![0] + (data as string).length); break; - - case index.isDataIndex: - this.removeDataNode(index.blockIndex!, index.dataKey!); + case index instanceof DataIndex: + this.removeDataNode(index.blockIndex, index.dataKey); break; - - case index.isBlockIndex: - (data as BlockNodeSerialized[]).forEach(() => this.removeBlock(index.blockIndex!)); + case index instanceof BlockIndex: + (data as BlockNodeSerialized[]).forEach(() => this.removeBlock(index.blockIndex)); break; default: throw new Error('Unsupported index'); @@ -509,19 +506,21 @@ export class EditorDocument extends EventBus { * @param data - data to modify (includes current and previous values) */ public modifyData(index: Index, data: ModifiedEventData): void { - switch (true) { - case index.isTextIndex: - if (data.value !== null) { - this.format(index.blockIndex!, index.dataKey!, (data.value as TextFormattedEventData).tool, index.textRange![0], index.textRange![1]); - } else if (data.previous !== null) { - this.unformat(index.blockIndex!, index.dataKey!, (data.previous as TextUnformattedEventData).tool, index.textRange![0], index.textRange![1]); - } - - default: - /** - * @todo implement other actions - */ + /** + * @todo composite (multi-segment) TextIndex has undefined blockIndex/dataKey/textRange, so the assertions below would throw. + * Not currently reachable — index always comes from PartialIndex.resolve(), which only builds single-segment TextIndex. + * Guard explicitly if a composite index can ever reach this method. + */ + if (index instanceof TextIndex) { + if (data.value !== null) { + this.format(index.blockIndex!, index.dataKey!, (data.value as TextFormattedEventData).tool, index.textRange![0], index.textRange![1]); + } else if (data.previous !== null) { + this.unformat(index.blockIndex!, index.dataKey!, (data.previous as TextUnformattedEventData).tool, index.textRange![0], index.textRange![1]); + } } + /** + * @todo implement other actions + */ } /** @@ -565,16 +564,15 @@ export class EditorDocument extends EventBus { return; } - const builder = new IndexBuilder(); - const index = this.#children.indexOf(block); - - builder.from(event.detail.index) - .addDocumentId(this.identifier) - .addBlockIndex(index); + const blockIndex = this.#children.indexOf(block); + const completeIndex = (event.detail.index as PartialIndex) + .withBlockIndex(blockIndex) + .withDocumentId(this.identifier) + .resolve(); this.dispatchEvent( new (event.constructor as Constructor)( - builder.build(), + completeIndex, event.detail.data ) ); diff --git a/packages/model/src/entities/ValueNode/index.ts b/packages/model/src/entities/ValueNode/index.ts index d1ff4a5c..7b291b20 100644 --- a/packages/model/src/entities/ValueNode/index.ts +++ b/packages/model/src/entities/ValueNode/index.ts @@ -1,5 +1,5 @@ import { getContext } from '../../utils/Context.js'; -import { IndexBuilder } from '@editorjs/model-types'; +import { PartialIndex } from '@editorjs/model-types'; import type { ValueNodeConstructorParameters } from './types/index.js'; import type { ValueSerialized } from '@editorjs/model-types'; import { BlockChildType } from '@editorjs/model-types'; @@ -38,10 +38,8 @@ export class ValueNode extends EventBus { this.#value = value; - const builder = new IndexBuilder(); - this.dispatchEvent( - new ValueModifiedEvent(builder.build(), { + new ValueModifiedEvent(new PartialIndex({}), { value: this.#value, previous: previousValue, }, getContext()!) diff --git a/packages/model/src/entities/inline-fragments/ParentInlineNode/index.ts b/packages/model/src/entities/inline-fragments/ParentInlineNode/index.ts index 98bd660e..3552c6e8 100644 --- a/packages/model/src/entities/inline-fragments/ParentInlineNode/index.ts +++ b/packages/model/src/entities/inline-fragments/ParentInlineNode/index.ts @@ -1,5 +1,5 @@ import { getContext } from '../../../utils/Context.js'; -import { IndexBuilder } from '@editorjs/model-types'; +import { PartialIndex } from '@editorjs/model-types'; import type { InlineNode } from '../InlineNode/index.js'; import type { InlineFragment, InlineTreeNodeSerialized, InlineToolData, InlineToolName } from '@editorjs/model-types'; import type { ParentNodeConstructorOptions } from '../mixins/ParentNode/index.js'; @@ -69,11 +69,7 @@ export class ParentInlineNode extends EventBus implements InlineNode { this.normalize(); - const builder = new IndexBuilder(); - - builder.addTextRange([index, index]); - - this.dispatchEvent(new TextAddedEvent(builder.build(), text, getContext()!)); + this.dispatchEvent(new TextAddedEvent(new PartialIndex({ textRange: [index, index] }), text, getContext()!)); } /** @@ -96,11 +92,7 @@ export class ParentInlineNode extends EventBus implements InlineNode { this.normalize(); - const builder = new IndexBuilder(); - - builder.addTextRange([start, end]); - - this.dispatchEvent(new TextRemovedEvent(builder.build(), removedText, getContext()!)); + this.dispatchEvent(new TextRemovedEvent(new PartialIndex({ textRange: [start, end] }), removedText, getContext()!)); return removedText; } @@ -196,13 +188,9 @@ export class ParentInlineNode extends EventBus implements InlineNode { this.normalize(); - const builder = new IndexBuilder(); - - builder.addTextRange([start, end]); - this.dispatchEvent( new TextFormattedEvent( - builder.build(), + new PartialIndex({ textRange: [start, end] }), { tool, data, @@ -244,11 +232,7 @@ export class ParentInlineNode extends EventBus implements InlineNode { this.normalize(); - const builder = new IndexBuilder(); - - builder.addTextRange([start, end]); - - this.dispatchEvent(new TextUnformattedEvent(builder.build(), { tool }, getContext()!)); + this.dispatchEvent(new TextUnformattedEvent(new PartialIndex({ textRange: [start, end] }), { tool }, getContext()!)); return newNodes; } diff --git a/packages/ot-server/src/DocumentManager.spec.ts b/packages/ot-server/src/DocumentManager.spec.ts index a5d47c7e..4628a7e4 100644 --- a/packages/ot-server/src/DocumentManager.spec.ts +++ b/packages/ot-server/src/DocumentManager.spec.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-magic-numbers */ import { Operation, OperationType, type OperationTypeToData } from '@editorjs/collaboration-manager'; -import { IndexBuilder } from '@editorjs/sdk'; +import { Index } from '@editorjs/sdk'; import { DocumentManager } from './DocumentManager.js'; // eslint-disable-next-line jsdoc/require-param @@ -16,8 +16,7 @@ function createOperation( ): Operation { return new Operation( type, - new IndexBuilder().from(JSON.stringify(index)) - .build(), + Index.parse(index), data, userId, rev @@ -31,7 +30,7 @@ describe('DocumentManager', () => { await manager.process( createOperation( OperationType.Insert, - 'doc@0:block@0', + '{"k":"block","b":0,"id":"0"}', { payload: [{ name: 'paragraph', @@ -51,7 +50,7 @@ describe('DocumentManager', () => { await manager.process( createOperation( OperationType.Insert, - 'doc@0:block@0:data@text:[0,0]', + '{"k":"text","b":0,"data":"text","r":[0,0],"id":"0"}', { payload: 'A' }, 'user', 1 @@ -60,7 +59,7 @@ describe('DocumentManager', () => { await manager.process( createOperation( OperationType.Insert, - 'doc@0:block@0:data@text:[0,0]', + '{"k":"text","b":0,"data":"text","r":[0,0],"id":"0"}', { payload: 'A' }, 'user', 2 @@ -69,7 +68,7 @@ describe('DocumentManager', () => { await manager.process( createOperation( OperationType.Insert, - 'doc@0:block@0:data@text:[0,0]', + '{"k":"text","b":0,"data":"text","r":[0,0],"id":"0"}', { payload: 'A' }, 'user', 3 @@ -101,7 +100,7 @@ describe('DocumentManager', () => { await manager.process( createOperation( OperationType.Insert, - 'doc@0:block@0', + '{"k":"block","b":0,"id":"0"}', { payload: [{ name: 'paragraph', @@ -121,7 +120,7 @@ describe('DocumentManager', () => { await manager.process( createOperation( OperationType.Insert, - 'doc@0:block@0:data@text:[0,0]', + '{"k":"text","b":0,"data":"text","r":[0,0],"id":"0"}', { payload: 'A' }, 'user', 1 @@ -130,7 +129,7 @@ describe('DocumentManager', () => { await manager.process( createOperation( OperationType.Insert, - 'doc@0:block@0:data@text:[0,0]', + '{"k":"text","b":0,"data":"text","r":[0,0],"id":"0"}', { payload: 'B' }, 'user', 1 @@ -162,7 +161,7 @@ describe('DocumentManager', () => { await manager.process( createOperation( OperationType.Insert, - 'doc@0:block@0', + '{"k":"block","b":0,"id":"0"}', { payload: [{ name: 'paragraph', @@ -182,7 +181,7 @@ describe('DocumentManager', () => { await manager.process( createOperation( OperationType.Insert, - 'doc@0:block@0:data@text:[0,0]', + '{"k":"text","b":0,"data":"text","r":[0,0],"id":"0"}', { payload: 'A' }, 'user', 1 @@ -191,7 +190,7 @@ describe('DocumentManager', () => { await manager.process( createOperation( OperationType.Insert, - 'doc@0:block@0:data@text:[0,0]', + '{"k":"text","b":0,"data":"text","r":[0,0],"id":"0"}', { payload: 'A' }, 'user', 2 @@ -200,7 +199,7 @@ describe('DocumentManager', () => { await manager.process( createOperation( OperationType.Insert, - 'doc@0:block@0:data@text:[0,0]', + '{"k":"text","b":0,"data":"text","r":[0,0],"id":"0"}', { payload: 'B' }, 'user', 1 @@ -232,7 +231,7 @@ describe('DocumentManager', () => { void manager.process( createOperation( OperationType.Insert, - 'doc@0:block@0', + '{"k":"block","b":0,"id":"0"}', { payload: [{ name: 'paragraph', @@ -252,7 +251,7 @@ describe('DocumentManager', () => { void manager.process( createOperation( OperationType.Insert, - 'doc@0:block@0:data@text:[0,0]', + '{"k":"text","b":0,"data":"text","r":[0,0],"id":"0"}', { payload: 'A' }, 'user', 1 @@ -261,7 +260,7 @@ describe('DocumentManager', () => { void manager.process( createOperation( OperationType.Insert, - 'doc@0:block@0:data@text:[0,0]', + '{"k":"text","b":0,"data":"text","r":[0,0],"id":"0"}', { payload: 'A' }, 'user', 2 @@ -273,7 +272,7 @@ describe('DocumentManager', () => { await manager.process( createOperation( OperationType.Insert, - 'doc@0:block@0:data@text:[0,0]', + '{"k":"text","b":0,"data":"text","r":[0,0],"id":"0"}', { payload: 'A' }, 'user', 3 diff --git a/packages/playground/src/App.vue b/packages/playground/src/App.vue index 84a7e58a..7e45a4d6 100644 --- a/packages/playground/src/App.vue +++ b/packages/playground/src/App.vue @@ -19,7 +19,6 @@ const model = ref(null); const userId = crypto.randomUUID(); - /** * @todo display caret index somewhere */ @@ -83,7 +82,7 @@ const collapsedSections = ref<{ [key: string]: boolean }>({ /** * Toggles the collapsed state of a section * - * @param section - section to toggle + * @param {string} section - section name to toggle */ function toggleSection(section: string) { collapsedSections.value[section] = !collapsedSections.value[section]; diff --git a/packages/playground/src/components/CaretIndex.vue b/packages/playground/src/components/CaretIndex.vue index 90b6ea6e..dd79146c 100644 --- a/packages/playground/src/components/CaretIndex.vue +++ b/packages/playground/src/components/CaretIndex.vue @@ -3,6 +3,9 @@ import type { EditorJSModel } from '@editorjs/model'; import { CaretManagerCaretUpdatedEvent, EventType, Index } from '@editorjs/sdk'; import { onUpdated, ref } from 'vue'; +/** + * Map of user ids to caret indexes + */ const indexes = ref>(new Map()); const props = defineProps<{ @@ -11,6 +14,9 @@ const props = defineProps<{ */ model: EditorJSModel; + /** + * Current user id + */ userId: string; }>(); diff --git a/packages/playground/src/components/Indent.vue b/packages/playground/src/components/Indent.vue index 028b49b4..30ee8298 100644 --- a/packages/playground/src/components/Indent.vue +++ b/packages/playground/src/components/Indent.vue @@ -5,7 +5,7 @@ const props = defineProps<{ /** * True to hide the content. */ - collapsed?: boolean + collapsed?: boolean; }>(); const isHidden = ref(props.collapsed ?? true); @@ -15,7 +15,7 @@ const isHidden = ref(props.collapsed ?? true); */ function collapseExpand() { isHidden.value = !isHidden.value; -}; +}