mobx-keystone-yjs 1.3.1 → 1.4.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,92 +1,96 @@
1
- import { Patch } from "mobx-keystone"
2
- import * as Y from "yjs"
3
- import { failure } from "../utils/error"
4
- import { convertJsonToYjsData } from "./convertJsonToYjsData"
5
-
6
- export function applyMobxKeystonePatchToYjsObject(patch: Patch, yjs: unknown): void {
7
- if (patch.path.length > 1) {
8
- const [key, ...rest] = patch.path
9
-
10
- if (yjs instanceof Y.Map) {
11
- const child = yjs.get(String(key))
12
- if (child === undefined) {
13
- throw failure(
14
- `invalid patch path, key "${key}" not found in Yjs map - patch: ${JSON.stringify(patch)}`
15
- )
16
- }
17
- applyMobxKeystonePatchToYjsObject({ ...patch, path: rest }, child)
18
- } else if (yjs instanceof Y.Array) {
19
- const child = yjs.get(Number(key))
20
- if (child === undefined) {
21
- throw failure(
22
- `invalid patch path, key "${key}" not found in Yjs array - patch: ${JSON.stringify(
23
- patch
24
- )}`
25
- )
26
- }
27
- applyMobxKeystonePatchToYjsObject({ ...patch, path: rest }, child)
28
- } else {
29
- throw failure(
30
- `invalid patch path, key "${key}" not found in unknown Yjs object - patch: ${JSON.stringify(
31
- patch
32
- )}`
33
- )
34
- }
35
- } else if (patch.path.length === 1) {
36
- if (yjs instanceof Y.Map) {
37
- const key = String(patch.path[0])
38
-
39
- switch (patch.op) {
40
- case "add":
41
- case "replace": {
42
- yjs.set(key, convertJsonToYjsData(patch.value))
43
- break
44
- }
45
- case "remove": {
46
- yjs.delete(key)
47
- break
48
- }
49
- default: {
50
- throw failure(`invalid patch operation for map`)
51
- }
52
- }
53
- } else if (yjs instanceof Y.Array) {
54
- const key = patch.path[0]
55
-
56
- switch (patch.op) {
57
- case "replace": {
58
- if (key === "length") {
59
- if (yjs.length > patch.value) {
60
- const toDelete = yjs.length - patch.value
61
- yjs.delete(patch.value, toDelete)
62
- } else if (yjs.length < patch.value) {
63
- const toInsert = patch.value - yjs.length
64
- yjs.insert(yjs.length, Array(toInsert).fill(undefined))
65
- }
66
- } else {
67
- yjs.delete(Number(key))
68
- yjs.insert(Number(key), [convertJsonToYjsData(patch.value)])
69
- }
70
- break
71
- }
72
- case "add": {
73
- yjs.insert(Number(key), [convertJsonToYjsData(patch.value)])
74
- break
75
- }
76
- case "remove": {
77
- yjs.delete(Number(key))
78
- break
79
- }
80
- default: {
81
- throw failure(`invalid patch operation for array`)
82
- }
83
- }
84
- } else {
85
- throw failure(
86
- `invalid patch path, the Yjs object is of an unkown type, so key "${patch.path[0]}" cannot be found in it`
87
- )
88
- }
89
- } else {
90
- throw failure(`invalid patch path, it cannot be empty`)
91
- }
92
- }
1
+ import { Patch } from "mobx-keystone"
2
+ import * as Y from "yjs"
3
+ import { failure } from "../utils/error"
4
+ import { convertJsonToYjsData } from "./convertJsonToYjsData"
5
+
6
+ export function applyMobxKeystonePatchToYjsObject(patch: Patch, yjs: unknown): void {
7
+ if (patch.path.length > 1) {
8
+ const [key, ...rest] = patch.path
9
+
10
+ if (yjs instanceof Y.Map) {
11
+ const child = yjs.get(String(key))
12
+ if (child === undefined) {
13
+ throw failure(
14
+ `invalid patch path, key "${key}" not found in Yjs map - patch: ${JSON.stringify(patch)}`
15
+ )
16
+ }
17
+ applyMobxKeystonePatchToYjsObject({ ...patch, path: rest }, child)
18
+ } else if (yjs instanceof Y.Array) {
19
+ const child = yjs.get(Number(key))
20
+ if (child === undefined) {
21
+ throw failure(
22
+ `invalid patch path, key "${key}" not found in Yjs array - patch: ${JSON.stringify(
23
+ patch
24
+ )}`
25
+ )
26
+ }
27
+ applyMobxKeystonePatchToYjsObject({ ...patch, path: rest }, child)
28
+ } else if (yjs instanceof Y.Text) {
29
+ // changes to deltaList will be handled by the array observe in the YjsTextModel class
30
+ } else {
31
+ throw failure(
32
+ `invalid patch path, key "${key}" not found in unknown Yjs object - patch: ${JSON.stringify(
33
+ patch
34
+ )}`
35
+ )
36
+ }
37
+ } else if (patch.path.length === 1) {
38
+ if (yjs instanceof Y.Map) {
39
+ const key = String(patch.path[0])
40
+
41
+ switch (patch.op) {
42
+ case "add":
43
+ case "replace": {
44
+ yjs.set(key, convertJsonToYjsData(patch.value))
45
+ break
46
+ }
47
+ case "remove": {
48
+ yjs.delete(key)
49
+ break
50
+ }
51
+ default: {
52
+ throw failure(`invalid patch operation for map`)
53
+ }
54
+ }
55
+ } else if (yjs instanceof Y.Array) {
56
+ const key = patch.path[0]
57
+
58
+ switch (patch.op) {
59
+ case "replace": {
60
+ if (key === "length") {
61
+ if (yjs.length > patch.value) {
62
+ const toDelete = yjs.length - patch.value
63
+ yjs.delete(patch.value, toDelete)
64
+ } else if (yjs.length < patch.value) {
65
+ const toInsert = patch.value - yjs.length
66
+ yjs.insert(yjs.length, Array(toInsert).fill(undefined))
67
+ }
68
+ } else {
69
+ yjs.delete(Number(key))
70
+ yjs.insert(Number(key), [convertJsonToYjsData(patch.value)])
71
+ }
72
+ break
73
+ }
74
+ case "add": {
75
+ yjs.insert(Number(key), [convertJsonToYjsData(patch.value)])
76
+ break
77
+ }
78
+ case "remove": {
79
+ yjs.delete(Number(key))
80
+ break
81
+ }
82
+ default: {
83
+ throw failure(`invalid patch operation for array`)
84
+ }
85
+ }
86
+ } else if (yjs instanceof Y.Text) {
87
+ // initialization of a YjsTextModel, do nothing
88
+ } else {
89
+ throw failure(
90
+ `invalid patch path, the Yjs object is of an unkown type, so key "${patch.path[0]}" cannot be found in it`
91
+ )
92
+ }
93
+ } else {
94
+ throw failure(`invalid patch path, it cannot be empty`)
95
+ }
96
+ }
@@ -1,157 +1,191 @@
1
- import {
2
- AnyDataModel,
3
- AnyModel,
4
- AnyStandardType,
5
- ModelClass,
6
- Patch,
7
- TypeToData,
8
- applyPatches,
9
- fromSnapshot,
10
- getParentToChildPath,
11
- onGlobalPatches,
12
- onPatches,
13
- onSnapshot,
14
- } from "mobx-keystone"
15
- import * as Y from "yjs"
16
- import { applyMobxKeystonePatchToYjsObject } from "./applyMobxKeystonePatchToYjsObject"
17
- import { convertYjsEventToPatches } from "./convertYjsEventToPatches"
18
- import { YjsBindingContext, yjsBindingContext } from "./yjsBindingContext"
19
-
20
- export function bindYjsToMobxKeystone<
21
- TType extends AnyStandardType | ModelClass<AnyModel> | ModelClass<AnyDataModel>,
22
- >({
23
- yjsDoc,
24
- yjsObject,
25
- mobxKeystoneType,
26
- }: {
27
- yjsDoc: Y.Doc
28
- yjsObject: Y.Map<unknown> | Y.Array<unknown>
29
- mobxKeystoneType: TType
30
- }): {
31
- boundObject: TypeToData<TType>
32
- dispose(): void
33
- yjsOrigin: symbol
34
- } {
35
- const yjsOrigin = Symbol("bindYjsToMobxKeystoneTransactionOrigin")
36
-
37
- const bindingContext: YjsBindingContext = {
38
- yjsDoc,
39
- yjsObject,
40
- mobxKeystoneType,
41
- yjsOrigin,
42
- boundObject: undefined, // not yet created
43
- }
44
-
45
- const yjsJson = yjsObject.toJSON()
46
-
47
- const initializationGlobalPatches: { target: object; patches: Patch[] }[] = []
48
-
49
- const createBoundObject = () => {
50
- const disposeOnGlobalPatches = onGlobalPatches((target, patches) => {
51
- initializationGlobalPatches.push({ target, patches })
52
- })
53
-
54
- try {
55
- const boundObject = yjsBindingContext.apply(
56
- () => fromSnapshot(mobxKeystoneType, yjsJson as any),
57
- bindingContext
58
- )
59
- yjsBindingContext.set(boundObject, { ...bindingContext, boundObject })
60
- return boundObject
61
- } finally {
62
- disposeOnGlobalPatches()
63
- }
64
- }
65
-
66
- const boundObject = createBoundObject()
67
-
68
- let applyingMobxKeystoneChanges = 0
69
-
70
- // bind any changes from yjs to mobx-keystone
71
- const observeDeepCb = (events: Y.YEvent<any>[]) => {
72
- const patches: Patch[] = []
73
- events.forEach((event) => {
74
- if (event.transaction.origin !== yjsOrigin) {
75
- patches.push(...convertYjsEventToPatches(event))
76
- }
77
- })
78
-
79
- if (patches.length > 0) {
80
- applyingMobxKeystoneChanges++
81
- try {
82
- applyPatches(boundObject, patches)
83
- } finally {
84
- applyingMobxKeystoneChanges--
85
- }
86
- }
87
- }
88
-
89
- yjsObject.observeDeep(observeDeepCb)
90
-
91
- // bind any changes from mobx-keystone to yjs
92
- let pendingPatches: Patch[] = []
93
- const disposeOnPatches = onPatches(boundObject, (patches) => {
94
- if (applyingMobxKeystoneChanges > 0) {
95
- return
96
- }
97
-
98
- pendingPatches.push(...patches)
99
- })
100
-
101
- // this is only used so we can transact all patches to the snapshot boundary
102
- const disposeOnSnapshot = onSnapshot(boundObject, () => {
103
- if (pendingPatches.length === 0) {
104
- return
105
- }
106
-
107
- const patches = pendingPatches
108
- pendingPatches = []
109
-
110
- yjsDoc.transact(() => {
111
- patches.forEach((patch) => {
112
- applyMobxKeystonePatchToYjsObject(patch, yjsObject)
113
- })
114
- }, yjsOrigin)
115
- })
116
-
117
- // sync initial patches, that might include setting defaults, IDs, etc
118
- yjsDoc.transact(() => {
119
- // we need to skip initializations until we hit the initialization of the bound object
120
- // this is because default objects might be created and initialized before the main object
121
- // but we just need to catch when those are actually assigned to the bound object
122
- let boundObjectFound = false
123
-
124
- initializationGlobalPatches.forEach(({ target, patches }) => {
125
- if (!boundObjectFound) {
126
- if (target !== boundObject) {
127
- return // skip
128
- }
129
- boundObjectFound = true
130
- }
131
-
132
- const parentToChildPath = getParentToChildPath(boundObject, target)
133
- // this is undefined only if target is not a child of boundModel
134
- if (parentToChildPath !== undefined) {
135
- patches.forEach((patch) => {
136
- applyMobxKeystonePatchToYjsObject(
137
- {
138
- ...patch,
139
- path: [...parentToChildPath, ...patch.path],
140
- },
141
- yjsObject
142
- )
143
- })
144
- }
145
- })
146
- }, yjsOrigin)
147
-
148
- return {
149
- boundObject,
150
- dispose: () => {
151
- disposeOnPatches()
152
- disposeOnSnapshot()
153
- yjsObject.unobserveDeep(observeDeepCb)
154
- },
155
- yjsOrigin,
156
- }
157
- }
1
+ import { action } from "mobx"
2
+ import {
3
+ AnyDataModel,
4
+ AnyModel,
5
+ AnyStandardType,
6
+ ModelClass,
7
+ Patch,
8
+ TypeToData,
9
+ applyPatches,
10
+ fromSnapshot,
11
+ getParentToChildPath,
12
+ onGlobalPatches,
13
+ onPatches,
14
+ onSnapshot,
15
+ } from "mobx-keystone"
16
+ import * as Y from "yjs"
17
+ import { getOrCreateYjsCollectionAtom } from "../utils/getOrCreateYjsCollectionAtom"
18
+ import { applyMobxKeystonePatchToYjsObject } from "./applyMobxKeystonePatchToYjsObject"
19
+ import { convertYjsDataToJson } from "./convertYjsDataToJson"
20
+ import { convertYjsEventToPatches } from "./convertYjsEventToPatches"
21
+ import { YjsBindingContext, yjsBindingContext } from "./yjsBindingContext"
22
+
23
+ /**
24
+ * Creates a bidirectional binding between a Y.js data structure and a mobx-keystone model.
25
+ */
26
+ export function bindYjsToMobxKeystone<
27
+ TType extends AnyStandardType | ModelClass<AnyModel> | ModelClass<AnyDataModel>,
28
+ >({
29
+ yjsDoc,
30
+ yjsObject,
31
+ mobxKeystoneType,
32
+ }: {
33
+ /**
34
+ * The Y.js document.
35
+ */
36
+ yjsDoc: Y.Doc
37
+ /**
38
+ * The bound Y.js data structure.
39
+ */
40
+ yjsObject: Y.Map<unknown> | Y.Array<unknown> | Y.Text
41
+ /**
42
+ * The mobx-keystone model type.
43
+ */
44
+ mobxKeystoneType: TType
45
+ }): {
46
+ /**
47
+ * The bound mobx-keystone instance.
48
+ */
49
+ boundObject: TypeToData<TType>
50
+ /**
51
+ * Disposes the binding.
52
+ */
53
+ dispose(): void
54
+ /**
55
+ * The Y.js origin symbol used for binding transactions.
56
+ */
57
+ yjsOrigin: symbol
58
+ } {
59
+ const yjsOrigin = Symbol("bindYjsToMobxKeystoneTransactionOrigin")
60
+
61
+ let applyingYjsChangesToMobxKeystone = 0
62
+
63
+ const bindingContext: YjsBindingContext = {
64
+ yjsDoc,
65
+ yjsObject,
66
+ mobxKeystoneType,
67
+ yjsOrigin,
68
+ boundObject: undefined, // not yet created
69
+
70
+ get isApplyingYjsChangesToMobxKeystone() {
71
+ return applyingYjsChangesToMobxKeystone > 0
72
+ },
73
+ }
74
+
75
+ const yjsJson = convertYjsDataToJson(yjsObject)
76
+
77
+ const initializationGlobalPatches: { target: object; patches: Patch[] }[] = []
78
+
79
+ const createBoundObject = () => {
80
+ const disposeOnGlobalPatches = onGlobalPatches((target, patches) => {
81
+ initializationGlobalPatches.push({ target, patches })
82
+ })
83
+
84
+ try {
85
+ const boundObject = yjsBindingContext.apply(
86
+ () => fromSnapshot(mobxKeystoneType, yjsJson as any),
87
+ bindingContext
88
+ )
89
+ yjsBindingContext.set(boundObject, { ...bindingContext, boundObject })
90
+ return boundObject
91
+ } finally {
92
+ disposeOnGlobalPatches()
93
+ }
94
+ }
95
+
96
+ const boundObject = createBoundObject()
97
+
98
+ // bind any changes from yjs to mobx-keystone
99
+ const observeDeepCb = action((events: Y.YEvent<any>[]) => {
100
+ const patches: Patch[] = []
101
+ events.forEach((event) => {
102
+ if (event.transaction.origin !== yjsOrigin) {
103
+ patches.push(...convertYjsEventToPatches(event))
104
+ }
105
+
106
+ if (event.target instanceof Y.Map || event.target instanceof Y.Array) {
107
+ getOrCreateYjsCollectionAtom(event.target).reportChanged()
108
+ }
109
+ })
110
+
111
+ if (patches.length > 0) {
112
+ applyingYjsChangesToMobxKeystone++
113
+ try {
114
+ applyPatches(boundObject, patches)
115
+ } finally {
116
+ applyingYjsChangesToMobxKeystone--
117
+ }
118
+ }
119
+ })
120
+
121
+ yjsObject.observeDeep(observeDeepCb)
122
+
123
+ // bind any changes from mobx-keystone to yjs
124
+ let pendingArrayOfArrayOfPatches: Patch[][] = []
125
+ const disposeOnPatches = onPatches(boundObject, (patches) => {
126
+ if (applyingYjsChangesToMobxKeystone > 0) {
127
+ return
128
+ }
129
+
130
+ pendingArrayOfArrayOfPatches.push(patches)
131
+ })
132
+
133
+ // this is only used so we can transact all patches to the snapshot boundary
134
+ const disposeOnSnapshot = onSnapshot(boundObject, () => {
135
+ if (pendingArrayOfArrayOfPatches.length === 0) {
136
+ return
137
+ }
138
+
139
+ const arrayOfArrayOfPatches = pendingArrayOfArrayOfPatches
140
+ pendingArrayOfArrayOfPatches = []
141
+
142
+ yjsDoc.transact(() => {
143
+ arrayOfArrayOfPatches.forEach((arrayOfPatches) => {
144
+ arrayOfPatches.forEach((patch) => {
145
+ applyMobxKeystonePatchToYjsObject(patch, yjsObject)
146
+ })
147
+ })
148
+ }, yjsOrigin)
149
+ })
150
+
151
+ // sync initial patches, that might include setting defaults, IDs, etc
152
+ yjsDoc.transact(() => {
153
+ // we need to skip initializations until we hit the initialization of the bound object
154
+ // this is because default objects might be created and initialized before the main object
155
+ // but we just need to catch when those are actually assigned to the bound object
156
+ let boundObjectFound = false
157
+
158
+ initializationGlobalPatches.forEach(({ target, patches }) => {
159
+ if (!boundObjectFound) {
160
+ if (target !== boundObject) {
161
+ return // skip
162
+ }
163
+ boundObjectFound = true
164
+ }
165
+
166
+ const parentToChildPath = getParentToChildPath(boundObject, target)
167
+ // this is undefined only if target is not a child of boundModel
168
+ if (parentToChildPath !== undefined) {
169
+ patches.forEach((patch) => {
170
+ applyMobxKeystonePatchToYjsObject(
171
+ {
172
+ ...patch,
173
+ path: [...parentToChildPath, ...patch.path],
174
+ },
175
+ yjsObject
176
+ )
177
+ })
178
+ }
179
+ })
180
+ }, yjsOrigin)
181
+
182
+ return {
183
+ boundObject,
184
+ dispose: () => {
185
+ disposeOnPatches()
186
+ disposeOnSnapshot()
187
+ yjsObject.unobserveDeep(observeDeepCb)
188
+ },
189
+ yjsOrigin,
190
+ }
191
+ }
@@ -1,50 +1,72 @@
1
- import * as Y from "yjs"
2
- import { JsonValue, JsonArray, JsonObject, JsonPrimitive } from "../jsonTypes"
3
-
4
- function isJsonPrimitive(v: JsonValue): v is JsonPrimitive {
5
- const t = typeof v
6
- return t === "string" || t === "number" || t === "boolean" || v === null
7
- }
8
-
9
- function isJsonArray(v: JsonValue): v is JsonArray {
10
- return Array.isArray(v)
11
- }
12
-
13
- function isJsonObject(v: JsonValue): v is JsonObject {
14
- return !isJsonArray(v) && typeof v === "object"
15
- }
16
-
17
- export function convertJsonToYjsData(v: JsonValue) {
18
- if (v === undefined || isJsonPrimitive(v)) {
19
- return v
20
- }
21
-
22
- if (isJsonArray(v)) {
23
- const arr = new Y.Array()
24
- applyJsonArrayYArray(arr, v)
25
- return arr
26
- }
27
-
28
- if (isJsonObject(v)) {
29
- if (v.$frozen === true) {
30
- // frozen value, save as immutable object
31
- return v
32
- }
33
-
34
- const map = new Y.Map()
35
- applyJsonObjectToYMap(map, v)
36
- return map
37
- }
38
-
39
- throw new Error(`unsupported value type: ${v}`)
40
- }
41
-
42
- export function applyJsonArrayYArray(dest: Y.Array<unknown>, source: JsonArray) {
43
- dest.push(source.map(convertJsonToYjsData))
44
- }
45
-
46
- export function applyJsonObjectToYMap(dest: Y.Map<unknown>, source: JsonObject) {
47
- Object.entries(source).forEach(([k, v]) => {
48
- dest.set(k, convertJsonToYjsData(v))
49
- })
50
- }
1
+ import * as Y from "yjs"
2
+ import { JsonValue, JsonArray, JsonObject, JsonPrimitive } from "../jsonTypes"
3
+ import { YjsTextModel, yjsTextModelId } from "./YjsTextModel"
4
+ import { SnapshotOutOf } from "mobx-keystone"
5
+
6
+ function isJsonPrimitive(v: JsonValue): v is JsonPrimitive {
7
+ const t = typeof v
8
+ return t === "string" || t === "number" || t === "boolean" || v === null
9
+ }
10
+
11
+ function isJsonArray(v: JsonValue): v is JsonArray {
12
+ return Array.isArray(v)
13
+ }
14
+
15
+ function isJsonObject(v: JsonValue): v is JsonObject {
16
+ return !isJsonArray(v) && typeof v === "object"
17
+ }
18
+
19
+ /**
20
+ * Converts a JSON value to a Y.js data structure.
21
+ * Objects are converted to Y.Maps, arrays to Y.Arrays, primitives are untouched.
22
+ * Frozen values are a special case and they are kept as immutable plain values.
23
+ */
24
+ export function convertJsonToYjsData(v: JsonValue) {
25
+ if (v === undefined || isJsonPrimitive(v)) {
26
+ return v
27
+ }
28
+
29
+ if (isJsonArray(v)) {
30
+ const arr = new Y.Array()
31
+ applyJsonArrayToYArray(arr, v)
32
+ return arr
33
+ }
34
+
35
+ if (isJsonObject(v)) {
36
+ if (v.$frozen === true) {
37
+ // frozen value, save as immutable object
38
+ return v
39
+ }
40
+
41
+ if (v.$modelType === yjsTextModelId) {
42
+ const text = new Y.Text()
43
+ const yjsTextModel = v as SnapshotOutOf<YjsTextModel>
44
+ yjsTextModel.deltaList.forEach((frozenDeltas) => {
45
+ text.applyDelta(frozenDeltas.data)
46
+ })
47
+ return text
48
+ }
49
+
50
+ const map = new Y.Map()
51
+ applyJsonObjectToYMap(map, v)
52
+ return map
53
+ }
54
+
55
+ throw new Error(`unsupported value type: ${v}`)
56
+ }
57
+
58
+ /**
59
+ * Applies a JSON array to a Y.Array, using the convertJsonToYjsData to convert the values.
60
+ */
61
+ export function applyJsonArrayToYArray(dest: Y.Array<unknown>, source: JsonArray) {
62
+ dest.push(source.map(convertJsonToYjsData))
63
+ }
64
+
65
+ /**
66
+ * Applies a JSON object to a Y.Map, using the convertJsonToYjsData to convert the values.
67
+ */
68
+ export function applyJsonObjectToYMap(dest: Y.Map<unknown>, source: JsonObject) {
69
+ Object.entries(source).forEach(([k, v]) => {
70
+ dest.set(k, convertJsonToYjsData(v))
71
+ })
72
+ }