fix: use store.listen for zero-lag pinned shape updates

Replace tick event with store.listen to react synchronously when the
camera record changes. This eliminates the one-frame delay that was
causing the shape and its indicator to lag behind camera movements.

Changes:
- Use editor.store.listen instead of editor.on('tick')
- Filter for camera record changes specifically
- Remove position threshold for maximum responsiveness
- Remove unused pinnedAtZoom since CSS scaling was removed

🤖 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-16 19:43:15 -05:00
parent 72c2e52ae7
commit 1bde78bb29
1 changed files with 41 additions and 46 deletions

View File

@ -19,11 +19,11 @@ 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 as the camera
* as the camera moves and zooms. Content inside the shape remains unchanged. * moves and zooms. The shape scales normally with zoom.
* *
* Uses tldraw's 'tick' event for synchronous updates with the render cycle, * Uses store.listen for immediate synchronous updates when the camera changes,
* ensuring the shape never visually lags behind camera movements. * ensuring zero visual lag between camera movement and shape repositioning.
*/ */
export function usePinnedToView( export function usePinnedToView(
editor: Editor | null, editor: Editor | null,
@ -51,16 +51,12 @@ export function usePinnedToView(
// Store the original coordinates - these will be restored when unpinned // Store the original coordinates - these will be restored when unpinned
originalCoordinatesRef.current = { x: shape.x, y: shape.y } originalCoordinatesRef.current = { x: shape.x, y: shape.y }
// Store the original zoom level in shape meta for CSS transform calculation // Store original position in meta for unpinning
const currentCamera = editor.getCamera()
// Store original zoom in meta so StandardizedToolWrapper can access it
editor.updateShape({ editor.updateShape({
id: shapeId as TLShapeId, id: shapeId as TLShapeId,
type: shape.type, type: shape.type,
meta: { meta: {
...shape.meta, ...shape.meta,
pinnedAtZoom: currentCamera.z,
originalX: shape.x, originalX: shape.x,
originalY: shape.y, originalY: shape.y,
}, },
@ -69,6 +65,7 @@ export function usePinnedToView(
// Calculate screen position based on position option // Calculate screen position based on position option
let screenPoint: { x: number; y: number } let screenPoint: { x: number; y: number }
const viewport = editor.getViewportScreenBounds() const viewport = editor.getViewportScreenBounds()
const currentCamera = editor.getCamera()
const shapeWidth = (shape.props as any).w || 0 const shapeWidth = (shape.props as any).w || 0
const shapeHeight = (shape.props as any).h || 0 const shapeHeight = (shape.props as any).h || 0
@ -168,8 +165,7 @@ export function usePinnedToView(
} else { } else {
// Animation complete - clear pinned meta data // Animation complete - clear pinned meta data
try { try {
// Create new meta without pinned properties (don't use undefined) const { originalX, originalY, ...cleanMeta } = (currentShape.meta || {}) as any
const { pinnedAtZoom, originalX, originalY, ...cleanMeta } = (currentShape.meta || {}) as any
editor.updateShape({ editor.updateShape({
id: shapeId as TLShapeId, id: shapeId as TLShapeId,
type: currentShape.type, type: currentShape.type,
@ -188,8 +184,7 @@ export function usePinnedToView(
} else { } else {
// Distance is too small, just set directly and clear meta // Distance is too small, just set directly and clear meta
try { try {
// Create new meta without pinned properties (don't use undefined) const { originalX, originalY, ...cleanMeta } = (currentShape.meta || {}) as any
const { pinnedAtZoom, originalX, originalY, ...cleanMeta } = (currentShape.meta || {}) as any
editor.updateShape({ editor.updateShape({
id: shapeId as TLShapeId, id: shapeId as TLShapeId,
type: currentShape.type, type: currentShape.type,
@ -216,10 +211,9 @@ export function usePinnedToView(
return return
} }
// Use tldraw's tick event for synchronous updates with the render cycle // Function to update pinned position - called synchronously on camera changes
// This ensures the shape position is updated BEFORE rendering, eliminating visual lag const updatePinnedPosition = () => {
const handleTick = () => { if (isUpdatingRef.current || !editor || !shapeId) {
if (isUpdatingRef.current || !editor || !shapeId || !isPinned) {
return return
} }
@ -236,27 +230,21 @@ export function usePinnedToView(
const currentCamera = editor.getCamera() 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
// For preset positions, account for the visual scale
const visualScale = pinnedAtZoom / currentCamera.z
const visualWidth = shapeWidth * visualScale
const visualHeight = shapeHeight * visualScale
if (position === 'top-center') { if (position === 'top-center') {
pinnedScreenPos = { pinnedScreenPos = {
x: viewport.x + (viewport.w / 2) - (visualWidth * currentCamera.z / 2) + offsetX, x: viewport.x + (viewport.w / 2) - (shapeWidth * currentCamera.z / 2) + offsetX,
y: viewport.y + offsetY, y: viewport.y + offsetY,
} }
} else if (position === 'bottom-center') { } else if (position === 'bottom-center') {
pinnedScreenPos = { pinnedScreenPos = {
x: viewport.x + (viewport.w / 2) - (visualWidth * currentCamera.z / 2) + offsetX, x: viewport.x + (viewport.w / 2) - (shapeWidth * currentCamera.z / 2) + offsetX,
y: viewport.y + viewport.h - (visualHeight * currentCamera.z) - offsetY, y: viewport.y + viewport.h - (shapeHeight * currentCamera.z) - offsetY,
} }
} else if (position === 'center') { } else if (position === 'center') {
pinnedScreenPos = { pinnedScreenPos = {
x: viewport.x + (viewport.w / 2) - (visualWidth * currentCamera.z / 2) + offsetX, x: viewport.x + (viewport.w / 2) - (shapeWidth * currentCamera.z / 2) + offsetX,
y: viewport.y + (viewport.h / 2) - (visualHeight * currentCamera.z / 2) + offsetY, y: viewport.y + (viewport.h / 2) - (shapeHeight * currentCamera.z / 2) + offsetY,
} }
} else { } else {
pinnedScreenPos = pinnedScreenPositionRef.current! pinnedScreenPos = pinnedScreenPositionRef.current!
@ -272,40 +260,47 @@ export function usePinnedToView(
// Convert screen position back to page coordinates // Convert screen position back to page coordinates
const newPagePoint = editor.screenToPage(pinnedScreenPos) const newPagePoint = editor.screenToPage(pinnedScreenPos)
// Check if position needs updating (with small tolerance to avoid unnecessary updates) // Always update - no threshold, for maximum responsiveness
const deltaX = Math.abs(currentShape.x - newPagePoint.x) isUpdatingRef.current = true
const deltaY = Math.abs(currentShape.y - newPagePoint.y)
if (deltaX > 0.01 || deltaY > 0.01) { editor.updateShape({
isUpdatingRef.current = true id: shapeId as TLShapeId,
type: currentShape.type,
x: newPagePoint.x,
y: newPagePoint.y,
})
editor.updateShape({ isUpdatingRef.current = false
id: shapeId as TLShapeId,
type: currentShape.type,
x: newPagePoint.x,
y: newPagePoint.y,
})
isUpdatingRef.current = false
}
} catch (error) { } catch (error) {
console.error('Error updating pinned shape position:', error) console.error('Error updating pinned shape position:', error)
isUpdatingRef.current = false isUpdatingRef.current = false
} }
} }
// Subscribe to tick event for synchronous updates // Use store.listen to react immediately to camera changes
editor.on('tick', handleTick) // This is more immediate than 'tick' as it fires synchronously when the store changes
const unsubscribe = editor.store.listen(
(entry) => {
// Only react to camera changes
const dominated = Object.entries(entry.changes.updated).some(
([, [, record]]) => record.typeName === 'camera'
)
if (dominated && !isUpdatingRef.current) {
updatePinnedPosition()
}
},
{ source: 'all', scope: 'document' }
)
// Also do an immediate update to sync position // Also do an immediate update to sync position
handleTick() updatePinnedPosition()
return () => { return () => {
if (driftAnimationRef.current) { if (driftAnimationRef.current) {
cancelAnimationFrame(driftAnimationRef.current) cancelAnimationFrame(driftAnimationRef.current)
driftAnimationRef.current = null driftAnimationRef.current = null
} }
editor.off('tick', handleTick) unsubscribe()
} }
}, [editor, shapeId, isPinned, position, offsetX, offsetY]) }, [editor, shapeId, isPinned, position, offsetX, offsetY])
} }