fix: properly reset pin state to prevent shape jumping on re-pin

When pinning a shape again after unpinning, leftover state from the
previous session was causing the shape to jump/resize unexpectedly.

Changes:
- Add clearPinState() helper to reset all refs and cancel animations
- Add cleanShapeMeta() helper to remove all pin-related meta properties
- Clear all state immediately when pinning starts (before setting new state)
- Clear refs immediately when unpinning (not in setTimeout)
- Remove pinnedAtZoom from meta cleanup (legacy from CSS scaling)
- Don't call updatePinnedPosition() on pin start - shape is already
  at correct position, only need to listen for future camera changes

🤖 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:57:21 -05:00
parent 1bde78bb29
commit 678df2bbca
1 changed files with 58 additions and 26 deletions

View File

@ -46,17 +46,52 @@ export function usePinnedToView(
const shape = editor.getShape(shapeId as TLShapeId) const shape = editor.getShape(shapeId as TLShapeId)
if (!shape) return if (!shape) return
// Helper to clear all pin-related state
const clearPinState = () => {
pinnedScreenPositionRef.current = null
originalCoordinatesRef.current = null
isUpdatingRef.current = false
if (driftAnimationRef.current) {
cancelAnimationFrame(driftAnimationRef.current)
driftAnimationRef.current = null
}
}
// Helper to clean shape meta of any pin-related properties
const cleanShapeMeta = (currentShape: any) => {
const meta = currentShape.meta || {}
// Remove all pin-related meta properties
const { originalX, originalY, pinnedAtZoom, ...cleanMeta } = meta as any
// Only update if there were pin properties to remove
if ('originalX' in meta || 'originalY' in meta || 'pinnedAtZoom' in meta) {
try {
editor.updateShape({
id: shapeId as TLShapeId,
type: currentShape.type,
meta: cleanMeta,
})
} catch (e) {
// Ignore errors during cleanup
}
}
return cleanMeta
}
// If just became pinned (transition from false to true) // If just became pinned (transition from false to true)
if (isPinned && !wasPinnedRef.current) { if (isPinned && !wasPinnedRef.current) {
// Clear any leftover state from previous pin sessions
clearPinState()
// 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 original position in meta for unpinning // Store original position in meta for unpinning (clean any old meta first)
const cleanMeta = cleanShapeMeta(shape)
editor.updateShape({ editor.updateShape({
id: shapeId as TLShapeId, id: shapeId as TLShapeId,
type: shape.type, type: shape.type,
meta: { meta: {
...shape.meta, ...cleanMeta,
originalX: shape.x, originalX: shape.x,
originalY: shape.y, originalY: shape.y,
}, },
@ -85,7 +120,7 @@ export function usePinnedToView(
y: viewport.y + (viewport.h / 2) - (shapeHeight * currentCamera.z / 2) + offsetY, y: viewport.y + (viewport.h / 2) - (shapeHeight * currentCamera.z / 2) + offsetY,
} }
} else { } else {
// Default: use current position // Default: use current position - shape stays exactly where it is
const pagePoint = { x: shape.x, y: shape.y } const pagePoint = { x: shape.x, y: shape.y }
screenPoint = editor.pageToScreen(pagePoint) screenPoint = editor.pageToScreen(pagePoint)
} }
@ -116,6 +151,12 @@ 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 animations
if (driftAnimationRef.current) {
cancelAnimationFrame(driftAnimationRef.current)
driftAnimationRef.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) {
@ -132,6 +173,10 @@ export function usePinnedToView(
Math.pow(targetX - startX, 2) + Math.pow(targetY - startY, 2) Math.pow(targetX - startX, 2) + Math.pow(targetY - startY, 2)
) )
// Immediately clear refs so next pin session starts fresh
pinnedScreenPositionRef.current = null
originalCoordinatesRef.current = null
if (distance > 1) { if (distance > 1) {
// Animation parameters // Animation parameters
const duration = 600 // 600ms for a calm drift const duration = 600 // 600ms for a calm drift
@ -164,18 +209,7 @@ export function usePinnedToView(
driftAnimationRef.current = requestAnimationFrame(animateDrift) driftAnimationRef.current = requestAnimationFrame(animateDrift)
} else { } else {
// Animation complete - clear pinned meta data // Animation complete - clear pinned meta data
try { cleanShapeMeta(currentShape)
const { originalX, originalY, ...cleanMeta } = (currentShape.meta || {}) as any
editor.updateShape({
id: shapeId as TLShapeId,
type: currentShape.type,
x: targetX,
y: targetY,
meta: cleanMeta,
})
} catch (error) {
console.error('Error setting final position:', error)
}
driftAnimationRef.current = null driftAnimationRef.current = null
} }
} }
@ -184,25 +218,22 @@ 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 {
const { 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,
x: targetX, x: targetX,
y: targetY, y: targetY,
meta: cleanMeta,
}) })
cleanShapeMeta(currentShape)
} catch (error) { } catch (error) {
console.error('Error restoring original coordinates:', error) console.error('Error restoring original coordinates:', error)
} }
} }
} } else {
// Shape doesn't exist, just clear refs
// Clear refs
setTimeout(() => {
pinnedScreenPositionRef.current = null pinnedScreenPositionRef.current = null
originalCoordinatesRef.current = null originalCoordinatesRef.current = null
}, 50) }
} }
wasPinnedRef.current = isPinned wasPinnedRef.current = isPinned
@ -282,18 +313,19 @@ export function usePinnedToView(
const unsubscribe = editor.store.listen( const unsubscribe = editor.store.listen(
(entry) => { (entry) => {
// Only react to camera changes // Only react to camera changes
const dominated = Object.entries(entry.changes.updated).some( const hasCamera = Object.entries(entry.changes.updated).some(
([, [, record]]) => record.typeName === 'camera' ([, [, record]]) => record.typeName === 'camera'
) )
if (dominated && !isUpdatingRef.current) { if (hasCamera && !isUpdatingRef.current) {
updatePinnedPosition() updatePinnedPosition()
} }
}, },
{ source: 'all', scope: 'document' } { source: 'all', scope: 'document' }
) )
// Also do an immediate update to sync position // Don't call updatePinnedPosition immediately - the shape is already at
updatePinnedPosition() // the correct position since we just captured its current screen position
// Only start listening for camera changes
return () => { return () => {
if (driftAnimationRef.current) { if (driftAnimationRef.current) {