fix: use tldraw tick event for synchronous pinned shape updates

Replace requestAnimationFrame polling with tldraw's 'tick' event which
fires synchronously with the render cycle. This ensures the pinned shape
position is updated BEFORE rendering, eliminating the visual lag where
the shape appeared to "chase" the camera during zooming.

Changes:
- Use editor.on('tick') instead of requestAnimationFrame polling
- Remove throttling (no longer needed with tick event)
- Reduce position tolerance from 0.5 to 0.01 for more precise tracking
- Simplify code by removing unnecessary camera tracking refs

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2025-12-15 20:12:08 -05:00
parent 6f57c767f4
commit cc1928852f
1 changed files with 31 additions and 91 deletions

View File

@ -21,6 +21,9 @@ export interface PinnedViewOptions {
* Hook to manage shapes pinned to the viewport. * Hook to manage shapes pinned to the viewport.
* When a shape is pinned, it stays in the same screen position AND visual size * When a shape is pinned, it stays in the same screen position AND visual size
* as the camera moves and zooms. Content inside the shape remains unchanged. * as the camera moves and zooms. Content inside the shape remains unchanged.
*
* Uses tldraw's 'tick' event for synchronous updates with the render cycle,
* ensuring the shape never visually lags behind camera movements.
*/ */
export function usePinnedToView( export function usePinnedToView(
editor: Editor | null, editor: Editor | null,
@ -33,9 +36,6 @@ export function usePinnedToView(
const originalCoordinatesRef = useRef<{ x: number; y: number } | null>(null) const originalCoordinatesRef = useRef<{ x: number; y: number } | null>(null)
const wasPinnedRef = useRef<boolean>(false) const wasPinnedRef = useRef<boolean>(false)
const isUpdatingRef = useRef<boolean>(false) const isUpdatingRef = useRef<boolean>(false)
const animationFrameRef = useRef<number | null>(null)
const lastCameraRef = useRef<{ x: number; y: number; z: number } | null>(null)
const lastUpdateTimeRef = useRef<number>(0)
const driftAnimationRef = useRef<number | null>(null) const driftAnimationRef = useRef<number | null>(null)
useEffect(() => { useEffect(() => {
@ -94,7 +94,6 @@ export function usePinnedToView(
} }
pinnedScreenPositionRef.current = { x: screenPoint.x, y: screenPoint.y } pinnedScreenPositionRef.current = { x: screenPoint.x, y: screenPoint.y }
lastCameraRef.current = { ...currentCamera }
// Bring the shape to the front // Bring the shape to the front
try { try {
@ -120,12 +119,6 @@ export function usePinnedToView(
// If just became unpinned, animate back to original coordinates // If just became unpinned, animate back to original coordinates
if (!isPinned && wasPinnedRef.current) { if (!isPinned && wasPinnedRef.current) {
// Cancel any ongoing pinned position updates
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current)
animationFrameRef.current = null
}
// Get original coordinates from meta // Get original coordinates from meta
const currentShape = editor.getShape(shapeId as TLShapeId) const currentShape = editor.getShape(shapeId as TLShapeId)
if (currentShape) { if (currentShape) {
@ -214,7 +207,6 @@ export function usePinnedToView(
setTimeout(() => { setTimeout(() => {
pinnedScreenPositionRef.current = null pinnedScreenPositionRef.current = null
originalCoordinatesRef.current = null originalCoordinatesRef.current = null
lastCameraRef.current = null
}, 50) }, 50)
} }
@ -224,29 +216,24 @@ export function usePinnedToView(
return return
} }
// Update position (NOT size) to maintain screen position // Use tldraw's tick event for synchronous updates with the render cycle
const updatePinnedPosition = (timestamp: number) => { // This ensures the shape position is updated BEFORE rendering, eliminating visual lag
const handleTick = () => {
if (isUpdatingRef.current || !editor || !shapeId || !isPinned) { if (isUpdatingRef.current || !editor || !shapeId || !isPinned) {
if (isPinned) {
animationFrameRef.current = requestAnimationFrame(updatePinnedPosition)
}
return return
} }
const currentShape = editor.getShape(shapeId as TLShapeId) const currentShape = editor.getShape(shapeId as TLShapeId)
if (!currentShape) { if (!currentShape) {
animationFrameRef.current = requestAnimationFrame(updatePinnedPosition)
return return
} }
const currentCamera = editor.getCamera() // Get the target screen position
const lastCamera = lastCameraRef.current
// Calculate pinned screen position
let pinnedScreenPos: { x: number; y: number } let pinnedScreenPos: { x: number; y: number }
if (position !== 'current') { if (position !== 'current') {
const viewport = editor.getViewportScreenBounds() const viewport = editor.getViewportScreenBounds()
const currentCamera = editor.getCamera()
const shapeWidth = (currentShape.props as any).w || 0 const shapeWidth = (currentShape.props as any).w || 0
const shapeHeight = (currentShape.props as any).h || 0 const shapeHeight = (currentShape.props as any).h || 0
const pinnedAtZoom = (currentShape.meta as any)?.pinnedAtZoom || currentCamera.z const pinnedAtZoom = (currentShape.meta as any)?.pinnedAtZoom || currentCamera.z
@ -276,96 +263,49 @@ export function usePinnedToView(
} }
} else { } else {
if (!pinnedScreenPositionRef.current) { if (!pinnedScreenPositionRef.current) {
animationFrameRef.current = requestAnimationFrame(updatePinnedPosition)
return return
} }
pinnedScreenPos = pinnedScreenPositionRef.current pinnedScreenPos = pinnedScreenPositionRef.current
} }
// Check if camera has changed try {
const cameraChanged = !lastCamera || ( // Convert screen position back to page coordinates
Math.abs(currentCamera.x - lastCamera.x) > 0.1 || const newPagePoint = editor.screenToPage(pinnedScreenPos)
Math.abs(currentCamera.y - lastCamera.y) > 0.1 ||
Math.abs(currentCamera.z - lastCamera.z) > 0.001
)
const shouldUpdate = cameraChanged || position !== 'current' // Check if position needs updating (with small tolerance to avoid unnecessary updates)
const deltaX = Math.abs(currentShape.x - newPagePoint.x)
const deltaY = Math.abs(currentShape.y - newPagePoint.y)
if (shouldUpdate) { if (deltaX > 0.01 || deltaY > 0.01) {
const timeSinceLastUpdate = timestamp - lastUpdateTimeRef.current isUpdatingRef.current = true
const minUpdateInterval = 16 // ~60fps
if (timeSinceLastUpdate >= minUpdateInterval) { editor.updateShape({
try { id: shapeId as TLShapeId,
// Convert screen position back to page coordinates type: currentShape.type,
const newPagePoint = editor.screenToPage(pinnedScreenPos) x: newPagePoint.x,
y: newPagePoint.y,
})
const deltaX = Math.abs(currentShape.x - newPagePoint.x) isUpdatingRef.current = false
const deltaY = Math.abs(currentShape.y - newPagePoint.y)
// Only update position if it changed significantly
// Note: We do NOT update w/h - visual scaling is handled by CSS transform
if (deltaX > 0.5 || deltaY > 0.5) {
isUpdatingRef.current = true
editor.batch(() => {
editor.updateShape({
id: shapeId as TLShapeId,
type: currentShape.type,
x: newPagePoint.x,
y: newPagePoint.y,
})
})
lastUpdateTimeRef.current = timestamp
isUpdatingRef.current = false
}
lastCameraRef.current = { ...currentCamera }
} catch (error) {
console.error('Error updating pinned shape position:', error)
isUpdatingRef.current = false
}
} }
} catch (error) {
console.error('Error updating pinned shape position:', error)
isUpdatingRef.current = false
} }
animationFrameRef.current = requestAnimationFrame(updatePinnedPosition)
} }
lastUpdateTimeRef.current = performance.now() // Subscribe to tick event for synchronous updates
animationFrameRef.current = requestAnimationFrame(updatePinnedPosition) editor.on('tick', handleTick)
// Listen for shape changes (user dragging while pinned) // Also do an immediate update to sync position
const handleShapeChange = (event: any) => { handleTick()
if (isUpdatingRef.current || !editor || !shapeId || !isPinned) return
const changedShapes = event?.changedShapes || event?.shapes || []
const shapeChanged = changedShapes.some((s: any) => s?.id === (shapeId as TLShapeId))
if (!shapeChanged) return
const currentShape = editor.getShape(shapeId as TLShapeId)
if (!currentShape) return
// Update the pinned screen position
const pagePoint = { x: currentShape.x, y: currentShape.y }
const screenPoint = editor.pageToScreen(pagePoint)
pinnedScreenPositionRef.current = { x: screenPoint.x, y: screenPoint.y }
lastCameraRef.current = { ...editor.getCamera() }
}
editor.on('change' as any, handleShapeChange)
return () => { return () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current)
animationFrameRef.current = null
}
if (driftAnimationRef.current) { if (driftAnimationRef.current) {
cancelAnimationFrame(driftAnimationRef.current) cancelAnimationFrame(driftAnimationRef.current)
driftAnimationRef.current = null driftAnimationRef.current = null
} }
editor.off('change' as any, handleShapeChange) editor.off('tick', handleTick)
} }
}, [editor, shapeId, isPinned, position, offsetX, offsetY]) }, [editor, shapeId, isPinned, position, offsetX, offsetY])
} }