mobx-keystone-yjs 1.5.4 → 1.6.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 (31) hide show
  1. package/CHANGELOG.md +57 -45
  2. package/dist/mobx-keystone-yjs.esm.js +475 -299
  3. package/dist/mobx-keystone-yjs.esm.mjs +475 -299
  4. package/dist/mobx-keystone-yjs.umd.js +475 -299
  5. package/dist/types/binding/YjsTextModel.d.ts +5 -4
  6. package/dist/types/binding/applyMobxChangeToYjsObject.d.ts +3 -0
  7. package/dist/types/binding/applyYjsEventToMobx.d.ts +8 -0
  8. package/dist/types/binding/bindYjsToMobxKeystone.d.ts +1 -1
  9. package/dist/types/binding/convertJsonToYjsData.d.ts +23 -4
  10. package/dist/types/binding/convertYjsDataToJson.d.ts +1 -1
  11. package/dist/types/binding/resolveYjsPath.d.ts +14 -1
  12. package/dist/types/binding/yjsBindingContext.d.ts +2 -2
  13. package/dist/types/binding/yjsSnapshotTracking.d.ts +24 -0
  14. package/dist/types/index.d.ts +7 -6
  15. package/dist/types/utils/isYjsValueDeleted.d.ts +7 -0
  16. package/package.json +90 -78
  17. package/src/binding/YjsTextModel.ts +280 -247
  18. package/src/binding/applyMobxChangeToYjsObject.ts +77 -0
  19. package/src/binding/applyYjsEventToMobx.ts +173 -0
  20. package/src/binding/bindYjsToMobxKeystone.ts +300 -192
  21. package/src/binding/convertJsonToYjsData.ts +218 -76
  22. package/src/binding/convertYjsDataToJson.ts +1 -1
  23. package/src/binding/resolveYjsPath.ts +51 -27
  24. package/src/binding/yjsSnapshotTracking.ts +40 -0
  25. package/src/index.ts +11 -10
  26. package/src/utils/getOrCreateYjsCollectionAtom.ts +27 -27
  27. package/src/utils/isYjsValueDeleted.ts +14 -0
  28. package/dist/types/binding/applyMobxKeystonePatchToYjsObject.d.ts +0 -2
  29. package/dist/types/binding/convertYjsEventToPatches.d.ts +0 -3
  30. package/src/binding/applyMobxKeystonePatchToYjsObject.ts +0 -98
  31. package/src/binding/convertYjsEventToPatches.ts +0 -92
@@ -1,247 +1,280 @@
1
- import { IAtom, computed, createAtom, observe, reaction } from "mobx"
2
- import {
3
- Frozen,
4
- Model,
5
- frozen,
6
- getParentToChildPath,
7
- model,
8
- onSnapshot,
9
- tProp,
10
- types,
11
- } from "mobx-keystone"
12
- import * as Y from "yjs"
13
- import { failure } from "../utils/error"
14
- import { YjsBindingContext, yjsBindingContext } from "./yjsBindingContext"
15
- import { resolveYjsPath } from "./resolveYjsPath"
16
-
17
- // Delta[][], since each single change is a Delta[]
18
- // we use frozen so that we can reuse each delta change
19
- const deltaListType = types.array(types.frozen(types.unchecked<unknown[]>()))
20
-
21
- export const yjsTextModelId = "mobx-keystone-yjs/YjsTextModel"
22
-
23
- /**
24
- * A mobx-keystone model that represents a Yjs.Text object.
25
- */
26
- @model(yjsTextModelId)
27
- export class YjsTextModel extends Model({
28
- deltaList: tProp(deltaListType, () => []),
29
- }) {
30
- /**
31
- * Helper function to create a YjsTextModel instance with a simple text.
32
- */
33
- static withText(text: string): YjsTextModel {
34
- return new DecoratedYjsTextModel({
35
- deltaList: [
36
- frozen([
37
- {
38
- insert: text,
39
- },
40
- ]),
41
- ],
42
- })
43
- }
44
-
45
- /**
46
- * The Y.js path from the bound object to the YjsTextModel instance.
47
- */
48
- @computed
49
- private get _yjsObjectPath() {
50
- const ctx = yjsBindingContext.get(this)
51
- if (ctx?.boundObject == null) {
52
- throw failure(
53
- "the YjsTextModel instance must be part of a bound object before it can be accessed"
54
- )
55
- }
56
-
57
- const path = getParentToChildPath(ctx.boundObject, this)
58
- if (!path) {
59
- throw failure("a path from the bound object to the YjsTextModel instance is not available")
60
- }
61
-
62
- return path
63
- }
64
-
65
- /**
66
- * The Yjs.Text object present at this mobx-keystone node's path.
67
- */
68
- @computed
69
- private get _yjsObjectAtPath(): unknown {
70
- const path = this._yjsObjectPath
71
-
72
- const ctx = yjsBindingContext.get(this)!
73
-
74
- return resolveYjsPath(ctx.yjsObject, path)
75
- }
76
-
77
- /**
78
- * The Yjs.Text object represented by this mobx-keystone node.
79
- */
80
- @computed
81
- get yjsText(): Y.Text {
82
- const yjsObject = this._yjsObjectAtPath
83
-
84
- if (!(yjsObject instanceof Y.Text)) {
85
- throw failure(`Y.Text was expected at path ${JSON.stringify(this._yjsObjectPath)}`)
86
- }
87
-
88
- return yjsObject
89
- }
90
-
91
- /**
92
- * Atom that gets changed when the associated Y.js text changes.
93
- */
94
- yjsTextChangedAtom = createAtom("yjsTextChangedAtom")
95
-
96
- /**
97
- * The text value of the Yjs.Text object.
98
- * Shortcut for `yjsText.toString()`, but computed.
99
- */
100
- @computed
101
- get text(): string {
102
- this.yjsTextChangedAtom.reportObserved()
103
- return this.yjsText.toString()
104
- }
105
-
106
- protected onInit() {
107
- const shouldReplicateToYjs = (ctx: YjsBindingContext | undefined): ctx is YjsBindingContext => {
108
- return !!ctx && !!ctx.boundObject && !ctx.isApplyingYjsChangesToMobxKeystone
109
- }
110
-
111
- let reapplyDeltasToYjsText = false
112
- const newDeltas: Frozen<unknown[]>[] = []
113
-
114
- let disposeObserveDeltaList: (() => void) | undefined
115
-
116
- const disposeReactionToDeltaListRefChange = reaction(
117
- () => this.$.deltaList,
118
- (deltaList) => {
119
- disposeObserveDeltaList?.()
120
- disposeObserveDeltaList = undefined
121
-
122
- disposeObserveDeltaList = observe(deltaList, (change) => {
123
- if (reapplyDeltasToYjsText) {
124
- // already gonna replace them all
125
- return
126
- }
127
- if (!shouldReplicateToYjs(yjsBindingContext.get(this))) {
128
- // yjs text is already up to date with these changes
129
- return
130
- }
131
-
132
- if (
133
- change.type === "splice" &&
134
- change.removedCount === 0 &&
135
- change.addedCount > 0 &&
136
- change.index === this.deltaList.length
137
- ) {
138
- // optimization, just adding new ones to the end
139
- newDeltas.push(...change.added)
140
- } else {
141
- // any other change, we need to reapply all deltas
142
- reapplyDeltasToYjsText = true
143
- }
144
- })
145
- },
146
- { fireImmediately: true }
147
- )
148
-
149
- const disposeOnSnapshot = onSnapshot(this, () => {
150
- try {
151
- if (reapplyDeltasToYjsText) {
152
- const ctx = yjsBindingContext.get(this)
153
-
154
- if (shouldReplicateToYjs(ctx)) {
155
- const { yjsText } = this
156
-
157
- ctx.yjsDoc.transact(() => {
158
- // didn't find a better way than this to reapply all deltas
159
- // without having to re-create the Y.Text object
160
- if (yjsText.length > 0) {
161
- yjsText.delete(0, yjsText.length)
162
- }
163
-
164
- this.deltaList.forEach((frozenDeltas) => {
165
- yjsText.applyDelta(frozenDeltas.data)
166
- })
167
- }, ctx.yjsOrigin)
168
- }
169
- } else if (newDeltas.length > 0) {
170
- const ctx = yjsBindingContext.get(this)
171
-
172
- if (shouldReplicateToYjs(ctx)) {
173
- const { yjsText } = this
174
-
175
- ctx.yjsDoc.transact(() => {
176
- newDeltas.forEach((frozenDeltas) => {
177
- yjsText.applyDelta(frozenDeltas.data)
178
- })
179
- }, ctx.yjsOrigin)
180
- }
181
- }
182
- } finally {
183
- reapplyDeltasToYjsText = false
184
- newDeltas.length = 0
185
- }
186
- })
187
-
188
- const diposeYjsTextChangedAtom = hookYjsTextChangedAtom(
189
- () => this.yjsText,
190
- this.yjsTextChangedAtom
191
- )
192
-
193
- return () => {
194
- disposeOnSnapshot()
195
- disposeReactionToDeltaListRefChange()
196
- disposeObserveDeltaList?.()
197
- disposeObserveDeltaList = undefined
198
-
199
- diposeYjsTextChangedAtom()
200
- }
201
- }
202
- }
203
-
204
- // we use this trick just to avoid a babel bug that causes classes used inside classes not to be overriden
205
- // by the decorator
206
- const DecoratedYjsTextModel = YjsTextModel
207
-
208
- function hookYjsTextChangedAtom(getYjsText: () => Y.Text, textChangedAtom: IAtom) {
209
- let disposeObserveYjsText: (() => void) | undefined
210
-
211
- const observeFn = () => {
212
- textChangedAtom.reportChanged()
213
- }
214
-
215
- const disposeReactionToYTextChange = reaction(
216
- () => {
217
- try {
218
- return getYjsText()
219
- } catch {
220
- return undefined
221
- }
222
- },
223
- (yjsText) => {
224
- disposeObserveYjsText?.()
225
- disposeObserveYjsText = undefined
226
-
227
- if (yjsText) {
228
- yjsText.observe(observeFn)
229
-
230
- disposeObserveYjsText = () => {
231
- yjsText.unobserve(observeFn)
232
- }
233
- }
234
-
235
- textChangedAtom.reportChanged()
236
- },
237
- {
238
- fireImmediately: true,
239
- }
240
- )
241
-
242
- return () => {
243
- disposeReactionToYTextChange()
244
- disposeObserveYjsText?.()
245
- disposeObserveYjsText = undefined
246
- }
247
- }
1
+ import { computed, createAtom, IAtom, observe, reaction } from "mobx"
2
+ import {
3
+ Frozen,
4
+ frozen,
5
+ getParentToChildPath,
6
+ Model,
7
+ model,
8
+ onSnapshot,
9
+ tProp,
10
+ types,
11
+ } from "mobx-keystone"
12
+ import * as Y from "yjs"
13
+ import { failure } from "../utils/error"
14
+ import { isYjsValueDeleted } from "../utils/isYjsValueDeleted"
15
+ import { resolveYjsPath } from "./resolveYjsPath"
16
+ import { YjsBindingContext, yjsBindingContext } from "./yjsBindingContext"
17
+
18
+ // Delta[][], since each single change is a Delta[]
19
+ // we use frozen so that we can reuse each delta change
20
+ const deltaListType = types.array(types.frozen(types.unchecked<unknown[]>()))
21
+
22
+ export const yjsTextModelId = "mobx-keystone-yjs/YjsTextModel"
23
+
24
+ /**
25
+ * A mobx-keystone model that represents a Yjs.Text object.
26
+ */
27
+ @model(yjsTextModelId)
28
+ export class YjsTextModel extends Model({
29
+ deltaList: tProp(deltaListType, () => []),
30
+ }) {
31
+ /**
32
+ * Helper function to create a YjsTextModel instance with a simple text.
33
+ */
34
+ static withText(text: string): YjsTextModel {
35
+ return new DecoratedYjsTextModel({
36
+ deltaList: [
37
+ frozen([
38
+ {
39
+ insert: text,
40
+ },
41
+ ]),
42
+ ],
43
+ })
44
+ }
45
+
46
+ /**
47
+ * The Y.js path from the bound object to the YjsTextModel instance.
48
+ */
49
+ @computed
50
+ private get _yjsObjectPath() {
51
+ const ctx = yjsBindingContext.get(this)
52
+ if (ctx?.boundObject == null) {
53
+ throw failure(
54
+ "the YjsTextModel instance must be part of a bound object before it can be accessed"
55
+ )
56
+ }
57
+
58
+ const path = getParentToChildPath(ctx.boundObject, this)
59
+ if (!path) {
60
+ throw failure("a path from the bound object to the YjsTextModel instance is not available")
61
+ }
62
+
63
+ return path
64
+ }
65
+
66
+ /**
67
+ * The Yjs.Text object present at this mobx-keystone node's path.
68
+ */
69
+ @computed
70
+ private get _yjsObjectAtPath(): unknown {
71
+ const path = this._yjsObjectPath
72
+
73
+ const ctx = yjsBindingContext.get(this)!
74
+
75
+ return resolveYjsPath(ctx.yjsObject, path)
76
+ }
77
+
78
+ /**
79
+ * The Yjs.Text object represented by this mobx-keystone node.
80
+ */
81
+ @computed
82
+ get yjsText(): Y.Text {
83
+ const yjsObject = this._yjsObjectAtPath
84
+
85
+ if (!(yjsObject instanceof Y.Text)) {
86
+ throw failure(`Y.Text was expected at path ${JSON.stringify(this._yjsObjectPath)}`)
87
+ }
88
+
89
+ return yjsObject
90
+ }
91
+
92
+ /**
93
+ * Atom that gets changed when the associated Y.js text changes.
94
+ */
95
+ yjsTextChangedAtom = createAtom("yjsTextChangedAtom")
96
+
97
+ /**
98
+ * The text value of the Yjs.Text object.
99
+ * Shortcut for `yjsText.toString()`, but computed.
100
+ */
101
+ @computed
102
+ get text(): string {
103
+ this.yjsTextChangedAtom.reportObserved()
104
+
105
+ const ctx = yjsBindingContext.get(this)
106
+ if (ctx?.boundObject != null) {
107
+ try {
108
+ const yjsTextString = this.yjsText.toString()
109
+ // if the yjsText is detached, toString() returns an empty string
110
+ // in that case we should use the deltaList as a fallback
111
+ if (yjsTextString !== "" || this.deltaList.length === 0) {
112
+ return yjsTextString
113
+ }
114
+ } catch {
115
+ // fall back
116
+ }
117
+ }
118
+
119
+ // fall back to deltaList
120
+ return this.deltaListToText()
121
+ }
122
+
123
+ private deltaListToText(): string {
124
+ const doc = new Y.Doc()
125
+ const text = doc.getText()
126
+ this.deltaList.forEach((d) => {
127
+ text.applyDelta(d.data)
128
+ })
129
+ return text.toString()
130
+ }
131
+
132
+ protected onInit() {
133
+ const shouldReplicateToYjs = (ctx: YjsBindingContext | undefined): ctx is YjsBindingContext => {
134
+ return !!ctx && !!ctx.boundObject && !ctx.isApplyingYjsChangesToMobxKeystone
135
+ }
136
+
137
+ let reapplyDeltasToYjsText = false
138
+ const newDeltas: Frozen<unknown[]>[] = []
139
+
140
+ let disposeObserveDeltaList: (() => void) | undefined
141
+
142
+ const disposeReactionToDeltaListRefChange = reaction(
143
+ () => this.$.deltaList,
144
+ (deltaList) => {
145
+ disposeObserveDeltaList?.()
146
+ disposeObserveDeltaList = undefined
147
+
148
+ disposeObserveDeltaList = observe(deltaList, (change) => {
149
+ if (reapplyDeltasToYjsText) {
150
+ // already gonna replace them all
151
+ return
152
+ }
153
+ if (!shouldReplicateToYjs(yjsBindingContext.get(this))) {
154
+ // yjs text is already up to date with these changes
155
+ return
156
+ }
157
+
158
+ if (
159
+ change.type === "splice" &&
160
+ change.removedCount === 0 &&
161
+ change.addedCount > 0 &&
162
+ change.index === this.deltaList.length
163
+ ) {
164
+ // optimization, just adding new ones to the end
165
+ newDeltas.push(...change.added)
166
+ } else {
167
+ // any other change, we need to reapply all deltas
168
+ reapplyDeltasToYjsText = true
169
+ }
170
+ })
171
+ },
172
+ { fireImmediately: true }
173
+ )
174
+
175
+ const disposeOnSnapshot = onSnapshot(this, () => {
176
+ try {
177
+ if (reapplyDeltasToYjsText) {
178
+ const ctx = yjsBindingContext.get(this)
179
+
180
+ if (shouldReplicateToYjs(ctx)) {
181
+ const { yjsText } = this
182
+ if (isYjsValueDeleted(yjsText)) {
183
+ throw failure("cannot reapply deltas to deleted Yjs.Text")
184
+ }
185
+
186
+ ctx.yjsDoc.transact(() => {
187
+ // didn't find a better way than this to reapply all deltas
188
+ // without having to re-create the Y.Text object
189
+ if (yjsText.length > 0) {
190
+ yjsText.delete(0, yjsText.length)
191
+ }
192
+
193
+ this.deltaList.forEach((frozenDeltas) => {
194
+ yjsText.applyDelta(frozenDeltas.data)
195
+ })
196
+ }, ctx.yjsOrigin)
197
+ }
198
+ } else if (newDeltas.length > 0) {
199
+ const ctx = yjsBindingContext.get(this)
200
+
201
+ if (shouldReplicateToYjs(ctx)) {
202
+ const { yjsText } = this
203
+ if (isYjsValueDeleted(yjsText)) {
204
+ throw failure("cannot reapply deltas to deleted Yjs.Text")
205
+ }
206
+
207
+ ctx.yjsDoc.transact(() => {
208
+ newDeltas.forEach((frozenDeltas) => {
209
+ yjsText.applyDelta(frozenDeltas.data)
210
+ })
211
+ }, ctx.yjsOrigin)
212
+ }
213
+ }
214
+ } finally {
215
+ reapplyDeltasToYjsText = false
216
+ newDeltas.length = 0
217
+ }
218
+ })
219
+
220
+ const diposeYjsTextChangedAtom = hookYjsTextChangedAtom(
221
+ () => this.yjsText,
222
+ this.yjsTextChangedAtom
223
+ )
224
+
225
+ return () => {
226
+ disposeOnSnapshot()
227
+ disposeReactionToDeltaListRefChange()
228
+ disposeObserveDeltaList?.()
229
+ disposeObserveDeltaList = undefined
230
+
231
+ diposeYjsTextChangedAtom()
232
+ }
233
+ }
234
+ }
235
+
236
+ // we use this trick just to avoid a babel bug that causes classes used inside classes not to be overriden
237
+ // by the decorator
238
+ const DecoratedYjsTextModel = YjsTextModel
239
+
240
+ function hookYjsTextChangedAtom(getYjsText: () => Y.Text, textChangedAtom: IAtom) {
241
+ let disposeObserveYjsText: (() => void) | undefined
242
+
243
+ const observeFn = () => {
244
+ textChangedAtom.reportChanged()
245
+ }
246
+
247
+ const disposeReactionToYTextChange = reaction(
248
+ () => {
249
+ try {
250
+ const yjsText = getYjsText()
251
+ return isYjsValueDeleted(yjsText) ? undefined : yjsText
252
+ } catch {
253
+ return undefined
254
+ }
255
+ },
256
+ (yjsText) => {
257
+ disposeObserveYjsText?.()
258
+ disposeObserveYjsText = undefined
259
+
260
+ if (yjsText) {
261
+ yjsText.observe(observeFn)
262
+
263
+ disposeObserveYjsText = () => {
264
+ yjsText.unobserve(observeFn)
265
+ }
266
+ }
267
+
268
+ textChangedAtom.reportChanged()
269
+ },
270
+ {
271
+ fireImmediately: true,
272
+ }
273
+ )
274
+
275
+ return () => {
276
+ disposeReactionToYTextChange()
277
+ disposeObserveYjsText?.()
278
+ disposeObserveYjsText = undefined
279
+ }
280
+ }
@@ -0,0 +1,77 @@
1
+ import { DeepChange, DeepChangeType } from "mobx-keystone"
2
+ import * as Y from "yjs"
3
+ import { failure } from "../utils/error"
4
+ import { isYjsValueDeleted } from "../utils/isYjsValueDeleted"
5
+ import { convertJsonToYjsData } from "./convertJsonToYjsData"
6
+ import { resolveYjsPath } from "./resolveYjsPath"
7
+
8
+ /**
9
+ * Converts a snapshot value to a Yjs-compatible value.
10
+ * Note: All values passed here are already snapshots (captured at change time).
11
+ */
12
+ function convertValue(v: unknown): any {
13
+ // Handle primitives directly
14
+ if (v === null || v === undefined || typeof v !== "object") {
15
+ return v
16
+ }
17
+ // Handle plain arrays - used for empty array init
18
+ if (Array.isArray(v) && v.length === 0) {
19
+ return new Y.Array()
20
+ }
21
+ // Value is already a snapshot, convert to Yjs data
22
+ return convertJsonToYjsData(v as any)
23
+ }
24
+
25
+ export function applyMobxChangeToYjsObject(
26
+ change: DeepChange,
27
+ yjsObject: Y.Map<any> | Y.Array<any> | Y.Text
28
+ ): void {
29
+ // Check if the YJS object is deleted
30
+ if (isYjsValueDeleted(yjsObject)) {
31
+ throw failure("cannot apply patch to deleted Yjs value")
32
+ }
33
+
34
+ const yjsContainer = resolveYjsPath(yjsObject, change.path)
35
+
36
+ if (!yjsContainer) {
37
+ // Container not found, skip this change
38
+ return
39
+ }
40
+
41
+ if (yjsContainer instanceof Y.Array) {
42
+ if (change.type === DeepChangeType.ArraySplice) {
43
+ // splice
44
+ yjsContainer.delete(change.index, change.removedValues.length)
45
+ if (change.addedValues.length > 0) {
46
+ const valuesToInsert = change.addedValues.map(convertValue)
47
+ yjsContainer.insert(change.index, valuesToInsert)
48
+ }
49
+ } else if (change.type === DeepChangeType.ArrayUpdate) {
50
+ // update
51
+ yjsContainer.delete(change.index, 1)
52
+ yjsContainer.insert(change.index, [convertValue(change.newValue)])
53
+ } else {
54
+ throw failure(`unsupported array change type: ${change.type}`)
55
+ }
56
+ } else if (yjsContainer instanceof Y.Map) {
57
+ if (change.type === DeepChangeType.ObjectAdd || change.type === DeepChangeType.ObjectUpdate) {
58
+ const key = change.key
59
+ if (change.newValue === undefined) {
60
+ yjsContainer.delete(key)
61
+ } else {
62
+ yjsContainer.set(key, convertValue(change.newValue))
63
+ }
64
+ } else if (change.type === DeepChangeType.ObjectRemove) {
65
+ const key = change.key
66
+ yjsContainer.delete(key)
67
+ } else {
68
+ throw failure(`unsupported object change type: ${change.type}`)
69
+ }
70
+ } else if (yjsContainer instanceof Y.Text) {
71
+ // Y.Text is handled differently - init changes for text are managed by YjsTextModel
72
+ // Skip init changes for Y.Text containers
73
+ return
74
+ } else {
75
+ throw failure(`unsupported Yjs container type: ${yjsContainer}`)
76
+ }
77
+ }