kopia lustrzana https://github.com/Tldraw/Tldraw
266 wiersze
7.2 KiB
TypeScript
266 wiersze
7.2 KiB
TypeScript
import { RecordsDiff, UnknownRecord } from '@tldraw/store'
|
|
import { objectMapEntries, objectMapValues } from '@tldraw/utils'
|
|
import isEqual from 'lodash.isequal'
|
|
|
|
/** @public */
|
|
export const RecordOpType = {
|
|
Put: 'put',
|
|
Patch: 'patch',
|
|
Remove: 'remove',
|
|
} as const
|
|
|
|
/** @public */
|
|
export type RecordOpType = (typeof RecordOpType)[keyof typeof RecordOpType]
|
|
|
|
/** @public */
|
|
export type RecordOp<R extends UnknownRecord> =
|
|
| [typeof RecordOpType.Put, R]
|
|
| [typeof RecordOpType.Patch, ObjectDiff]
|
|
| [typeof RecordOpType.Remove]
|
|
|
|
/**
|
|
* A one-way (non-reversible) diff designed for small json footprint. These are mainly intended to
|
|
* be sent over the wire. Either as push requests from the client to the server, or as patch
|
|
* operations in the opposite direction.
|
|
*
|
|
* Each key in this object is the id of a record that has been added, updated, or removed.
|
|
*
|
|
* @public
|
|
*/
|
|
export type NetworkDiff<R extends UnknownRecord> = {
|
|
[id: string]: RecordOp<R>
|
|
}
|
|
|
|
/**
|
|
* Converts a (reversible, verbose) RecordsDiff into a (non-reversible, concise) NetworkDiff
|
|
*
|
|
* @public
|
|
*/
|
|
export const getNetworkDiff = <R extends UnknownRecord>(
|
|
diff: RecordsDiff<R>
|
|
): NetworkDiff<R> | null => {
|
|
let res: NetworkDiff<R> | null = null
|
|
|
|
for (const [k, v] of objectMapEntries(diff.added)) {
|
|
if (!res) res = {}
|
|
res[k] = [RecordOpType.Put, v]
|
|
}
|
|
|
|
for (const [from, to] of objectMapValues(diff.updated)) {
|
|
const diff = diffRecord(from, to)
|
|
if (diff) {
|
|
if (!res) res = {}
|
|
res[to.id] = [RecordOpType.Patch, diff]
|
|
}
|
|
}
|
|
|
|
for (const removed of Object.keys(diff.removed)) {
|
|
if (!res) res = {}
|
|
res[removed] = [RecordOpType.Remove]
|
|
}
|
|
|
|
return res
|
|
}
|
|
|
|
/** @public */
|
|
export const ValueOpType = {
|
|
Put: 'put',
|
|
Delete: 'delete',
|
|
Append: 'append',
|
|
Patch: 'patch',
|
|
} as const
|
|
export type ValueOpType = (typeof ValueOpType)[keyof typeof ValueOpType]
|
|
|
|
/** @public */
|
|
export type PutOp = [type: typeof ValueOpType.Put, value: unknown]
|
|
/** @public */
|
|
export type AppendOp = [type: typeof ValueOpType.Append, values: unknown[], offset: number]
|
|
/** @public */
|
|
export type PatchOp = [type: typeof ValueOpType.Patch, diff: ObjectDiff]
|
|
/** @public */
|
|
export type DeleteOp = [type: typeof ValueOpType.Delete]
|
|
|
|
/** @public */
|
|
export type ValueOp = PutOp | AppendOp | PatchOp | DeleteOp
|
|
|
|
/** @public */
|
|
export type ObjectDiff = {
|
|
[k: string]: ValueOp
|
|
}
|
|
|
|
/** @public */
|
|
export function diffRecord(prev: object, next: object): ObjectDiff | null {
|
|
return diffObject(prev, next, new Set(['props']))
|
|
}
|
|
|
|
function diffObject(prev: object, next: object, nestedKeys?: Set<string>): ObjectDiff | null {
|
|
if (prev === next) {
|
|
return null
|
|
}
|
|
let result: ObjectDiff | null = null
|
|
for (const key of Object.keys(prev)) {
|
|
// if key is not in next then it was deleted
|
|
if (!(key in next)) {
|
|
if (!result) result = {}
|
|
result[key] = [ValueOpType.Delete]
|
|
continue
|
|
}
|
|
// if key is in both places, then compare values
|
|
const prevVal = (prev as any)[key]
|
|
const nextVal = (next as any)[key]
|
|
if (!isEqual(prevVal, nextVal)) {
|
|
if (nestedKeys?.has(key) && prevVal && nextVal) {
|
|
const diff = diffObject(prevVal, nextVal)
|
|
if (diff) {
|
|
if (!result) result = {}
|
|
result[key] = [ValueOpType.Patch, diff]
|
|
}
|
|
} else if (Array.isArray(nextVal) && Array.isArray(prevVal)) {
|
|
const op = diffArray(prevVal, nextVal)
|
|
if (op) {
|
|
if (!result) result = {}
|
|
result[key] = op
|
|
}
|
|
} else {
|
|
if (!result) result = {}
|
|
result[key] = [ValueOpType.Put, nextVal]
|
|
}
|
|
}
|
|
}
|
|
for (const key of Object.keys(next)) {
|
|
// if key is in next but not in prev then it was added
|
|
if (!(key in prev)) {
|
|
if (!result) result = {}
|
|
result[key] = [ValueOpType.Put, (next as any)[key]]
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
function diffValue(valueA: unknown, valueB: unknown): ValueOp | null {
|
|
if (Object.is(valueA, valueB)) return null
|
|
if (Array.isArray(valueA) && Array.isArray(valueB)) {
|
|
return diffArray(valueA, valueB)
|
|
} else if (!valueA || !valueB || typeof valueA !== 'object' || typeof valueB !== 'object') {
|
|
return isEqual(valueA, valueB) ? null : [ValueOpType.Put, valueB]
|
|
} else {
|
|
const diff = diffObject(valueA, valueB)
|
|
return diff ? [ValueOpType.Patch, diff] : null
|
|
}
|
|
}
|
|
|
|
function diffArray(prevArray: unknown[], nextArray: unknown[]): PutOp | AppendOp | PatchOp | null {
|
|
if (Object.is(prevArray, nextArray)) return null
|
|
// if lengths are equal, check for patch operation
|
|
if (prevArray.length === nextArray.length) {
|
|
// bail out if more than len/5 items need patching
|
|
const maxPatchIndexes = Math.max(prevArray.length / 5, 1)
|
|
const toPatchIndexes = []
|
|
for (let i = 0; i < prevArray.length; i++) {
|
|
if (!isEqual(prevArray[i], nextArray[i])) {
|
|
toPatchIndexes.push(i)
|
|
if (toPatchIndexes.length > maxPatchIndexes) {
|
|
return [ValueOpType.Put, nextArray]
|
|
}
|
|
}
|
|
}
|
|
if (toPatchIndexes.length === 0) {
|
|
// same length and no items changed, so no diff
|
|
return null
|
|
}
|
|
const diff: ObjectDiff = {}
|
|
for (const i of toPatchIndexes) {
|
|
const prevItem = prevArray[i]
|
|
const nextItem = nextArray[i]
|
|
if (!prevItem || !nextItem) {
|
|
diff[i] = [ValueOpType.Put, nextItem]
|
|
} else if (typeof prevItem === 'object' && typeof nextItem === 'object') {
|
|
const op = diffValue(prevItem, nextItem)
|
|
if (op) {
|
|
diff[i] = op
|
|
}
|
|
} else {
|
|
diff[i] = [ValueOpType.Put, nextItem]
|
|
}
|
|
}
|
|
return [ValueOpType.Patch, diff]
|
|
}
|
|
|
|
// if lengths are not equal, check for append operation, and bail out
|
|
// to replace whole array if any shared elems changed
|
|
for (let i = 0; i < prevArray.length; i++) {
|
|
if (!isEqual(prevArray[i], nextArray[i])) {
|
|
return [ValueOpType.Put, nextArray]
|
|
}
|
|
}
|
|
|
|
return [ValueOpType.Append, nextArray.slice(prevArray.length), prevArray.length]
|
|
}
|
|
|
|
/** @public */
|
|
export function applyObjectDiff<T extends object>(object: T, objectDiff: ObjectDiff): T {
|
|
// don't patch nulls
|
|
if (!object || typeof object !== 'object') return object
|
|
const isArray = Array.isArray(object)
|
|
let newObject: any | undefined = undefined
|
|
const set = (k: any, v: any) => {
|
|
if (!newObject) {
|
|
if (isArray) {
|
|
newObject = [...object]
|
|
} else {
|
|
newObject = { ...object }
|
|
}
|
|
}
|
|
if (isArray) {
|
|
newObject[Number(k)] = v
|
|
} else {
|
|
newObject[k] = v
|
|
}
|
|
}
|
|
for (const [key, op] of Object.entries(objectDiff)) {
|
|
switch (op[0]) {
|
|
case ValueOpType.Put: {
|
|
const value = op[1]
|
|
if (!isEqual(object[key as keyof T], value)) {
|
|
set(key, value)
|
|
}
|
|
break
|
|
}
|
|
case ValueOpType.Append: {
|
|
const values = op[1]
|
|
const offset = op[2]
|
|
const arr = object[key as keyof T]
|
|
if (Array.isArray(arr) && arr.length === offset) {
|
|
set(key, [...arr, ...values])
|
|
}
|
|
break
|
|
}
|
|
case ValueOpType.Patch: {
|
|
if (object[key as keyof T] && typeof object[key as keyof T] === 'object') {
|
|
const diff = op[1]
|
|
const patched = applyObjectDiff(object[key as keyof T] as object, diff)
|
|
if (patched !== object[key as keyof T]) {
|
|
set(key, patched)
|
|
}
|
|
}
|
|
break
|
|
}
|
|
case ValueOpType.Delete: {
|
|
if (key in object) {
|
|
if (!newObject) {
|
|
if (isArray) {
|
|
console.error("Can't delete array item yet (this should never happen)")
|
|
newObject = [...object]
|
|
} else {
|
|
newObject = { ...object }
|
|
}
|
|
}
|
|
delete newObject[key]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return newObject ?? object
|
|
}
|