mobx-keystone-yjs
Version:
Yjs bindings for mobx-keystone
193 lines (170 loc) • 5.27 kB
text/typescript
import { action } from "mobx"
import {
AnyDataModel,
AnyModel,
AnyStandardType,
ModelClass,
Patch,
SnapshotInOf,
TypeToData,
applyPatches,
fromSnapshot,
getParentToChildPath,
onGlobalPatches,
onPatches,
onSnapshot,
} from "mobx-keystone"
import * as Y from "yjs"
import { getYjsCollectionAtom } from "../utils/getOrCreateYjsCollectionAtom"
import { applyMobxKeystonePatchToYjsObject } from "./applyMobxKeystonePatchToYjsObject"
import { convertYjsDataToJson } from "./convertYjsDataToJson"
import { convertYjsEventToPatches } from "./convertYjsEventToPatches"
import { YjsBindingContext, yjsBindingContext } from "./yjsBindingContext"
/**
* Creates a bidirectional binding between a Y.js data structure and a mobx-keystone model.
*/
export function bindYjsToMobxKeystone<
TType extends AnyStandardType | ModelClass<AnyModel> | ModelClass<AnyDataModel>,
>({
yjsDoc,
yjsObject,
mobxKeystoneType,
}: {
/**
* The Y.js document.
*/
yjsDoc: Y.Doc
/**
* The bound Y.js data structure.
*/
yjsObject: Y.Map<any> | Y.Array<any> | Y.Text
/**
* The mobx-keystone model type.
*/
mobxKeystoneType: TType
}): {
/**
* The bound mobx-keystone instance.
*/
boundObject: TypeToData<TType>
/**
* Disposes the binding.
*/
dispose: () => void
/**
* The Y.js origin symbol used for binding transactions.
*/
yjsOrigin: symbol
} {
const yjsOrigin = Symbol("bindYjsToMobxKeystoneTransactionOrigin")
let applyingYjsChangesToMobxKeystone = 0
const bindingContext: YjsBindingContext = {
yjsDoc,
yjsObject,
mobxKeystoneType,
yjsOrigin,
boundObject: undefined, // not yet created
get isApplyingYjsChangesToMobxKeystone() {
return applyingYjsChangesToMobxKeystone > 0
},
}
const yjsJson = convertYjsDataToJson(yjsObject)
const initializationGlobalPatches: { target: object; patches: Patch[] }[] = []
const createBoundObject = () => {
const disposeOnGlobalPatches = onGlobalPatches((target, patches) => {
initializationGlobalPatches.push({ target, patches })
})
try {
const boundObject = yjsBindingContext.apply(
() => fromSnapshot(mobxKeystoneType, yjsJson as unknown as SnapshotInOf<TypeToData<TType>>),
bindingContext
)
yjsBindingContext.set(boundObject, { ...bindingContext, boundObject })
return boundObject
} finally {
disposeOnGlobalPatches()
}
}
const boundObject = createBoundObject()
// bind any changes from yjs to mobx-keystone
const observeDeepCb = action((events: Y.YEvent<any>[]) => {
const patches: Patch[] = []
events.forEach((event) => {
if (event.transaction.origin !== yjsOrigin) {
patches.push(...convertYjsEventToPatches(event))
}
if (event.target instanceof Y.Map || event.target instanceof Y.Array) {
getYjsCollectionAtom(event.target)?.reportChanged()
}
})
if (patches.length > 0) {
applyingYjsChangesToMobxKeystone++
try {
applyPatches(boundObject, patches)
} finally {
applyingYjsChangesToMobxKeystone--
}
}
})
yjsObject.observeDeep(observeDeepCb)
// bind any changes from mobx-keystone to yjs
let pendingArrayOfArrayOfPatches: Patch[][] = []
const disposeOnPatches = onPatches(boundObject, (patches) => {
if (applyingYjsChangesToMobxKeystone > 0) {
return
}
pendingArrayOfArrayOfPatches.push(patches)
})
// this is only used so we can transact all patches to the snapshot boundary
const disposeOnSnapshot = onSnapshot(boundObject, () => {
if (pendingArrayOfArrayOfPatches.length === 0) {
return
}
const arrayOfArrayOfPatches = pendingArrayOfArrayOfPatches
pendingArrayOfArrayOfPatches = []
yjsDoc.transact(() => {
arrayOfArrayOfPatches.forEach((arrayOfPatches) => {
arrayOfPatches.forEach((patch) => {
applyMobxKeystonePatchToYjsObject(patch, yjsObject)
})
})
}, yjsOrigin)
})
// sync initial patches, that might include setting defaults, IDs, etc
yjsDoc.transact(() => {
// we need to skip initializations until we hit the initialization of the bound object
// this is because default objects might be created and initialized before the main object
// but we just need to catch when those are actually assigned to the bound object
let boundObjectFound = false
initializationGlobalPatches.forEach(({ target, patches }) => {
if (!boundObjectFound) {
if (target !== boundObject) {
return // skip
}
boundObjectFound = true
}
const parentToChildPath = getParentToChildPath(boundObject, target)
// this is undefined only if target is not a child of boundModel
if (parentToChildPath !== undefined) {
patches.forEach((patch) => {
applyMobxKeystonePatchToYjsObject(
{
...patch,
path: [...parentToChildPath, ...patch.path],
},
yjsObject
)
})
}
})
}, yjsOrigin)
return {
boundObject,
dispose: () => {
disposeOnPatches()
disposeOnSnapshot()
yjsObject.unobserveDeep(observeDeepCb)
},
yjsOrigin,
}
}