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:
parent
72c2e52ae7
commit
1bde78bb29
|
|
@ -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])
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue