Prevent wobble during viewport following (#3695)
This revives the old 'derived camera' idea to prevent cursor wobbling during viewport following. Before this PR we updated the camera on a tick during viewport following, but the shapes and cursors were not moving on the same tick (we tried that during the perf work and it was all kinds of problematic). Frankly I've forgotten how we ever managed to eliminate wobble here in the first place? Anyway after this PR we derive the camera based on whether or not we are following a user. When you follow a user it makes it so that your viewport contains their viewport. If your viewport is not already very close to their viewport it will animate the initial position, after which it will 'lock' in place and the derived value will be used from then on. This exposed a minor issue in our sync engine: the fact that we send presence updates in separate websocket messages from document updates. We get into situations like this 1. user A follows user B 2. user B deletes the current page they are on 3. user B's page deletion diff gets sent 4. user B's presence update gets sent with a new currentPageId 5. user A receives the page deletion 6. user A still thinks that user B is on the old page and doesn't know how to update the follow state. So to fix this I made it so that we can (and do) send presence updates in the same websocket messages as document updates so the server can handle them atomically. ### Change Type <!-- ❗ Please select a 'Scope' label ❗️ --> - [x] `sdk` — Changes the tldraw SDK - [x] `bugfix` — Bug fix ### Test Plan 1. Add a step-by-step description of how to test your PR here. 8. - [ ] Unit Tests - [ ] End to end tests ### Release Notes - Fixes a bug that caused the cursor & shapes to wiggle around when following someone else's viewport
This commit is contained in:
parent
e559a7cdbb
commit
48512995b4
5 changed files with 278 additions and 202 deletions
|
@ -20,15 +20,8 @@ export const DEFAULT_CAMERA_OPTIONS: TLCameraOptions = {
|
|||
zoomSteps: [0.1, 0.25, 0.5, 1, 2, 4, 8],
|
||||
}
|
||||
|
||||
export const FOLLOW_CHASE_PROPORTION = 0.5
|
||||
/** @internal */
|
||||
export const FOLLOW_CHASE_PAN_SNAP = 0.1
|
||||
/** @internal */
|
||||
export const FOLLOW_CHASE_PAN_UNSNAP = 0.2
|
||||
/** @internal */
|
||||
export const FOLLOW_CHASE_ZOOM_SNAP = 0.005
|
||||
/** @internal */
|
||||
export const FOLLOW_CHASE_ZOOM_UNSNAP = 0.05
|
||||
export const FOLLOW_CHASE_VIEWPORT_SNAP = 2
|
||||
|
||||
/** @internal */
|
||||
export const DOUBLE_CLICK_DURATION = 450
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { EMPTY_ARRAY, atom, computed, transact } from '@tldraw/state'
|
||||
import { EMPTY_ARRAY, atom, computed, react, transact, unsafe__withoutCapture } from '@tldraw/state'
|
||||
import {
|
||||
ComputedCache,
|
||||
RecordType,
|
||||
|
@ -20,6 +20,7 @@ import {
|
|||
TLBinding,
|
||||
TLBindingId,
|
||||
TLBindingPartial,
|
||||
TLCamera,
|
||||
TLCursor,
|
||||
TLCursorType,
|
||||
TLDOCUMENT_ID,
|
||||
|
@ -69,6 +70,7 @@ import {
|
|||
getOwnProperty,
|
||||
hasOwnProperty,
|
||||
last,
|
||||
lerp,
|
||||
sortById,
|
||||
sortByIndex,
|
||||
structuredClone,
|
||||
|
@ -88,11 +90,7 @@ import {
|
|||
DEFAULT_ANIMATION_OPTIONS,
|
||||
DEFAULT_CAMERA_OPTIONS,
|
||||
DRAG_DISTANCE,
|
||||
FOLLOW_CHASE_PAN_SNAP,
|
||||
FOLLOW_CHASE_PAN_UNSNAP,
|
||||
FOLLOW_CHASE_PROPORTION,
|
||||
FOLLOW_CHASE_ZOOM_SNAP,
|
||||
FOLLOW_CHASE_ZOOM_UNSNAP,
|
||||
FOLLOW_CHASE_VIEWPORT_SNAP,
|
||||
HIT_TEST_MARGIN,
|
||||
INTERNAL_POINTER_IDS,
|
||||
LEFT_MOUSE_BUTTON,
|
||||
|
@ -2036,8 +2034,55 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
*
|
||||
* @public
|
||||
*/
|
||||
@computed getCamera() {
|
||||
return this.store.get(this.getCameraId())!
|
||||
@computed getCamera(): TLCamera {
|
||||
const baseCamera = this.store.get(this.getCameraId())!
|
||||
if (this._isLockedOnFollowingUser.get()) {
|
||||
const followingCamera = this.getCameraForFollowing()
|
||||
if (followingCamera) {
|
||||
return { ...baseCamera, ...followingCamera }
|
||||
}
|
||||
}
|
||||
return baseCamera
|
||||
}
|
||||
|
||||
@computed
|
||||
private getViewportPageBoundsForFollowing(): null | Box {
|
||||
const followingUserId = this.getInstanceState().followingUserId
|
||||
if (!followingUserId) return null
|
||||
const leaderPresence = this.getCollaborators().find((c) => c.userId === followingUserId)
|
||||
if (!leaderPresence) return null
|
||||
|
||||
// Fit their viewport inside of our screen bounds
|
||||
// 1. calculate their viewport in page space
|
||||
const { w: lw, h: lh } = leaderPresence.screenBounds
|
||||
const { x: lx, y: ly, z: lz } = leaderPresence.camera
|
||||
const theirViewport = new Box(-lx, -ly, lw / lz, lh / lz)
|
||||
|
||||
// resize our screenBounds to contain their viewport
|
||||
const ourViewport = this.getViewportScreenBounds().clone()
|
||||
const ourAspectRatio = ourViewport.width / ourViewport.height
|
||||
|
||||
ourViewport.width = theirViewport.width
|
||||
ourViewport.height = ourViewport.width / ourAspectRatio
|
||||
if (ourViewport.height < theirViewport.height) {
|
||||
ourViewport.height = theirViewport.height
|
||||
ourViewport.width = ourViewport.height * ourAspectRatio
|
||||
}
|
||||
|
||||
ourViewport.center = theirViewport.center
|
||||
return ourViewport
|
||||
}
|
||||
|
||||
@computed
|
||||
private getCameraForFollowing(): null | { x: number; y: number; z: number } {
|
||||
const viewport = this.getViewportPageBoundsForFollowing()
|
||||
if (!viewport) return null
|
||||
|
||||
return {
|
||||
x: -viewport.x,
|
||||
y: -viewport.y,
|
||||
z: this.getViewportScreenBounds().w / viewport.width,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -3100,6 +3145,9 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
|
||||
// Following
|
||||
|
||||
// When we are 'locked on' to a user, our camera is derived from their camera.
|
||||
private _isLockedOnFollowingUser = atom('isLockedOnFollowingUser', false)
|
||||
|
||||
/**
|
||||
* Start viewport-following a user.
|
||||
*
|
||||
|
@ -3109,18 +3157,28 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
* ```
|
||||
*
|
||||
* @param userId - The id of the user to follow.
|
||||
* @param opts - Options for starting to follow a user.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
startFollowingUser(userId: string): this {
|
||||
// if we were already following someone, stop following them
|
||||
this.stopFollowingUser()
|
||||
|
||||
const leaderPresences = this._getCollaboratorsQuery()
|
||||
.get()
|
||||
.filter((p) => p.userId === userId)
|
||||
|
||||
if (!leaderPresences.length) {
|
||||
console.warn('User not found')
|
||||
return this
|
||||
}
|
||||
|
||||
const thisUserId = this.user.getId()
|
||||
|
||||
if (!thisUserId) {
|
||||
console.warn('You should set the userId for the current instance before following a user')
|
||||
// allow to continue since it's probably fine most of the time.
|
||||
}
|
||||
|
||||
// If the leader is following us, then we can't follow them
|
||||
|
@ -3128,113 +3186,108 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
return this
|
||||
}
|
||||
|
||||
const latestLeaderPresence = computed('latestLeaderPresence', () => {
|
||||
return this.getCollaborators().find((p) => p.userId === userId)
|
||||
})
|
||||
|
||||
transact(() => {
|
||||
this.stopFollowingUser()
|
||||
this.updateInstanceState({ followingUserId: userId })
|
||||
|
||||
// we listen for page changes separately from the 'moveTowardsUser' tick
|
||||
const dispose = react('update current page', () => {
|
||||
const leaderPresence = latestLeaderPresence.get()
|
||||
if (!leaderPresence) {
|
||||
this.stopFollowingUser()
|
||||
return
|
||||
}
|
||||
if (
|
||||
leaderPresence.currentPageId !== this.getCurrentPageId() &&
|
||||
this.getPage(leaderPresence.currentPageId)
|
||||
) {
|
||||
// if the page changed, switch page
|
||||
this.history.ignore(() => {
|
||||
// sneaky store.put here, we can't go through setCurrentPage because it calls stopFollowingUser
|
||||
this.store.put([
|
||||
{ ...this.getInstanceState(), currentPageId: leaderPresence.currentPageId },
|
||||
])
|
||||
this._isLockedOnFollowingUser.set(true)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const cancel = () => {
|
||||
dispose()
|
||||
this._isLockedOnFollowingUser.set(false)
|
||||
this.off('frame', moveTowardsUser)
|
||||
this.off('stop-following', cancel)
|
||||
}
|
||||
|
||||
let isCaughtUp = false
|
||||
|
||||
const moveTowardsUser = () => {
|
||||
transact(() => {
|
||||
// Stop following if we can't find the user
|
||||
const leaderPresence = this._getCollaboratorsQuery()
|
||||
.get()
|
||||
.filter((p) => p.userId === userId)
|
||||
.sort((a, b) => {
|
||||
return b.lastActivityTimestamp - a.lastActivityTimestamp
|
||||
})[0]
|
||||
|
||||
const leaderPresence = latestLeaderPresence.get()
|
||||
if (!leaderPresence) {
|
||||
this.stopFollowingUser()
|
||||
return
|
||||
}
|
||||
|
||||
// Change page if leader is on a different page
|
||||
const isOnSamePage = leaderPresence.currentPageId === this.getCurrentPageId()
|
||||
const chaseProportion = isOnSamePage ? FOLLOW_CHASE_PROPORTION : 1
|
||||
if (!isOnSamePage) {
|
||||
this.stopFollowingUser()
|
||||
this.setCurrentPage(leaderPresence.currentPageId)
|
||||
this.startFollowingUser(userId)
|
||||
if (this._isLockedOnFollowingUser.get()) return
|
||||
|
||||
const animationSpeed = this.user.getAnimationSpeed()
|
||||
|
||||
if (animationSpeed === 0) {
|
||||
this._isLockedOnFollowingUser.set(true)
|
||||
return
|
||||
}
|
||||
|
||||
// Get the bounds of the follower (me) and the leader (them)
|
||||
const { center, width, height } = this.getViewportPageBounds()
|
||||
const leaderScreen = Box.From(leaderPresence.screenBounds)
|
||||
const leaderWidth = leaderScreen.width / leaderPresence.camera.z
|
||||
const leaderHeight = leaderScreen.height / leaderPresence.camera.z
|
||||
const leaderCenter = new Vec(
|
||||
leaderWidth / 2 - leaderPresence.camera.x,
|
||||
leaderHeight / 2 - leaderPresence.camera.y
|
||||
const targetViewport = this.getViewportPageBoundsForFollowing()
|
||||
if (!targetViewport) {
|
||||
this.stopFollowingUser()
|
||||
return
|
||||
}
|
||||
const currentViewport = this.getViewportPageBounds()
|
||||
|
||||
const diffX =
|
||||
Math.abs(targetViewport.minX - currentViewport.minX) +
|
||||
Math.abs(targetViewport.maxX - currentViewport.maxX)
|
||||
const diffY =
|
||||
Math.abs(targetViewport.minY - currentViewport.minY) +
|
||||
Math.abs(targetViewport.maxY - currentViewport.maxY)
|
||||
|
||||
// Stop chasing if we're close enough!
|
||||
if (diffX < FOLLOW_CHASE_VIEWPORT_SNAP && diffY < FOLLOW_CHASE_VIEWPORT_SNAP) {
|
||||
this._isLockedOnFollowingUser.set(true)
|
||||
return
|
||||
}
|
||||
|
||||
// Chase the user's viewport!
|
||||
// Interpolate between the current viewport and the target viewport based on animation speed.
|
||||
// This will produce an 'ease-out' effect.
|
||||
const t = clamp(animationSpeed * 0.5, 0.1, 0.8)
|
||||
|
||||
const nextViewport = new Box(
|
||||
lerp(currentViewport.minX, targetViewport.minX, t),
|
||||
lerp(currentViewport.minY, targetViewport.minY, t),
|
||||
lerp(currentViewport.width, targetViewport.width, t),
|
||||
lerp(currentViewport.height, targetViewport.height, t)
|
||||
)
|
||||
|
||||
// At this point, let's check if we're following someone who's following us.
|
||||
// If so, we can't try to contain their entire viewport
|
||||
// because that would become a feedback loop where we zoom, they zoom, etc.
|
||||
const isFollowingFollower = leaderPresence.followingUserId === thisUserId
|
||||
|
||||
// Figure out how much to zoom
|
||||
const desiredWidth = width + (leaderWidth - width) * chaseProportion
|
||||
const desiredHeight = height + (leaderHeight - height) * chaseProportion
|
||||
const ratio = !isFollowingFollower
|
||||
? Math.min(width / desiredWidth, height / desiredHeight)
|
||||
: height / desiredHeight
|
||||
|
||||
const baseZoom = this.getBaseZoom()
|
||||
const { zoomSteps } = this.getCameraOptions()
|
||||
const zoomMin = zoomSteps[0]
|
||||
const zoomMax = last(zoomSteps)!
|
||||
const targetZoom = clamp(this.getCamera().z * ratio, zoomMin * baseZoom, zoomMax * baseZoom)
|
||||
const targetWidth = this.getViewportScreenBounds().w / targetZoom
|
||||
const targetHeight = this.getViewportScreenBounds().h / targetZoom
|
||||
|
||||
// Figure out where to move the camera
|
||||
const displacement = leaderCenter.sub(center)
|
||||
const targetCenter = Vec.Add(center, Vec.Mul(displacement, chaseProportion))
|
||||
|
||||
// Now let's assess whether we've caught up to the leader or not
|
||||
const distance = Vec.Sub(targetCenter, center).len()
|
||||
const zoomChange = Math.abs(targetZoom - this.getCamera().z)
|
||||
|
||||
// If we're chasing the leader...
|
||||
// Stop chasing if we're close enough
|
||||
if (distance < FOLLOW_CHASE_PAN_SNAP && zoomChange < FOLLOW_CHASE_ZOOM_SNAP) {
|
||||
isCaughtUp = true
|
||||
return
|
||||
}
|
||||
|
||||
// If we're already caught up with the leader...
|
||||
// Only start moving again if we're far enough away
|
||||
if (
|
||||
isCaughtUp &&
|
||||
distance < FOLLOW_CHASE_PAN_UNSNAP &&
|
||||
zoomChange < FOLLOW_CHASE_ZOOM_UNSNAP
|
||||
) {
|
||||
return
|
||||
}
|
||||
const nextCamera = new Vec(
|
||||
-nextViewport.x,
|
||||
-nextViewport.y,
|
||||
this.getViewportScreenBounds().width / nextViewport.width
|
||||
)
|
||||
|
||||
// Update the camera!
|
||||
isCaughtUp = false
|
||||
this.stopCameraAnimation()
|
||||
this._setCamera(
|
||||
new Vec(
|
||||
-(targetCenter.x - targetWidth / 2),
|
||||
-(targetCenter.y - targetHeight / 2),
|
||||
targetZoom
|
||||
)
|
||||
)
|
||||
})
|
||||
this._setCamera(nextCamera)
|
||||
}
|
||||
|
||||
this.once('stop-following', cancel)
|
||||
this.on('frame', moveTowardsUser)
|
||||
this.addListener('frame', moveTowardsUser)
|
||||
|
||||
// call once to start synchronously
|
||||
moveTowardsUser()
|
||||
})
|
||||
|
||||
return this
|
||||
}
|
||||
|
@ -3249,8 +3302,14 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
* @public
|
||||
*/
|
||||
stopFollowingUser(): this {
|
||||
this.batch(() => {
|
||||
// commit the current camera to the store
|
||||
this.store.put([this.getCamera()])
|
||||
// this must happen after the camera is committed
|
||||
this._isLockedOnFollowingUser.set(false)
|
||||
this.updateInstanceState({ followingUserId: null })
|
||||
this.emit('stop-following')
|
||||
})
|
||||
return this
|
||||
}
|
||||
|
||||
|
@ -8295,7 +8354,6 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
const instanceState = this.store.unsafeGetWithoutCapture(TLINSTANCE_ID)!
|
||||
const pageState = this.store.get(this._getCurrentPageStateId())!
|
||||
const cameraOptions = this._cameraOptions.__unsafe__getWithoutCapture()!
|
||||
const camera = this.store.unsafeGetWithoutCapture(this.getCameraId())!
|
||||
|
||||
switch (type) {
|
||||
case 'pinch': {
|
||||
|
@ -8337,13 +8395,13 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
instanceState.screenBounds.y
|
||||
)
|
||||
|
||||
const { x: cx, y: cy, z: cz } = camera
|
||||
|
||||
this.stopCameraAnimation()
|
||||
if (instanceState.followingUserId) {
|
||||
this.stopFollowingUser()
|
||||
}
|
||||
|
||||
const { x: cx, y: cy, z: cz } = unsafe__withoutCapture(() => this.getCamera())
|
||||
|
||||
const { panSpeed, zoomSpeed } = cameraOptions
|
||||
this._setCamera(
|
||||
new Vec(
|
||||
|
@ -8402,7 +8460,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
this.stopFollowingUser()
|
||||
}
|
||||
|
||||
const { x: cx, y: cy, z: cz } = camera
|
||||
const { x: cx, y: cy, z: cz } = unsafe__withoutCapture(() => this.getCamera())
|
||||
const { x: dx, y: dy, z: dz = 0 } = info.delta
|
||||
|
||||
let behavior = wheelBehavior
|
||||
|
@ -8517,10 +8575,12 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
// If the user is in pen mode, but the pointer is not a pen, stop here.
|
||||
if (!isPen && isPenMode) return
|
||||
|
||||
const { x: cx, y: cy, z: cz } = unsafe__withoutCapture(() => this.getCamera())
|
||||
|
||||
// If we've started panning, then clear any long press timeout
|
||||
if (this.inputs.isPanning && this.inputs.isPointing) {
|
||||
// Handle spacebar / middle mouse button panning
|
||||
const { currentScreenPoint, previousScreenPoint } = this.inputs
|
||||
const { x: cx, y: cy, z: cz } = camera
|
||||
const { panSpeed } = cameraOptions
|
||||
const offset = Vec.Sub(currentScreenPoint, previousScreenPoint)
|
||||
this.setCamera(
|
||||
|
@ -8535,7 +8595,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
inputs.isPointing &&
|
||||
!inputs.isDragging &&
|
||||
Vec.Dist2(originPagePoint, currentPagePoint) >
|
||||
(instanceState.isCoarsePointer ? COARSE_DRAG_DISTANCE : DRAG_DISTANCE) / camera.z
|
||||
(instanceState.isCoarsePointer ? COARSE_DRAG_DISTANCE : DRAG_DISTANCE) / cz
|
||||
) {
|
||||
// Start dragging
|
||||
inputs.isDragging = true
|
||||
|
|
|
@ -423,30 +423,45 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
|
|||
lastPushedPresenceState: R | null = null
|
||||
|
||||
private pushPresence(nextPresence: R | null) {
|
||||
// make sure we push any document changes first
|
||||
// TODO: need to send presence changes in the same push request as document changes
|
||||
// in order to not get into weird states
|
||||
this.store._flushHistory()
|
||||
if (!this.isConnectedToRoom) {
|
||||
// if we're offline, don't do anything
|
||||
return
|
||||
}
|
||||
let req: TLPushRequest<R> | null = null
|
||||
|
||||
let presence: TLPushRequest<any>['presence'] = undefined
|
||||
if (!this.lastPushedPresenceState && nextPresence) {
|
||||
// we don't have a last presence state, so we need to push the full state
|
||||
req = {
|
||||
type: 'push',
|
||||
presence: [RecordOpType.Put, nextPresence],
|
||||
clientClock: this.clientClock++,
|
||||
}
|
||||
presence = [RecordOpType.Put, nextPresence]
|
||||
} else if (this.lastPushedPresenceState && nextPresence) {
|
||||
// we have a last presence state, so we need to push a diff if there is one
|
||||
const diff = diffRecord(this.lastPushedPresenceState, nextPresence)
|
||||
if (diff) {
|
||||
req = {
|
||||
type: 'push',
|
||||
presence: [RecordOpType.Patch, diff],
|
||||
clientClock: this.clientClock++,
|
||||
}
|
||||
presence = [RecordOpType.Patch, diff]
|
||||
}
|
||||
}
|
||||
|
||||
if (!presence) return
|
||||
this.lastPushedPresenceState = nextPresence
|
||||
|
||||
// if there is a pending push that has not been sent and does not already include a presence update,
|
||||
// then add this presence update to it
|
||||
const lastPush = this.pendingPushRequests.at(-1)
|
||||
if (lastPush && !lastPush.sent && !lastPush.request.presence) {
|
||||
lastPush.request.presence = presence
|
||||
return
|
||||
}
|
||||
|
||||
// otherwise, create a new push request
|
||||
const req: TLPushRequest<R> = {
|
||||
type: 'push',
|
||||
clientClock: this.clientClock++,
|
||||
presence,
|
||||
}
|
||||
|
||||
if (req) {
|
||||
this.pendingPushRequests.push({ request: req, sent: false })
|
||||
this.flushPendingPushRequests()
|
||||
|
@ -586,7 +601,7 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
|
|||
this.pendingPushRequests.shift()
|
||||
} else if (diff.action === 'commit') {
|
||||
const { request } = this.pendingPushRequests.shift()!
|
||||
if ('diff' in request) {
|
||||
if ('diff' in request && request.diff) {
|
||||
this.applyNetworkDiff(request.diff, true)
|
||||
}
|
||||
} else {
|
||||
|
@ -598,7 +613,7 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
|
|||
try {
|
||||
this.speculativeChanges = this.store.extractingChanges(() => {
|
||||
for (const { request } of this.pendingPushRequests) {
|
||||
if (!('diff' in request)) continue
|
||||
if (!('diff' in request) || !request.diff) continue
|
||||
this.applyNetworkDiff(request.diff, true)
|
||||
}
|
||||
})
|
||||
|
|
|
@ -814,11 +814,13 @@ export class TLSyncRoom<R extends UnknownRecord> {
|
|||
transaction((rollback) => {
|
||||
// collect actual ops that resulted from the push
|
||||
// these will be broadcast to other users
|
||||
let mergedChanges: NetworkDiff<R> | null = null
|
||||
type ActualChanges = { diff: NetworkDiff<R> | null }
|
||||
const docChanges: ActualChanges = { diff: null }
|
||||
const presenceChanges: ActualChanges = { diff: null }
|
||||
|
||||
const propagateOp = (id: string, op: RecordOp<R>) => {
|
||||
if (!mergedChanges) mergedChanges = {}
|
||||
mergedChanges[id] = op
|
||||
const propagateOp = (changes: ActualChanges, id: string, op: RecordOp<R>) => {
|
||||
if (!changes.diff) changes.diff = {}
|
||||
changes.diff[id] = op
|
||||
}
|
||||
|
||||
const fail = (reason: TLIncompatibilityReason): Result<void, void> => {
|
||||
|
@ -830,7 +832,7 @@ export class TLSyncRoom<R extends UnknownRecord> {
|
|||
return Result.err(undefined)
|
||||
}
|
||||
|
||||
const addDocument = (id: string, _state: R): Result<void, void> => {
|
||||
const addDocument = (changes: ActualChanges, id: string, _state: R): Result<void, void> => {
|
||||
const res = this.schema.migratePersistedRecord(_state, session.serializedSchema, 'up')
|
||||
if (res.type === 'error') {
|
||||
return fail(
|
||||
|
@ -852,7 +854,7 @@ export class TLSyncRoom<R extends UnknownRecord> {
|
|||
return fail(TLIncompatibilityReason.InvalidRecord)
|
||||
}
|
||||
if (diff.value) {
|
||||
propagateOp(id, [RecordOpType.Patch, diff.value])
|
||||
propagateOp(changes, id, [RecordOpType.Patch, diff.value])
|
||||
}
|
||||
} else {
|
||||
// Otherwise, if we don't already have a document with this id
|
||||
|
@ -861,13 +863,17 @@ export class TLSyncRoom<R extends UnknownRecord> {
|
|||
if (!result.ok) {
|
||||
return fail(TLIncompatibilityReason.InvalidRecord)
|
||||
}
|
||||
propagateOp(id, [RecordOpType.Put, state])
|
||||
propagateOp(changes, id, [RecordOpType.Put, state])
|
||||
}
|
||||
|
||||
return Result.ok(undefined)
|
||||
}
|
||||
|
||||
const patchDocument = (id: string, patch: ObjectDiff): Result<void, void> => {
|
||||
const patchDocument = (
|
||||
changes: ActualChanges,
|
||||
id: string,
|
||||
patch: ObjectDiff
|
||||
): Result<void, void> => {
|
||||
// if it was already deleted, there's no need to apply the patch
|
||||
const doc = this.getDocument(id)
|
||||
if (!doc) return Result.ok(undefined)
|
||||
|
@ -889,7 +895,7 @@ export class TLSyncRoom<R extends UnknownRecord> {
|
|||
return fail(TLIncompatibilityReason.InvalidRecord)
|
||||
}
|
||||
if (diff.value) {
|
||||
propagateOp(id, [RecordOpType.Patch, diff.value])
|
||||
propagateOp(changes, id, [RecordOpType.Patch, diff.value])
|
||||
}
|
||||
} else {
|
||||
// need to apply the patch to the downgraded version and then upgrade it
|
||||
|
@ -912,7 +918,7 @@ export class TLSyncRoom<R extends UnknownRecord> {
|
|||
return fail(TLIncompatibilityReason.InvalidRecord)
|
||||
}
|
||||
if (diff.value) {
|
||||
propagateOp(id, [RecordOpType.Patch, diff.value])
|
||||
propagateOp(changes, id, [RecordOpType.Patch, diff.value])
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -921,7 +927,7 @@ export class TLSyncRoom<R extends UnknownRecord> {
|
|||
|
||||
const { clientClock } = message
|
||||
|
||||
if ('presence' in message) {
|
||||
if ('presence' in message && message.presence) {
|
||||
// The push request was for the presence scope.
|
||||
const id = session.presenceId
|
||||
const [type, val] = message.presence
|
||||
|
@ -929,30 +935,27 @@ export class TLSyncRoom<R extends UnknownRecord> {
|
|||
switch (type) {
|
||||
case RecordOpType.Put: {
|
||||
// Try to put the document. If it fails, stop here.
|
||||
const res = addDocument(id, { ...val, id, typeName })
|
||||
const res = addDocument(presenceChanges, id, { ...val, id, typeName })
|
||||
// if res.ok is false here then we already called `fail` and we should stop immediately
|
||||
if (!res.ok) return
|
||||
break
|
||||
}
|
||||
case RecordOpType.Patch: {
|
||||
// Try to patch the document. If it fails, stop here.
|
||||
const res = patchDocument(id, {
|
||||
const res = patchDocument(presenceChanges, id, {
|
||||
...val,
|
||||
id: [ValueOpType.Put, id],
|
||||
typeName: [ValueOpType.Put, typeName],
|
||||
})
|
||||
// if res.ok is false here then we already called `fail` and we should stop immediately
|
||||
if (!res.ok) return
|
||||
break
|
||||
}
|
||||
}
|
||||
this.sendMessage(session.sessionKey, {
|
||||
type: 'push_result',
|
||||
clientClock,
|
||||
action: 'commit',
|
||||
serverClock: this.clock,
|
||||
})
|
||||
} else {
|
||||
}
|
||||
if (message.diff) {
|
||||
// The push request was for the document scope.
|
||||
for (const [id, op] of Object.entries(message.diff)) {
|
||||
for (const [id, op] of Object.entries(message.diff!)) {
|
||||
switch (op[0]) {
|
||||
case RecordOpType.Put: {
|
||||
// Try to add the document.
|
||||
|
@ -960,13 +963,15 @@ export class TLSyncRoom<R extends UnknownRecord> {
|
|||
if (!this.documentTypes.has(op[1].typeName)) {
|
||||
return fail(TLIncompatibilityReason.InvalidRecord)
|
||||
}
|
||||
const res = addDocument(id, op[1])
|
||||
const res = addDocument(docChanges, id, op[1])
|
||||
// if res.ok is false here then we already called `fail` and we should stop immediately
|
||||
if (!res.ok) return
|
||||
break
|
||||
}
|
||||
case RecordOpType.Patch: {
|
||||
// Try to patch the document. If it fails, stop here.
|
||||
const res = patchDocument(id, op[1])
|
||||
const res = patchDocument(docChanges, id, op[1])
|
||||
// if res.ok is false here then we already called `fail` and we should stop immediately
|
||||
if (!res.ok) return
|
||||
break
|
||||
}
|
||||
|
@ -986,23 +991,20 @@ export class TLSyncRoom<R extends UnknownRecord> {
|
|||
this.removeDocument(id, this.clock)
|
||||
// Schedule a pruneTombstones call to happen on the next call stack
|
||||
setTimeout(this.pruneTombstones, 0)
|
||||
propagateOp(id, op)
|
||||
propagateOp(docChanges, id, op)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Let the client know what action to take based on the results of the push
|
||||
if (!mergedChanges) {
|
||||
// DISCARD
|
||||
// Applying the client's changes had no effect, so the client should drop the diff
|
||||
this.sendMessage(session.sessionKey, {
|
||||
type: 'push_result',
|
||||
serverClock: this.clock,
|
||||
clientClock,
|
||||
action: 'discard',
|
||||
})
|
||||
} else if (isEqual(mergedChanges, message.diff)) {
|
||||
if (
|
||||
// if there was only a presence push, the client doesn't need to do anything aside from
|
||||
// shift the push request.
|
||||
!message.diff ||
|
||||
isEqual(docChanges.diff, message.diff)
|
||||
) {
|
||||
// COMMIT
|
||||
// Applying the client's changes had the exact same effect on the server as
|
||||
// they had on the client, so the client should keep the diff
|
||||
|
@ -1012,12 +1014,21 @@ export class TLSyncRoom<R extends UnknownRecord> {
|
|||
clientClock,
|
||||
action: 'commit',
|
||||
})
|
||||
} else if (!docChanges.diff) {
|
||||
// DISCARD
|
||||
// Applying the client's changes had no effect, so the client should drop the diff
|
||||
this.sendMessage(session.sessionKey, {
|
||||
type: 'push_result',
|
||||
serverClock: this.clock,
|
||||
clientClock,
|
||||
action: 'discard',
|
||||
})
|
||||
} else {
|
||||
// REBASE
|
||||
// Applying the client's changes had a different non-empty effect on the server,
|
||||
// so the client should rebase with our gold-standard / authoritative diff.
|
||||
// First we need to migrate the diff to the client's version
|
||||
const migrateResult = this.migrateDiffForSession(session.serializedSchema, mergedChanges)
|
||||
const migrateResult = this.migrateDiffForSession(session.serializedSchema, docChanges.diff)
|
||||
if (!migrateResult.ok) {
|
||||
return fail(
|
||||
migrateResult.error === MigrationFailureReason.TargetVersionTooNew
|
||||
|
@ -1033,13 +1044,15 @@ export class TLSyncRoom<R extends UnknownRecord> {
|
|||
action: { rebaseWithDiff: migrateResult.value },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// If there are merged changes, broadcast them to all other clients
|
||||
if (mergedChanges !== null) {
|
||||
if (docChanges.diff || presenceChanges.diff) {
|
||||
this.broadcastPatch({
|
||||
sourceSessionKey: session.sessionKey,
|
||||
diff: mergedChanges,
|
||||
diff: {
|
||||
...docChanges.diff,
|
||||
...presenceChanges.diff,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -61,17 +61,12 @@ export type TLSocketServerSentDataEvent<R extends UnknownRecord> =
|
|||
}
|
||||
|
||||
/** @public */
|
||||
export type TLPushRequest<R extends UnknownRecord> =
|
||||
| {
|
||||
export type TLPushRequest<R extends UnknownRecord> = {
|
||||
type: 'push'
|
||||
clientClock: number
|
||||
presence: [typeof RecordOpType.Patch, ObjectDiff] | [typeof RecordOpType.Put, R]
|
||||
}
|
||||
| {
|
||||
type: 'push'
|
||||
clientClock: number
|
||||
diff: NetworkDiff<R>
|
||||
}
|
||||
diff?: NetworkDiff<R>
|
||||
presence?: [typeof RecordOpType.Patch, ObjectDiff] | [typeof RecordOpType.Put, R]
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export type TLConnectRequest = {
|
||||
|
|
Loading…
Reference in a new issue