mobx-keystone-loro 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/LICENSE +21 -0
  3. package/README.md +119 -0
  4. package/dist/mobx-keystone-loro.esm.js +957 -0
  5. package/dist/mobx-keystone-loro.esm.mjs +957 -0
  6. package/dist/mobx-keystone-loro.umd.js +957 -0
  7. package/dist/types/binding/LoroTextModel.d.ts +72 -0
  8. package/dist/types/binding/applyLoroEventToMobx.d.ts +9 -0
  9. package/dist/types/binding/applyMobxChangeToLoroObject.d.ts +7 -0
  10. package/dist/types/binding/bindLoroToMobxKeystone.d.ts +33 -0
  11. package/dist/types/binding/convertJsonToLoroData.d.ts +51 -0
  12. package/dist/types/binding/convertLoroDataToJson.d.ts +11 -0
  13. package/dist/types/binding/loroBindingContext.d.ts +39 -0
  14. package/dist/types/binding/loroSnapshotTracking.d.ts +26 -0
  15. package/dist/types/binding/moveWithinArray.d.ts +36 -0
  16. package/dist/types/binding/resolveLoroPath.d.ts +11 -0
  17. package/dist/types/index.d.ts +8 -0
  18. package/dist/types/plainTypes.d.ts +6 -0
  19. package/dist/types/utils/error.d.ts +4 -0
  20. package/dist/types/utils/getOrCreateLoroCollectionAtom.d.ts +7 -0
  21. package/dist/types/utils/isBindableLoroContainer.d.ts +9 -0
  22. package/package.json +92 -0
  23. package/src/binding/LoroTextModel.ts +211 -0
  24. package/src/binding/applyLoroEventToMobx.ts +280 -0
  25. package/src/binding/applyMobxChangeToLoroObject.ts +182 -0
  26. package/src/binding/bindLoroToMobxKeystone.ts +353 -0
  27. package/src/binding/convertJsonToLoroData.ts +315 -0
  28. package/src/binding/convertLoroDataToJson.ts +68 -0
  29. package/src/binding/loroBindingContext.ts +46 -0
  30. package/src/binding/loroSnapshotTracking.ts +36 -0
  31. package/src/binding/moveWithinArray.ts +112 -0
  32. package/src/binding/resolveLoroPath.ts +37 -0
  33. package/src/index.ts +16 -0
  34. package/src/plainTypes.ts +7 -0
  35. package/src/utils/error.ts +12 -0
  36. package/src/utils/getOrCreateLoroCollectionAtom.ts +17 -0
  37. package/src/utils/isBindableLoroContainer.ts +13 -0
@@ -0,0 +1,72 @@
1
+ import { Delta, LoroText } from 'loro-crdt';
2
+ import { SnapshotOutOf } from 'mobx-keystone';
3
+ export declare const loroTextModelId = "mobx-keystone-loro/LoroTextModel";
4
+ /**
5
+ * Type for the delta stored in the model.
6
+ * Uses Quill delta format: array of operations with insert, delete, retain.
7
+ */
8
+ export type LoroTextDeltaList = Delta<string>[];
9
+ declare const LoroTextModel_base: import('mobx-keystone')._Model<unknown, {
10
+ /**
11
+ * The current delta representing the rich text content.
12
+ * Uses Quill delta format.
13
+ */
14
+ deltaList: import('mobx-keystone').OptionalModelProp<import('mobx-keystone').Frozen<LoroTextDeltaList>>;
15
+ }, never, never>;
16
+ /**
17
+ * A mobx-keystone model representing Loro rich text.
18
+ * This model stores the current delta state (Quill format) and syncs with LoroText.
19
+ */
20
+ export declare class LoroTextModel extends LoroTextModel_base {
21
+ /**
22
+ * Creates a LoroTextModel with initial text content.
23
+ */
24
+ static withText(text: string): LoroTextModel;
25
+ /**
26
+ * Creates a LoroTextModel with initial delta.
27
+ */
28
+ static withDelta(delta: LoroTextDeltaList): LoroTextModel;
29
+ /**
30
+ * Atom that gets changed when the associated Loro text changes.
31
+ */
32
+ loroTextChangedAtom: import('mobx').IAtom;
33
+ /**
34
+ * The LoroText object represented by this mobx-keystone node, if bound.
35
+ * Returns undefined when the model is not part of a bound object tree.
36
+ */
37
+ get loroText(): LoroText | undefined;
38
+ /**
39
+ * Gets the plain text content.
40
+ * This always uses the stored delta, which is kept in sync with Loro.
41
+ */
42
+ get text(): string;
43
+ /**
44
+ * Gets the current delta (Quill format).
45
+ */
46
+ get currentDelta(): LoroTextDeltaList;
47
+ /**
48
+ * Converts delta to plain text.
49
+ */
50
+ private deltaToText;
51
+ /**
52
+ * Sets the delta.
53
+ */
54
+ setDelta(delta: LoroTextDeltaList): void;
55
+ /**
56
+ * Inserts text at the specified position.
57
+ */
58
+ insertText(index: number, text: string): void;
59
+ /**
60
+ * Deletes text at the specified range.
61
+ */
62
+ deleteText(index: number, length: number): void;
63
+ }
64
+ /**
65
+ * Type for LoroTextModel for use with tProp.
66
+ */
67
+ export declare const loroTextModelType: import('mobx-keystone').ModelType<LoroTextModel>;
68
+ /**
69
+ * Checks if a snapshot is a LoroTextModel snapshot.
70
+ */
71
+ export declare function isLoroTextModelSnapshot(value: unknown): value is SnapshotOutOf<LoroTextModel>;
72
+ export {};
@@ -0,0 +1,9 @@
1
+ import { ContainerID, LoroDoc, LoroEvent } from 'loro-crdt';
2
+ import { Path } from 'mobx-keystone';
3
+ export type ReconciliationMap = Map<string, object>;
4
+ /**
5
+ * Applies a Loro event directly to the MobX model tree using proper mutations
6
+ * (splice for arrays, property assignment for objects).
7
+ * This is more efficient than converting to patches first.
8
+ */
9
+ export declare function applyLoroEventToMobx(event: LoroEvent, loroDoc: LoroDoc, boundObject: object, rootPath: Path, reconciliationMap: ReconciliationMap, newlyInsertedContainers: Set<ContainerID>): void;
@@ -0,0 +1,7 @@
1
+ import { DeepChange } from 'mobx-keystone';
2
+ import { BindableLoroContainer } from '../utils/isBindableLoroContainer';
3
+ import { ArrayMoveChange } from './moveWithinArray';
4
+ /**
5
+ * Applies a MobX DeepChange or an ArrayMoveChange to a Loro object.
6
+ */
7
+ export declare function applyMobxChangeToLoroObject(change: DeepChange | ArrayMoveChange, loroObject: BindableLoroContainer): void;
@@ -0,0 +1,33 @@
1
+ import { LoroDoc } from 'loro-crdt';
2
+ import { AnyDataModel, AnyModel, AnyStandardType, ModelClass, TypeToData } from 'mobx-keystone';
3
+ import { BindableLoroContainer } from '../utils/isBindableLoroContainer';
4
+ /**
5
+ * Creates a bidirectional binding between a Loro data structure and a mobx-keystone model.
6
+ */
7
+ export declare function bindLoroToMobxKeystone<TType extends AnyStandardType | ModelClass<AnyModel> | ModelClass<AnyDataModel>>({ loroDoc, loroObject, mobxKeystoneType, }: {
8
+ /**
9
+ * The Loro document.
10
+ */
11
+ loroDoc: LoroDoc;
12
+ /**
13
+ * The bound Loro data structure.
14
+ */
15
+ loroObject: BindableLoroContainer;
16
+ /**
17
+ * The mobx-keystone model type.
18
+ */
19
+ mobxKeystoneType: TType;
20
+ }): {
21
+ /**
22
+ * The bound mobx-keystone instance.
23
+ */
24
+ boundObject: TypeToData<TType>;
25
+ /**
26
+ * Disposes the binding.
27
+ */
28
+ dispose: () => void;
29
+ /**
30
+ * The Loro origin string used for binding transactions.
31
+ */
32
+ loroOrigin: string;
33
+ };
@@ -0,0 +1,51 @@
1
+ import { Delta, LoroMap, LoroMovableList, LoroText } from 'loro-crdt';
2
+ import { PlainArray, PlainObject, PlainValue } from '../plainTypes';
3
+ import { BindableLoroContainer } from '../utils/isBindableLoroContainer';
4
+ type LoroValue = BindableLoroContainer | PlainValue;
5
+ /**
6
+ * Options for applying JSON data to Loro data structures.
7
+ */
8
+ export interface ApplyJsonToLoroOptions {
9
+ /**
10
+ * The mode to use when applying JSON data to Loro data structures.
11
+ * - `add`: Creates new Loro containers for objects/arrays (default, backwards compatible)
12
+ * - `merge`: Recursively merges values, preserving existing container references where possible
13
+ */
14
+ mode?: "add" | "merge";
15
+ }
16
+ /**
17
+ * Extracts delta array from a LoroTextModel snapshot's delta field.
18
+ * The delta field is a frozen Delta<string>[] (array of delta operations).
19
+ */
20
+ export declare function extractTextDeltaFromSnapshot(delta: unknown): Delta<string>[];
21
+ /**
22
+ * Applies delta operations to a LoroText using insert/mark APIs.
23
+ * This works on both attached and detached containers.
24
+ *
25
+ * Strategy: Insert all text first, then apply marks. This avoids mark inheritance
26
+ * issues when inserting at the boundary of a marked region.
27
+ */
28
+ export declare function applyDeltaToLoroText(text: LoroText, deltas: Delta<string>[]): void;
29
+ /**
30
+ * Converts a plain value to a Loro data structure.
31
+ * Objects are converted to LoroMaps, arrays to LoroMovableLists, primitives are untouched.
32
+ * Frozen values are a special case and they are kept as immutable plain values.
33
+ */
34
+ export declare function convertJsonToLoroData(v: PlainValue): LoroValue;
35
+ /**
36
+ * Applies a JSON array to a LoroMovableList, using convertJsonToLoroData to convert the values.
37
+ *
38
+ * @param dest The destination LoroMovableList.
39
+ * @param source The source JSON array.
40
+ * @param options Options for applying the JSON data.
41
+ */
42
+ export declare const applyJsonArrayToLoroMovableList: (dest: LoroMovableList, source: PlainArray, options?: ApplyJsonToLoroOptions) => void;
43
+ /**
44
+ * Applies a JSON object to a LoroMap, using convertJsonToLoroData to convert the values.
45
+ *
46
+ * @param dest The destination LoroMap.
47
+ * @param source The source JSON object.
48
+ * @param options Options for applying the JSON data.
49
+ */
50
+ export declare const applyJsonObjectToLoroMap: (dest: LoroMap, source: PlainObject, options?: ApplyJsonToLoroOptions) => void;
51
+ export {};
@@ -0,0 +1,11 @@
1
+ import { PlainValue } from '../plainTypes';
2
+ import { BindableLoroContainer } from '../utils/isBindableLoroContainer';
3
+ type LoroValue = BindableLoroContainer | PlainValue;
4
+ /**
5
+ * Converts Loro data to JSON-compatible format for mobx-keystone snapshots.
6
+ *
7
+ * @param value The Loro value to convert
8
+ * @returns JSON-compatible value
9
+ */
10
+ export declare function convertLoroDataToJson(value: LoroValue): PlainValue;
11
+ export {};
@@ -0,0 +1,39 @@
1
+ import { LoroDoc } from 'loro-crdt';
2
+ import { AnyType } from 'mobx-keystone';
3
+ import { BindableLoroContainer } from '../utils/isBindableLoroContainer';
4
+ /**
5
+ * Context for the Loro binding, providing access to the Loro document
6
+ * and binding state from within mobx-keystone models.
7
+ */
8
+ export interface LoroBindingContext {
9
+ /**
10
+ * The Loro document being bound.
11
+ */
12
+ loroDoc: LoroDoc;
13
+ /**
14
+ * The root Loro object being bound.
15
+ */
16
+ loroObject: BindableLoroContainer;
17
+ /**
18
+ * The mobx-keystone model type being used.
19
+ */
20
+ mobxKeystoneType: AnyType;
21
+ /**
22
+ * String used as origin for Loro transactions to identify changes
23
+ * coming from mobx-keystone.
24
+ */
25
+ loroOrigin: string;
26
+ /**
27
+ * The bound mobx-keystone object (once created).
28
+ */
29
+ boundObject: unknown;
30
+ /**
31
+ * Whether changes are currently being applied from Loro to mobx-keystone.
32
+ * Used to prevent infinite loops.
33
+ */
34
+ isApplyingLoroChangesToMobxKeystone: boolean;
35
+ }
36
+ /**
37
+ * Context for accessing the Loro binding from within mobx-keystone models.
38
+ */
39
+ export declare const loroBindingContext: import('mobx-keystone').Context<LoroBindingContext | undefined>;
@@ -0,0 +1,26 @@
1
+ import { LoroMap, LoroMovableList } from 'loro-crdt';
2
+ type LoroContainer = LoroMap | LoroMovableList;
3
+ /**
4
+ * WeakMap that tracks which snapshot each Loro container was last synced from.
5
+ * This is used during reconciliation to skip containers that are already up-to-date.
6
+ *
7
+ * The key is the Loro container (LoroMap or LoroMovableList).
8
+ * The value is the snapshot (plain object or array) that was last synced to it.
9
+ */
10
+ export declare const loroContainerToSnapshot: WeakMap<LoroContainer, unknown>;
11
+ /**
12
+ * Updates the snapshot tracking for a Loro container.
13
+ * Call this after syncing a snapshot to a Loro container.
14
+ */
15
+ export declare function setLoroContainerSnapshot(container: LoroContainer, snapshot: unknown): void;
16
+ /**
17
+ * Gets the last synced snapshot for a Loro container.
18
+ * Returns undefined if the container has never been synced.
19
+ */
20
+ export declare function getLoroContainerSnapshot(container: LoroContainer): unknown;
21
+ /**
22
+ * Checks if a Loro container is up-to-date with the given snapshot.
23
+ * Uses reference equality to check if the snapshot is the same.
24
+ */
25
+ export declare function isLoroContainerUpToDate(container: LoroContainer, snapshot: unknown): boolean;
26
+ export {};
@@ -0,0 +1,36 @@
1
+ import { DeepChange } from 'mobx-keystone';
2
+ /**
3
+ * Synthetic change type for array moves.
4
+ */
5
+ export interface ArrayMoveChange {
6
+ type: "ArrayMove";
7
+ path: readonly (string | number)[];
8
+ fromIndex: number;
9
+ toIndex: number;
10
+ }
11
+ /**
12
+ * Moves an item within an array from one index to another.
13
+ *
14
+ * When used on a mobx-keystone array bound to a Loro movable list,
15
+ * this translates to a native Loro move operation for optimal CRDT merging.
16
+ *
17
+ * For unbound arrays, performs a standard splice-based move.
18
+ *
19
+ * @param array The array to move within
20
+ * @param fromIndex The index of the item to move
21
+ * @param toIndex The target index to move the item to
22
+ */
23
+ export declare function moveWithinArray<T>(array: T[], fromIndex: number, toIndex: number): void;
24
+ /**
25
+ * Check if a change is part of an active move operation and process it.
26
+ * This is called for ArraySplice changes on the target array.
27
+ *
28
+ * @param change The deep change (must be ArraySplice type)
29
+ * @returns The ArrayMoveChange if the move is complete (second splice),
30
+ * undefined if intercepted but not complete (first splice)
31
+ */
32
+ export declare function processChangeForMove(change: DeepChange): ArrayMoveChange | undefined;
33
+ /**
34
+ * Check if we're currently in a move context for a specific array.
35
+ */
36
+ export declare function isInMoveContextForArray(array: unknown[]): boolean;
@@ -0,0 +1,11 @@
1
+ import { Path } from 'mobx-keystone';
2
+ import { BindableLoroContainer } from '../utils/isBindableLoroContainer';
3
+ /**
4
+ * Resolves a path within a Loro object structure.
5
+ * Returns the Loro container at the specified path.
6
+ *
7
+ * @param loroObject The root Loro object
8
+ * @param path Array of keys/indices to traverse
9
+ * @returns The Loro container at the path
10
+ */
11
+ export declare function resolveLoroPath(loroObject: BindableLoroContainer, path: Path): unknown;
@@ -0,0 +1,8 @@
1
+ export { bindLoroToMobxKeystone } from './binding/bindLoroToMobxKeystone';
2
+ export type { ApplyJsonToLoroOptions } from './binding/convertJsonToLoroData';
3
+ export { applyJsonArrayToLoroMovableList, applyJsonObjectToLoroMap, convertJsonToLoroData, } from './binding/convertJsonToLoroData';
4
+ export { isLoroTextModelSnapshot, LoroTextModel, loroTextModelType, } from './binding/LoroTextModel';
5
+ export type { LoroBindingContext } from './binding/loroBindingContext';
6
+ export { loroBindingContext } from './binding/loroBindingContext';
7
+ export { moveWithinArray } from './binding/moveWithinArray';
8
+ export { MobxKeystoneLoroError } from './utils/error';
@@ -0,0 +1,6 @@
1
+ export type PlainPrimitive = string | number | boolean | null | undefined;
2
+ export type PlainValue = PlainPrimitive | PlainObject | PlainArray;
3
+ export type PlainObject = {
4
+ [key: string]: PlainValue;
5
+ };
6
+ export type PlainArray = PlainValue[];
@@ -0,0 +1,4 @@
1
+ export declare class MobxKeystoneLoroError extends Error {
2
+ constructor(msg: string);
3
+ }
4
+ export declare function failure(message: string): never;
@@ -0,0 +1,7 @@
1
+ import { IAtom } from 'mobx';
2
+ import { BindableLoroContainer } from './isBindableLoroContainer';
3
+ /**
4
+ * Gets or creates a MobX atom for a Loro collection.
5
+ * This is used to track reactivity for computed properties that read from Loro containers.
6
+ */
7
+ export declare function getOrCreateLoroCollectionAtom(collection: BindableLoroContainer): IAtom;
@@ -0,0 +1,9 @@
1
+ import { LoroMap, LoroMovableList, LoroText } from 'loro-crdt';
2
+ /**
3
+ * A bindable Loro container (Map, MovableList, or Text).
4
+ */
5
+ export type BindableLoroContainer = LoroMap | LoroMovableList | LoroText;
6
+ /**
7
+ * Checks if a value is a bindable Loro container.
8
+ */
9
+ export declare function isBindableLoroContainer(value: unknown): value is BindableLoroContainer;
package/package.json ADDED
@@ -0,0 +1,92 @@
1
+ {
2
+ "name": "mobx-keystone-loro",
3
+ "version": "1.0.0",
4
+ "description": "Loro CRDT bindings for mobx-keystone",
5
+ "keywords": [
6
+ "mobx",
7
+ "mobx-keystone",
8
+ "loro",
9
+ "loro-crdt",
10
+ "crdt",
11
+ "state",
12
+ "state-management",
13
+ "reactive",
14
+ "collaborative"
15
+ ],
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "https://github.com/xaviergonz/mobx-keystone.git"
19
+ },
20
+ "bugs": {
21
+ "url": "https://github.com/xaviergonz/mobx-keystone/issues"
22
+ },
23
+ "homepage": "https://mobx-keystone.js.org",
24
+ "license": "MIT",
25
+ "author": "Javier González Garcés",
26
+ "source": "./src/index.ts",
27
+ "exports": {
28
+ "./package.json": "./package.json",
29
+ ".": {
30
+ "types": "./dist/types/index.d.ts",
31
+ "script": "./dist/mobx-keystone-loro.umd.js",
32
+ "import": "./dist/mobx-keystone-loro.esm.mjs",
33
+ "require": "./dist/mobx-keystone-loro.umd.js",
34
+ "default": "./dist/mobx-keystone-loro.esm.mjs"
35
+ }
36
+ },
37
+ "esmodule": "./dist/mobx-keystone-loro.esm.js",
38
+ "module": "./dist/mobx-keystone-loro.esm.js",
39
+ "jsnext:main": "./dist/mobx-keystone-loro.esm.js",
40
+ "react-native": "./dist/mobx-keystone-loro.umd.js",
41
+ "umd:main": "./dist/mobx-keystone-loro.umd.js",
42
+ "unpkg": "./dist/mobx-keystone-loro.umd.js",
43
+ "jsdelivr": "./dist/mobx-keystone-loro.umd.js",
44
+ "main": "./dist/mobx-keystone-loro.umd.js",
45
+ "types": "./dist/types/index.d.ts",
46
+ "typings": "./dist/types/index.d.ts",
47
+ "sideEffects": false,
48
+ "files": [
49
+ "src",
50
+ "dist",
51
+ "CHANGELOG.md",
52
+ "LICENSE",
53
+ "README.md"
54
+ ],
55
+ "scripts": {
56
+ "quick-build": "tsc",
57
+ "quick-build-tests": "tsc -p test",
58
+ "copy-root-files": "shx cp ../../LICENSE .",
59
+ "build": "yarn quick-build && yarn copy-root-files && shx rm -rf dist && vite build && shx cp dist/mobx-keystone-loro.esm.mjs dist/mobx-keystone-loro.esm.js",
60
+ "test": "jest",
61
+ "test:ci": "jest --ci"
62
+ },
63
+ "peerDependencies": {
64
+ "loro-crdt": "^1.10.3",
65
+ "mobx": "^6.0.0 || ^5.0.0 || ^4.0.0",
66
+ "mobx-keystone": "^1.12.0"
67
+ },
68
+ "devDependencies": {
69
+ "@babel/core": "^7.28.5",
70
+ "@babel/plugin-proposal-class-properties": "^7.18.6",
71
+ "@babel/plugin-proposal-decorators": "^7.28.0",
72
+ "@babel/preset-env": "^7.28.5",
73
+ "@babel/preset-typescript": "^7.28.5",
74
+ "@types/jest": "^30.0.0",
75
+ "@types/node": "^25.0.3",
76
+ "babel-jest": "^30.2.0",
77
+ "jest": "^30.2.0",
78
+ "mobx-keystone": "workspace:packages/lib",
79
+ "shx": "^0.4.0",
80
+ "spec.ts": "^1.1.3",
81
+ "ts-jest": "^29.4.6",
82
+ "ts-node": "^10.9.2",
83
+ "typescript": "^5.9.3"
84
+ },
85
+ "dependencies": {
86
+ "nanoid": "^3.3.11",
87
+ "tslib": "^2.8.1"
88
+ },
89
+ "directories": {
90
+ "test": "test"
91
+ }
92
+ }
@@ -0,0 +1,211 @@
1
+ import { type Delta, LoroText } from "loro-crdt"
2
+ import { computed, createAtom } from "mobx"
3
+ import {
4
+ frozen,
5
+ getParentToChildPath,
6
+ getSnapshotModelType,
7
+ Model,
8
+ model,
9
+ modelAction,
10
+ type SnapshotOutOf,
11
+ tProp,
12
+ types,
13
+ } from "mobx-keystone"
14
+ import { getOrCreateLoroCollectionAtom } from "../utils/getOrCreateLoroCollectionAtom"
15
+ import { loroBindingContext } from "./loroBindingContext"
16
+ import { resolveLoroPath } from "./resolveLoroPath"
17
+
18
+ export const loroTextModelId = "mobx-keystone-loro/LoroTextModel"
19
+
20
+ /**
21
+ * Type for the delta stored in the model.
22
+ * Uses Quill delta format: array of operations with insert, delete, retain.
23
+ */
24
+ export type LoroTextDeltaList = Delta<string>[]
25
+
26
+ /**
27
+ * A mobx-keystone model representing Loro rich text.
28
+ * This model stores the current delta state (Quill format) and syncs with LoroText.
29
+ */
30
+ @model(loroTextModelId)
31
+ export class LoroTextModel extends Model({
32
+ /**
33
+ * The current delta representing the rich text content.
34
+ * Uses Quill delta format.
35
+ */
36
+ deltaList: tProp(types.frozen(types.unchecked<LoroTextDeltaList>()), () => frozen([])),
37
+ }) {
38
+ /**
39
+ * Creates a LoroTextModel with initial text content.
40
+ */
41
+ static withText(text: string): LoroTextModel {
42
+ return new LoroTextModel({
43
+ deltaList: frozen([{ insert: text }]),
44
+ })
45
+ }
46
+
47
+ /**
48
+ * Creates a LoroTextModel with initial delta.
49
+ */
50
+ static withDelta(delta: LoroTextDeltaList): LoroTextModel {
51
+ return new LoroTextModel({
52
+ deltaList: frozen(delta),
53
+ })
54
+ }
55
+
56
+ /**
57
+ * Atom that gets changed when the associated Loro text changes.
58
+ */
59
+ loroTextChangedAtom = createAtom("loroTextChangedAtom")
60
+
61
+ /**
62
+ * The LoroText object represented by this mobx-keystone node, if bound.
63
+ * Returns undefined when the model is not part of a bound object tree.
64
+ */
65
+ @computed
66
+ get loroText(): LoroText | undefined {
67
+ // Check if we have a binding context first - return undefined if not bound
68
+ const ctx = loroBindingContext.get(this)
69
+ if (ctx?.boundObject == null) {
70
+ return undefined
71
+ }
72
+
73
+ try {
74
+ const path = getParentToChildPath(ctx.boundObject, this)
75
+ if (!path) {
76
+ return undefined
77
+ }
78
+
79
+ // If this model IS the bound object, the loroObject is the LoroText
80
+ if (path.length === 0) {
81
+ const loroObject = ctx.loroObject
82
+ if (loroObject instanceof LoroText) {
83
+ getOrCreateLoroCollectionAtom(loroObject).reportObserved()
84
+ return loroObject
85
+ }
86
+ return undefined
87
+ }
88
+
89
+ // Otherwise resolve the path
90
+ const loroObject = resolveLoroPath(ctx.loroObject, path)
91
+
92
+ if (loroObject instanceof LoroText) {
93
+ getOrCreateLoroCollectionAtom(loroObject).reportObserved()
94
+ return loroObject
95
+ }
96
+ } catch {
97
+ // Path resolution failed - return undefined
98
+ }
99
+
100
+ return undefined
101
+ }
102
+
103
+ /**
104
+ * Gets the plain text content.
105
+ * This always uses the stored delta, which is kept in sync with Loro.
106
+ */
107
+ @computed
108
+ get text(): string {
109
+ this.loroTextChangedAtom.reportObserved()
110
+
111
+ // Always compute from delta - it's the source of truth for this model
112
+ // The delta is kept in sync with Loro via patches
113
+ return this.deltaToText(this.deltaList.data)
114
+ }
115
+
116
+ /**
117
+ * Gets the current delta (Quill format).
118
+ */
119
+ @computed
120
+ get currentDelta(): LoroTextDeltaList {
121
+ this.loroTextChangedAtom.reportObserved()
122
+
123
+ // Try to get from bound LoroText first
124
+ const loroText = this.loroText
125
+ if (loroText) {
126
+ try {
127
+ return loroText.toDelta()
128
+ } catch {
129
+ // fall back to stored delta
130
+ }
131
+ }
132
+
133
+ return this.deltaList.data
134
+ }
135
+
136
+ /**
137
+ * Converts delta to plain text.
138
+ */
139
+ private deltaToText(delta: LoroTextDeltaList): string {
140
+ let result = ""
141
+ for (const op of delta) {
142
+ if ("insert" in op && typeof op.insert === "string") {
143
+ result += op.insert
144
+ }
145
+ }
146
+ return result
147
+ }
148
+
149
+ /**
150
+ * Sets the delta.
151
+ */
152
+ @modelAction
153
+ setDelta(delta: LoroTextDeltaList): void {
154
+ this.deltaList = frozen(delta)
155
+ }
156
+
157
+ /**
158
+ * Inserts text at the specified position.
159
+ */
160
+ @modelAction
161
+ insertText(index: number, text: string): void {
162
+ const loroText = this.loroText
163
+ if (loroText) {
164
+ loroText.insert(index, text)
165
+ // The binding will handle syncing back
166
+ } else {
167
+ // Fallback: modify delta directly
168
+ const currentText = this.text
169
+ const newText = currentText.slice(0, index) + text + currentText.slice(index)
170
+ this.deltaList = frozen([{ insert: newText }])
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Deletes text at the specified range.
176
+ */
177
+ @modelAction
178
+ deleteText(index: number, length: number): void {
179
+ const loroText = this.loroText
180
+ if (loroText) {
181
+ loroText.delete(index, length)
182
+ // The binding will handle syncing back
183
+ } else {
184
+ // Fallback: modify delta directly
185
+ const currentText = this.text
186
+ const newText = currentText.slice(0, index) + currentText.slice(index + length)
187
+ this.deltaList = frozen([{ insert: newText }])
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Internal action to update delta from Loro sync.
193
+ * @internal
194
+ */
195
+ @modelAction
196
+ _updateDeltaFromLoro(delta: LoroTextDeltaList): void {
197
+ this.deltaList = frozen(delta)
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Type for LoroTextModel for use with tProp.
203
+ */
204
+ export const loroTextModelType = types.model(LoroTextModel)
205
+
206
+ /**
207
+ * Checks if a snapshot is a LoroTextModel snapshot.
208
+ */
209
+ export function isLoroTextModelSnapshot(value: unknown): value is SnapshotOutOf<LoroTextModel> {
210
+ return getSnapshotModelType(value) === loroTextModelId
211
+ }