mobx-keystone-yjs 1.3.1 → 1.5.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.
@@ -1,9 +1,11 @@
1
+ import { action } from "mobx"
1
2
  import {
2
3
  AnyDataModel,
3
4
  AnyModel,
4
5
  AnyStandardType,
5
6
  ModelClass,
6
7
  Patch,
8
+ SnapshotInOf,
7
9
  TypeToData,
8
10
  applyPatches,
9
11
  fromSnapshot,
@@ -13,10 +15,15 @@ import {
13
15
  onSnapshot,
14
16
  } from "mobx-keystone"
15
17
  import * as Y from "yjs"
18
+ import { getYjsCollectionAtom } from "../utils/getOrCreateYjsCollectionAtom"
16
19
  import { applyMobxKeystonePatchToYjsObject } from "./applyMobxKeystonePatchToYjsObject"
20
+ import { YjsData, convertYjsDataToJson } from "./convertYjsDataToJson"
17
21
  import { convertYjsEventToPatches } from "./convertYjsEventToPatches"
18
22
  import { YjsBindingContext, yjsBindingContext } from "./yjsBindingContext"
19
23
 
24
+ /**
25
+ * Creates a bidirectional binding between a Y.js data structure and a mobx-keystone model.
26
+ */
20
27
  export function bindYjsToMobxKeystone<
21
28
  TType extends AnyStandardType | ModelClass<AnyModel> | ModelClass<AnyDataModel>,
22
29
  >({
@@ -24,25 +31,49 @@ export function bindYjsToMobxKeystone<
24
31
  yjsObject,
25
32
  mobxKeystoneType,
26
33
  }: {
34
+ /**
35
+ * The Y.js document.
36
+ */
27
37
  yjsDoc: Y.Doc
28
- yjsObject: Y.Map<unknown> | Y.Array<unknown>
38
+ /**
39
+ * The bound Y.js data structure.
40
+ */
41
+ yjsObject: Y.Map<unknown> | Y.Array<unknown> | Y.Text
42
+ /**
43
+ * The mobx-keystone model type.
44
+ */
29
45
  mobxKeystoneType: TType
30
46
  }): {
47
+ /**
48
+ * The bound mobx-keystone instance.
49
+ */
31
50
  boundObject: TypeToData<TType>
32
- dispose(): void
51
+ /**
52
+ * Disposes the binding.
53
+ */
54
+ dispose: () => void
55
+ /**
56
+ * The Y.js origin symbol used for binding transactions.
57
+ */
33
58
  yjsOrigin: symbol
34
59
  } {
35
60
  const yjsOrigin = Symbol("bindYjsToMobxKeystoneTransactionOrigin")
36
61
 
62
+ let applyingYjsChangesToMobxKeystone = 0
63
+
37
64
  const bindingContext: YjsBindingContext = {
38
65
  yjsDoc,
39
66
  yjsObject,
40
67
  mobxKeystoneType,
41
68
  yjsOrigin,
42
69
  boundObject: undefined, // not yet created
70
+
71
+ get isApplyingYjsChangesToMobxKeystone() {
72
+ return applyingYjsChangesToMobxKeystone > 0
73
+ },
43
74
  }
44
75
 
45
- const yjsJson = yjsObject.toJSON()
76
+ const yjsJson = convertYjsDataToJson(yjsObject as YjsData)
46
77
 
47
78
  const initializationGlobalPatches: { target: object; patches: Patch[] }[] = []
48
79
 
@@ -53,7 +84,7 @@ export function bindYjsToMobxKeystone<
53
84
 
54
85
  try {
55
86
  const boundObject = yjsBindingContext.apply(
56
- () => fromSnapshot(mobxKeystoneType, yjsJson as any),
87
+ () => fromSnapshot(mobxKeystoneType, yjsJson as unknown as SnapshotInOf<TypeToData<TType>>),
57
88
  bindingContext
58
89
  )
59
90
  yjsBindingContext.set(boundObject, { ...bindingContext, boundObject })
@@ -65,51 +96,55 @@ export function bindYjsToMobxKeystone<
65
96
 
66
97
  const boundObject = createBoundObject()
67
98
 
68
- let applyingMobxKeystoneChanges = 0
69
-
70
99
  // bind any changes from yjs to mobx-keystone
71
- const observeDeepCb = (events: Y.YEvent<any>[]) => {
100
+ const observeDeepCb = action((events: Y.YEvent<any>[]) => {
72
101
  const patches: Patch[] = []
73
102
  events.forEach((event) => {
74
103
  if (event.transaction.origin !== yjsOrigin) {
75
104
  patches.push(...convertYjsEventToPatches(event))
76
105
  }
106
+
107
+ if (event.target instanceof Y.Map || event.target instanceof Y.Array) {
108
+ getYjsCollectionAtom(event.target)?.reportChanged()
109
+ }
77
110
  })
78
111
 
79
112
  if (patches.length > 0) {
80
- applyingMobxKeystoneChanges++
113
+ applyingYjsChangesToMobxKeystone++
81
114
  try {
82
115
  applyPatches(boundObject, patches)
83
116
  } finally {
84
- applyingMobxKeystoneChanges--
117
+ applyingYjsChangesToMobxKeystone--
85
118
  }
86
119
  }
87
- }
120
+ })
88
121
 
89
122
  yjsObject.observeDeep(observeDeepCb)
90
123
 
91
124
  // bind any changes from mobx-keystone to yjs
92
- let pendingPatches: Patch[] = []
125
+ let pendingArrayOfArrayOfPatches: Patch[][] = []
93
126
  const disposeOnPatches = onPatches(boundObject, (patches) => {
94
- if (applyingMobxKeystoneChanges > 0) {
127
+ if (applyingYjsChangesToMobxKeystone > 0) {
95
128
  return
96
129
  }
97
130
 
98
- pendingPatches.push(...patches)
131
+ pendingArrayOfArrayOfPatches.push(patches)
99
132
  })
100
133
 
101
134
  // this is only used so we can transact all patches to the snapshot boundary
102
135
  const disposeOnSnapshot = onSnapshot(boundObject, () => {
103
- if (pendingPatches.length === 0) {
136
+ if (pendingArrayOfArrayOfPatches.length === 0) {
104
137
  return
105
138
  }
106
139
 
107
- const patches = pendingPatches
108
- pendingPatches = []
140
+ const arrayOfArrayOfPatches = pendingArrayOfArrayOfPatches
141
+ pendingArrayOfArrayOfPatches = []
109
142
 
110
143
  yjsDoc.transact(() => {
111
- patches.forEach((patch) => {
112
- applyMobxKeystonePatchToYjsObject(patch, yjsObject)
144
+ arrayOfArrayOfPatches.forEach((arrayOfPatches) => {
145
+ arrayOfPatches.forEach((patch) => {
146
+ applyMobxKeystonePatchToYjsObject(patch, yjsObject)
147
+ })
113
148
  })
114
149
  }, yjsOrigin)
115
150
  })
@@ -1,49 +1,77 @@
1
1
  import * as Y from "yjs"
2
- import { JsonValue, JsonArray, JsonObject, JsonPrimitive } from "../jsonTypes"
2
+ import { YjsTextModel, yjsTextModelId } from "./YjsTextModel"
3
+ import { SnapshotOutOf } from "mobx-keystone"
4
+ import { YjsData } from "./convertYjsDataToJson"
5
+ import {
6
+ JsonArrayWithUndefined,
7
+ JsonObjectWithUndefined,
8
+ JsonPrimitiveWithUndefined,
9
+ JsonValueWithUndefined,
10
+ } from "jsonTypes"
3
11
 
4
- function isJsonPrimitive(v: JsonValue): v is JsonPrimitive {
12
+ function isJsonPrimitiveWithUndefined(v: JsonValueWithUndefined): v is JsonPrimitiveWithUndefined {
5
13
  const t = typeof v
6
- return t === "string" || t === "number" || t === "boolean" || v === null
14
+ return t === "string" || t === "number" || t === "boolean" || v === null || v === undefined
7
15
  }
8
16
 
9
- function isJsonArray(v: JsonValue): v is JsonArray {
17
+ function isJsonArrayWithUndefined(v: JsonValueWithUndefined): v is JsonArrayWithUndefined {
10
18
  return Array.isArray(v)
11
19
  }
12
20
 
13
- function isJsonObject(v: JsonValue): v is JsonObject {
14
- return !isJsonArray(v) && typeof v === "object"
21
+ function isJsonObjectWithUndefined(v: JsonValueWithUndefined): v is JsonObjectWithUndefined {
22
+ return !isJsonArrayWithUndefined(v) && typeof v === "object"
15
23
  }
16
24
 
17
- export function convertJsonToYjsData(v: JsonValue) {
18
- if (v === undefined || isJsonPrimitive(v)) {
25
+ /**
26
+ * Converts a JSON value to a Y.js data structure.
27
+ * Objects are converted to Y.Maps, arrays to Y.Arrays, primitives are untouched.
28
+ * Frozen values are a special case and they are kept as immutable plain values.
29
+ */
30
+ export function convertJsonToYjsData(v: JsonValueWithUndefined | undefined): YjsData {
31
+ if (v === undefined || isJsonPrimitiveWithUndefined(v)) {
19
32
  return v
20
33
  }
21
34
 
22
- if (isJsonArray(v)) {
35
+ if (isJsonArrayWithUndefined(v)) {
23
36
  const arr = new Y.Array()
24
- applyJsonArrayYArray(arr, v)
25
- return arr
37
+ applyJsonArrayToYArray(arr, v)
38
+ return arr as YjsData
26
39
  }
27
40
 
28
- if (isJsonObject(v)) {
41
+ if (isJsonObjectWithUndefined(v)) {
29
42
  if (v.$frozen === true) {
30
43
  // frozen value, save as immutable object
31
44
  return v
32
45
  }
33
46
 
47
+ if (v.$modelType === yjsTextModelId) {
48
+ const text = new Y.Text()
49
+ const yjsTextModel = v as unknown as SnapshotOutOf<YjsTextModel>
50
+ yjsTextModel.deltaList.forEach((frozenDeltas) => {
51
+ text.applyDelta(frozenDeltas.data)
52
+ })
53
+ return text
54
+ }
55
+
34
56
  const map = new Y.Map()
35
57
  applyJsonObjectToYMap(map, v)
36
- return map
58
+ return map as YjsData
37
59
  }
38
60
 
39
61
  throw new Error(`unsupported value type: ${v}`)
40
62
  }
41
63
 
42
- export function applyJsonArrayYArray(dest: Y.Array<unknown>, source: JsonArray) {
64
+ /**
65
+ * Applies a JSON array to a Y.Array, using the convertJsonToYjsData to convert the values.
66
+ */
67
+ export function applyJsonArrayToYArray(dest: Y.Array<unknown>, source: JsonArrayWithUndefined) {
43
68
  dest.push(source.map(convertJsonToYjsData))
44
69
  }
45
70
 
46
- export function applyJsonObjectToYMap(dest: Y.Map<unknown>, source: JsonObject) {
71
+ /**
72
+ * Applies a JSON object to a Y.Map, using the convertJsonToYjsData to convert the values.
73
+ */
74
+ export function applyJsonObjectToYMap(dest: Y.Map<unknown>, source: JsonObjectWithUndefined) {
47
75
  Object.entries(source).forEach(([k, v]) => {
48
76
  dest.set(k, convertJsonToYjsData(v))
49
77
  })
@@ -0,0 +1,31 @@
1
+ import { modelSnapshotOutWithMetadata } from "mobx-keystone"
2
+ import * as Y from "yjs"
3
+ import { JsonObjectWithUndefined, JsonValueWithUndefined } from "../jsonTypes"
4
+ import { YjsTextModel } from "./YjsTextModel"
5
+
6
+ export type YjsData = Y.Array<YjsData> | Y.Map<YjsData> | Y.Text | JsonValueWithUndefined
7
+
8
+ export function convertYjsDataToJson(yjsData: YjsData): JsonValueWithUndefined {
9
+ if (yjsData instanceof Y.Array) {
10
+ return yjsData.map((v) => convertYjsDataToJson(v))
11
+ }
12
+
13
+ if (yjsData instanceof Y.Map) {
14
+ const obj: JsonObjectWithUndefined = {}
15
+ yjsData.forEach((v, k) => {
16
+ obj[k] = convertYjsDataToJson(v)
17
+ })
18
+ return obj
19
+ }
20
+
21
+ if (yjsData instanceof Y.Text) {
22
+ const deltas = yjsData.toDelta() as unknown[]
23
+
24
+ return modelSnapshotOutWithMetadata(YjsTextModel, {
25
+ deltaList: deltas.length > 0 ? [{ $frozen: true, data: deltas }] : [],
26
+ }) as unknown as JsonValueWithUndefined
27
+ }
28
+
29
+ // assume it's a primitive
30
+ return yjsData
31
+ }
@@ -1,13 +1,17 @@
1
1
  import { Patch } from "mobx-keystone"
2
2
  import * as Y from "yjs"
3
- import { JsonArray, JsonObject, JsonValue } from "../jsonTypes"
3
+ import {
4
+ JsonArrayWithUndefined,
5
+ JsonObjectWithUndefined,
6
+ JsonValueWithUndefined,
7
+ } from "../jsonTypes"
4
8
  import { failure } from "../utils/error"
5
9
 
6
10
  export function convertYjsEventToPatches(event: Y.YEvent<any>): Patch[] {
7
11
  const patches: Patch[] = []
8
12
 
9
13
  if (event instanceof Y.YMapEvent) {
10
- const source = event.target as Y.Map<any>
14
+ const source = event.target
11
15
 
12
16
  event.changes.keys.forEach((change, key) => {
13
17
  const path = [...event.path, key]
@@ -71,14 +75,21 @@ export function convertYjsEventToPatches(event: Y.YEvent<any>): Patch[] {
71
75
  })
72
76
  }
73
77
  })
78
+ } else if (event instanceof Y.YTextEvent) {
79
+ const path = [...event.path, "deltaList", -1 /* last item */]
80
+ patches.push({
81
+ op: "add",
82
+ path,
83
+ value: { $frozen: true, data: event.delta },
84
+ })
74
85
  }
75
86
 
76
87
  return patches
77
88
  }
78
89
 
79
- function toPlainValue(v: Y.Map<any> | Y.Array<any> | JsonValue) {
90
+ function toPlainValue(v: Y.Map<any> | Y.Array<any> | JsonValueWithUndefined) {
80
91
  if (v instanceof Y.Map || v instanceof Y.Array) {
81
- return v.toJSON() as JsonObject | JsonArray
92
+ return v.toJSON() as JsonObjectWithUndefined | JsonArrayWithUndefined
82
93
  } else {
83
94
  return v
84
95
  }
@@ -0,0 +1,27 @@
1
+ import * as Y from "yjs"
2
+ import { failure } from "../utils/error"
3
+ import { getOrCreateYjsCollectionAtom } from "../utils/getOrCreateYjsCollectionAtom"
4
+
5
+ export function resolveYjsPath(yjsObject: unknown, path: readonly (string | number)[]): unknown {
6
+ let currentYjsObject: unknown = yjsObject
7
+
8
+ path.forEach((pathPart, i) => {
9
+ if (currentYjsObject instanceof Y.Map) {
10
+ getOrCreateYjsCollectionAtom(currentYjsObject).reportObserved()
11
+ const key = String(pathPart)
12
+ currentYjsObject = currentYjsObject.get(key)
13
+ } else if (currentYjsObject instanceof Y.Array) {
14
+ getOrCreateYjsCollectionAtom(currentYjsObject).reportObserved()
15
+ const key = Number(pathPart)
16
+ currentYjsObject = currentYjsObject.get(key)
17
+ } else {
18
+ throw failure(
19
+ `Y.Map or Y.Array was expected at path ${JSON.stringify(
20
+ path.slice(0, i)
21
+ )} in order to resolve path ${JSON.stringify(path)}, but got ${currentYjsObject} instead`
22
+ )
23
+ }
24
+ })
25
+
26
+ return currentYjsObject
27
+ }
@@ -1,12 +1,42 @@
1
1
  import { AnyType, createContext } from "mobx-keystone"
2
2
  import * as Y from "yjs"
3
3
 
4
+ /**
5
+ * Context with info on how a mobx-keystone model is bound to a Y.js data structure.
6
+ */
4
7
  export interface YjsBindingContext {
8
+ /**
9
+ * The Y.js document.
10
+ */
5
11
  yjsDoc: Y.Doc
6
- yjsObject: Y.Map<unknown> | Y.Array<unknown>
12
+
13
+ /**
14
+ * The bound Y.js data structure.
15
+ */
16
+ yjsObject: Y.Map<unknown> | Y.Array<unknown> | Y.Text
17
+
18
+ /**
19
+ * The mobx-keystone model type.
20
+ */
7
21
  mobxKeystoneType: AnyType
22
+
23
+ /**
24
+ * The origin symbol used for transactions.
25
+ */
8
26
  yjsOrigin: symbol
9
- boundObject: unknown | undefined
27
+
28
+ /**
29
+ * The bound mobx-keystone instance.
30
+ */
31
+ boundObject: unknown
32
+
33
+ /**
34
+ * Whether we are currently applying Y.js changes to the mobx-keystone model.
35
+ */
36
+ isApplyingYjsChangesToMobxKeystone: boolean
10
37
  }
11
38
 
39
+ /**
40
+ * Context with info on how a mobx-keystone model is bound to a Y.js data structure.
41
+ */
12
42
  export const yjsBindingContext = createContext<YjsBindingContext | undefined>(undefined)
package/src/index.ts CHANGED
@@ -1,9 +1,10 @@
1
- export { bindYjsToMobxKeystone } from "./binding/bindYjsToMobxKeystone"
2
- export {
3
- applyJsonArrayYArray,
4
- applyJsonObjectToYMap,
5
- convertJsonToYjsData,
6
- } from "./binding/convertJsonToYjsData"
7
- export { yjsBindingContext } from "./binding/yjsBindingContext"
8
- export type { YjsBindingContext } from "./binding/yjsBindingContext"
9
- export { MobxKeystoneYjsError } from "./utils/error"
1
+ export { YjsTextModel, yjsTextModelId } from "./binding/YjsTextModel"
2
+ export { bindYjsToMobxKeystone } from "./binding/bindYjsToMobxKeystone"
3
+ export {
4
+ applyJsonArrayToYArray,
5
+ applyJsonObjectToYMap,
6
+ convertJsonToYjsData,
7
+ } from "./binding/convertJsonToYjsData"
8
+ export { yjsBindingContext } from "./binding/yjsBindingContext"
9
+ export type { YjsBindingContext } from "./binding/yjsBindingContext"
10
+ export { MobxKeystoneYjsError } from "./utils/error"
package/src/jsonTypes.ts CHANGED
@@ -1,4 +1,9 @@
1
- export type JsonPrimitive = string | number | boolean | null
2
- export type JsonValue = JsonPrimitive | JsonObject | JsonArray
3
- export type JsonObject = { [key: string]: JsonValue }
4
- export interface JsonArray extends Array<JsonValue> {}
1
+ export type JsonPrimitiveWithUndefined = string | number | boolean | null | undefined
2
+ export type JsonValueWithUndefined =
3
+ | JsonPrimitiveWithUndefined
4
+ | JsonObjectWithUndefined
5
+ | JsonArrayWithUndefined
6
+ // eslint-disable-next-line @typescript-eslint/consistent-type-definitions
7
+ export type JsonObjectWithUndefined = { [key: string]: JsonValueWithUndefined }
8
+ // eslint-disable-next-line @typescript-eslint/no-empty-interface
9
+ export interface JsonArrayWithUndefined extends Array<JsonValueWithUndefined> {}
@@ -0,0 +1,27 @@
1
+ import { IAtom, createAtom } from "mobx"
2
+ import * as Y from "yjs"
3
+
4
+ const yjsCollectionAtoms = new WeakMap<Y.Map<unknown> | Y.Array<unknown>, IAtom>()
5
+
6
+ /**
7
+ * @internal
8
+ */
9
+ export const getYjsCollectionAtom = (
10
+ yjsCollection: Y.Map<unknown> | Y.Array<unknown>
11
+ ): IAtom | undefined => {
12
+ return yjsCollectionAtoms.get(yjsCollection)
13
+ }
14
+
15
+ /**
16
+ * @internal
17
+ */
18
+ export const getOrCreateYjsCollectionAtom = (
19
+ yjsCollection: Y.Map<unknown> | Y.Array<unknown>
20
+ ): IAtom => {
21
+ let atom = yjsCollectionAtoms.get(yjsCollection)
22
+ if (!atom) {
23
+ atom = createAtom(`yjsCollectionAtom`)
24
+ yjsCollectionAtoms.set(yjsCollection, atom)
25
+ }
26
+ return atom
27
+ }