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:
parent
a5e097f786
commit
11e39f0179
|
|
@ -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])
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue