From 6e29384a7915621b901e352187587efe28ee4a5d Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Mon, 15 Dec 2025 20:00:29 -0500 Subject: [PATCH] fix: improve pinned shape zoom behavior - maintain constant visual size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pinned shapes now stay exactly the same size visually during zoom - Uses CSS transform scale instead of changing w/h props - Content inside shapes renders identically at all zoom levels - Stores pinnedAtZoom in shape.meta for reference - Returns to original position smoothly when unpinned - Removed size-changing logic that was causing content reflow issues The transform approach ensures text, UI elements, and all content inside pinned shapes remain pixel-perfect regardless of canvas zoom. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/components/StandardizedToolWrapper.tsx | 66 +++- src/hooks/usePinnedToView.ts | 356 ++++++++------------- 2 files changed, 204 insertions(+), 218 deletions(-) diff --git a/src/components/StandardizedToolWrapper.tsx b/src/components/StandardizedToolWrapper.tsx index bc11545..a62c9d3 100644 --- a/src/components/StandardizedToolWrapper.tsx +++ b/src/components/StandardizedToolWrapper.tsx @@ -64,6 +64,8 @@ export interface StandardizedToolWrapperProps { onTagsChange?: (tags: string[]) => void /** Whether tags can be edited */ tagsEditable?: boolean + /** Zoom level when the shape was pinned (for constant visual size) */ + pinnedAtZoom?: number } /** @@ -90,13 +92,67 @@ export const StandardizedToolWrapper: React.FC = ( tags = [], onTagsChange, tagsEditable = true, + pinnedAtZoom, }) => { const [isHoveringHeader, setIsHoveringHeader] = useState(false) const [isEditingTags, setIsEditingTags] = useState(false) const [editingTagInput, setEditingTagInput] = useState('') + const [zoomScale, setZoomScale] = useState(1) const tagInputRef = useRef(null) const isDarkMode = useIsDarkMode() + // Calculate zoom compensation scale when pinned + useEffect(() => { + if (!isPinnedToView || !editor) { + setZoomScale(1) + return + } + + // Get pinnedAtZoom from shape meta if not passed as prop + let originalZoom = pinnedAtZoom + if (!originalZoom && shapeId) { + const shape = editor.getShape(shapeId) + originalZoom = (shape?.meta as any)?.pinnedAtZoom + } + + if (!originalZoom) { + setZoomScale(1) + return + } + + const updateScale = () => { + const currentZoom = editor.getCamera().z + // Scale inversely to zoom to maintain constant visual size + const scale = originalZoom / currentZoom + setZoomScale(scale) + } + + // Initial scale calculation + updateScale() + + // Listen for camera changes + const handleChange = () => { + updateScale() + } + + // Use requestAnimationFrame for smooth updates + let rafId: number | null = null + const throttledUpdate = () => { + if (rafId) return + rafId = requestAnimationFrame(() => { + updateScale() + rafId = null + }) + } + + editor.on('change' as any, throttledUpdate) + + return () => { + editor.off('change' as any, throttledUpdate) + if (rafId) cancelAnimationFrame(rafId) + } + }, [isPinnedToView, editor, shapeId, pinnedAtZoom]) + // Handle Esc key to exit maximize mode useEffect(() => { if (!isMaximized || !onMaximize) return @@ -173,6 +229,11 @@ export const StandardizedToolWrapper: React.FC = ( ? `${primaryColor}15` // 15% opacity : `${primaryColor}10` // 10% opacity + // Calculate transform for pinned shapes + const pinnedTransform = isPinnedToView && zoomScale !== 1 + ? `scale(${zoomScale})` + : undefined + const wrapperStyle: React.CSSProperties = { width: typeof width === 'number' ? `${width}px` : width, height: isMinimized ? 40 : (typeof height === 'number' ? `${height}px` : height), // Minimized height is just the header @@ -188,8 +249,11 @@ export const StandardizedToolWrapper: React.FC = ( fontFamily: "Inter, sans-serif", position: 'relative', pointerEvents: 'auto', - transition: 'height 0.2s ease, box-shadow 0.2s ease', + transition: isPinnedToView ? 'box-shadow 0.2s ease' : 'height 0.2s ease, box-shadow 0.2s ease', boxSizing: 'border-box', + // Apply zoom compensation transform for pinned shapes + transform: pinnedTransform, + transformOrigin: 'top left', } const headerStyle: React.CSSProperties = { diff --git a/src/hooks/usePinnedToView.ts b/src/hooks/usePinnedToView.ts index c1341c9..5451753 100644 --- a/src/hooks/usePinnedToView.ts +++ b/src/hooks/usePinnedToView.ts @@ -19,7 +19,8 @@ export interface PinnedViewOptions { /** * Hook to manage shapes pinned to the viewport. - * When a shape is pinned, it stays in the same screen position as the camera moves. + * 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. */ export function usePinnedToView( editor: Editor | null, @@ -30,13 +31,10 @@ export function usePinnedToView( const { position = 'current', offsetY = 0, offsetX = 0 } = options const pinnedScreenPositionRef = useRef<{ x: number; y: number } | null>(null) const originalCoordinatesRef = useRef<{ x: number; y: number } | null>(null) - const originalSizeRef = useRef<{ w: number; h: number } | null>(null) - const originalZoomRef = useRef(null) const wasPinnedRef = useRef(false) const isUpdatingRef = useRef(false) const animationFrameRef = useRef(null) const lastCameraRef = useRef<{ x: number; y: number; z: number } | null>(null) - const pendingUpdateRef = useRef<{ x: number; y: number } | null>(null) const lastUpdateTimeRef = useRef(0) const driftAnimationRef = useRef(null) @@ -48,19 +46,26 @@ export function usePinnedToView( const shape = editor.getShape(shapeId as TLShapeId) if (!shape) return - // If just became pinned (transition from false to true), capture the current screen position + // If just became pinned (transition from false to true) if (isPinned && !wasPinnedRef.current) { // Store the original coordinates - these will be restored when unpinned originalCoordinatesRef.current = { x: shape.x, y: shape.y } - - // Store the original size and zoom - needed to maintain constant visual size + + // Store the original zoom level in shape meta for CSS transform calculation const currentCamera = editor.getCamera() - originalSizeRef.current = { - w: (shape.props as any).w || 0, - h: (shape.props as any).h || 0 - } - originalZoomRef.current = currentCamera.z - + + // Store original zoom in meta so StandardizedToolWrapper can access it + editor.updateShape({ + id: shapeId as TLShapeId, + type: shape.type, + meta: { + ...shape.meta, + pinnedAtZoom: currentCamera.z, + originalX: shape.x, + originalY: shape.y, + }, + }) + // Calculate screen position based on position option let screenPoint: { x: number; y: number } const viewport = editor.getViewportScreenBounds() @@ -68,19 +73,16 @@ export function usePinnedToView( const shapeHeight = (shape.props as any).h || 0 if (position === 'top-center') { - // Center horizontally at the top of the viewport screenPoint = { x: viewport.x + (viewport.w / 2) - (shapeWidth * currentCamera.z / 2) + offsetX, y: viewport.y + offsetY, } } else if (position === 'bottom-center') { - // Center horizontally at the bottom of the viewport screenPoint = { x: viewport.x + (viewport.w / 2) - (shapeWidth * currentCamera.z / 2) + offsetX, y: viewport.y + viewport.h - (shapeHeight * currentCamera.z) - offsetY, } } else if (position === 'center') { - // Center in the viewport screenPoint = { x: viewport.x + (viewport.w / 2) - (shapeWidth * currentCamera.z / 2) + offsetX, y: viewport.y + (viewport.h / 2) - (shapeHeight * currentCamera.z / 2) + offsetY, @@ -93,20 +95,16 @@ export function usePinnedToView( pinnedScreenPositionRef.current = { x: screenPoint.x, y: screenPoint.y } lastCameraRef.current = { ...currentCamera } - - // Bring the shape to the front using tldraw's proper index functions + + // Bring the shape to the front try { const allShapes = editor.getCurrentPageShapes() - - // Find the highest index among all shapes let highestIndex = shape.index for (const s of allShapes) { if (s.id !== shape.id && s.index > highestIndex) { highestIndex = s.index } } - - // Only update if we need to move higher if (highestIndex > shape.index) { const newIndex = getIndexAbove(highestIndex) editor.updateShape({ @@ -127,133 +125,102 @@ export function usePinnedToView( cancelAnimationFrame(animationFrameRef.current) animationFrameRef.current = null } - - // Animate back to original coordinates and size with a calm drift - if (originalCoordinatesRef.current && originalSizeRef.current && originalZoomRef.current !== null) { - const currentShape = editor.getShape(shapeId as TLShapeId) - if (currentShape) { - const startX = currentShape.x - const startY = currentShape.y - const targetX = originalCoordinatesRef.current.x - const targetY = originalCoordinatesRef.current.y - - // Return to the exact original size (not calculated based on current zoom) - const originalW = originalSizeRef.current.w - const originalH = originalSizeRef.current.h - - // Use the original size directly - const targetW = originalW - const targetH = originalH - - const currentW = (currentShape.props as any).w || originalW - const currentH = (currentShape.props as any).h || originalH - - const startW = currentW - const startH = currentH - - // Only animate if there's a meaningful distance to travel or size change - const distance = Math.sqrt( - Math.pow(targetX - startX, 2) + Math.pow(targetY - startY, 2) - ) - const sizeChange = Math.abs(targetW - startW) > 0.1 || Math.abs(targetH - startH) > 0.1 - - if (distance > 1 || sizeChange) { - // Animation parameters - const duration = 600 // 600ms for a calm drift - const startTime = performance.now() - - // Easing function: ease-out for a calm deceleration - const easeOutCubic = (t: number): number => { - return 1 - Math.pow(1 - t, 3) - } - - const animateDrift = (currentTime: number) => { - const elapsed = currentTime - startTime - const progress = Math.min(elapsed / duration, 1) // Clamp to 0-1 - const easedProgress = easeOutCubic(progress) - - // Interpolate position - const currentX = startX + (targetX - startX) * easedProgress - const currentY = startY + (targetY - startY) * easedProgress - - // Interpolate size - const currentW = startW + (targetW - startW) * easedProgress - const currentH = startH + (targetH - startH) * easedProgress - - try { - editor.updateShape({ - id: shapeId as TLShapeId, - type: currentShape.type, - x: currentX, - y: currentY, - props: { - ...currentShape.props, - w: currentW, - h: currentH, - }, - }) - } catch (error) { - console.error('Error during drift animation:', error) - driftAnimationRef.current = null - return - } - - // Continue animation if not complete - if (progress < 1) { - driftAnimationRef.current = requestAnimationFrame(animateDrift) - } else { - // Animation complete - ensure we're exactly at target - try { - editor.updateShape({ - id: shapeId as TLShapeId, - type: currentShape.type, - x: targetX, - y: targetY, - props: { - ...currentShape.props, - w: targetW, - h: targetH, - }, - }) - console.log(`📍 Drifted back to original coordinates: (${targetX}, ${targetY}) and size: (${targetW}, ${targetH})`) - } catch (error) { - console.error('Error setting final position/size:', error) - } - driftAnimationRef.current = null - } - } - - // Start the animation - driftAnimationRef.current = requestAnimationFrame(animateDrift) - } else { - // Distance is too small, just set directly + + // Get original coordinates from meta + const currentShape = editor.getShape(shapeId as TLShapeId) + if (currentShape) { + const originalX = (currentShape.meta as any)?.originalX ?? originalCoordinatesRef.current?.x ?? currentShape.x + const originalY = (currentShape.meta as any)?.originalY ?? originalCoordinatesRef.current?.y ?? currentShape.y + + const startX = currentShape.x + const startY = currentShape.y + const targetX = originalX + const targetY = originalY + + // Calculate distance + const distance = Math.sqrt( + Math.pow(targetX - startX, 2) + Math.pow(targetY - startY, 2) + ) + + if (distance > 1) { + // Animation parameters + const duration = 600 // 600ms for a calm drift + const startTime = performance.now() + + const easeOutCubic = (t: number): number => 1 - Math.pow(1 - t, 3) + + const animateDrift = (currentTime: number) => { + const elapsed = currentTime - startTime + const progress = Math.min(elapsed / duration, 1) + const easedProgress = easeOutCubic(progress) + + const currentX = startX + (targetX - startX) * easedProgress + const currentY = startY + (targetY - startY) * easedProgress + try { editor.updateShape({ id: shapeId as TLShapeId, type: currentShape.type, - x: targetX, - y: targetY, - props: { - ...currentShape.props, - w: targetW, - h: targetH, - }, + x: currentX, + y: currentY, }) } catch (error) { - console.error('Error restoring original coordinates/size:', error) + console.error('Error during drift animation:', error) + driftAnimationRef.current = null + return } + + if (progress < 1) { + driftAnimationRef.current = requestAnimationFrame(animateDrift) + } else { + // Animation complete - clear pinned meta data + try { + editor.updateShape({ + id: shapeId as TLShapeId, + type: currentShape.type, + x: targetX, + y: targetY, + meta: { + ...currentShape.meta, + pinnedAtZoom: undefined, + originalX: undefined, + originalY: undefined, + }, + }) + } catch (error) { + console.error('Error setting final position:', error) + } + driftAnimationRef.current = null + } + } + + driftAnimationRef.current = requestAnimationFrame(animateDrift) + } else { + // Distance is too small, just set directly and clear meta + try { + editor.updateShape({ + id: shapeId as TLShapeId, + type: currentShape.type, + x: targetX, + y: targetY, + meta: { + ...currentShape.meta, + pinnedAtZoom: undefined, + originalX: undefined, + originalY: undefined, + }, + }) + } catch (error) { + console.error('Error restoring original coordinates:', error) } } } - - // Clear refs after a short delay to allow animation to start + + // Clear refs setTimeout(() => { pinnedScreenPositionRef.current = null originalCoordinatesRef.current = null - originalSizeRef.current = null - originalZoomRef.current = null lastCameraRef.current = null - pendingUpdateRef.current = null }, 50) } @@ -263,18 +230,15 @@ export function usePinnedToView( return } - // Use requestAnimationFrame for smooth, continuous updates - // Throttle updates to reduce jitter + // Update position (NOT size) to maintain screen position const updatePinnedPosition = (timestamp: number) => { - if (isUpdatingRef.current) { - animationFrameRef.current = requestAnimationFrame(updatePinnedPosition) + if (isUpdatingRef.current || !editor || !shapeId || !isPinned) { + if (isPinned) { + animationFrameRef.current = requestAnimationFrame(updatePinnedPosition) + } return } - - if (!editor || !shapeId || !isPinned) { - return - } - + const currentShape = editor.getShape(shapeId as TLShapeId) if (!currentShape) { animationFrameRef.current = requestAnimationFrame(updatePinnedPosition) @@ -284,29 +248,34 @@ export function usePinnedToView( const currentCamera = editor.getCamera() const lastCamera = lastCameraRef.current - // For preset positions (top-center, etc.), always recalculate based on viewport - // For 'current' position, use the stored screen position + // Calculate pinned screen position let pinnedScreenPos: { x: number; y: number } if (position !== 'current') { const viewport = editor.getViewportScreenBounds() const shapeWidth = (currentShape.props as any).w || 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') { pinnedScreenPos = { - x: viewport.x + (viewport.w / 2) - (shapeWidth * currentCamera.z / 2) + offsetX, + x: viewport.x + (viewport.w / 2) - (visualWidth * currentCamera.z / 2) + offsetX, y: viewport.y + offsetY, } } else if (position === 'bottom-center') { pinnedScreenPos = { - x: viewport.x + (viewport.w / 2) - (shapeWidth * currentCamera.z / 2) + offsetX, - y: viewport.y + viewport.h - (shapeHeight * currentCamera.z) - offsetY, + x: viewport.x + (viewport.w / 2) - (visualWidth * currentCamera.z / 2) + offsetX, + y: viewport.y + viewport.h - (visualHeight * currentCamera.z) - offsetY, } } else if (position === 'center') { pinnedScreenPos = { - x: viewport.x + (viewport.w / 2) - (shapeWidth * currentCamera.z / 2) + offsetX, - y: viewport.y + (viewport.h / 2) - (shapeHeight * currentCamera.z / 2) + offsetY, + x: viewport.x + (viewport.w / 2) - (visualWidth * currentCamera.z / 2) + offsetX, + y: viewport.y + (viewport.h / 2) - (visualHeight * currentCamera.z / 2) + offsetY, } } else { pinnedScreenPos = pinnedScreenPositionRef.current! @@ -319,124 +288,78 @@ export function usePinnedToView( pinnedScreenPos = pinnedScreenPositionRef.current } - // Check if camera has changed significantly + // Check if camera has changed const cameraChanged = !lastCamera || ( Math.abs(currentCamera.x - lastCamera.x) > 0.1 || Math.abs(currentCamera.y - lastCamera.y) > 0.1 || Math.abs(currentCamera.z - lastCamera.z) > 0.001 ) - // For preset positions, always check for updates (viewport might have changed) const shouldUpdate = cameraChanged || position !== 'current' if (shouldUpdate) { - // Throttle updates to max 60fps (every ~16ms) const timeSinceLastUpdate = timestamp - lastUpdateTimeRef.current const minUpdateInterval = 16 // ~60fps if (timeSinceLastUpdate >= minUpdateInterval) { try { - // Convert the pinned screen position back to page coordinates + // Convert screen position back to page coordinates const newPagePoint = editor.screenToPage(pinnedScreenPos) - - // Calculate delta + const deltaX = Math.abs(currentShape.x - newPagePoint.x) const deltaY = Math.abs(currentShape.y - newPagePoint.y) - - // Check if zoom changed - if so, adjust size to maintain constant visual size - const zoomChanged = lastCamera && Math.abs(currentCamera.z - lastCamera.z) > 0.001 - let needsSizeUpdate = false - let newW = (currentShape.props as any).w - let newH = (currentShape.props as any).h - - if (zoomChanged && originalSizeRef.current && originalZoomRef.current !== null) { - // Calculate the size needed to maintain constant visual size - // Visual size = page size * zoom - // To keep visual size constant: new_page_size = (original_page_size * original_zoom) / new_zoom - const originalW = originalSizeRef.current.w - const originalH = originalSizeRef.current.h - const originalZoom = originalZoomRef.current - const currentZoom = currentCamera.z - - newW = (originalW * originalZoom) / currentZoom - newH = (originalH * originalZoom) / currentZoom - - const currentW = (currentShape.props as any).w || originalW - const currentH = (currentShape.props as any).h || originalH - - // Check if size needs updating - needsSizeUpdate = Math.abs(newW - currentW) > 0.1 || Math.abs(newH - currentH) > 0.1 - } - - // Only update if the position would actually change significantly or size needs updating - if (deltaX > 0.5 || deltaY > 0.5 || needsSizeUpdate) { + + // 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 - - // Batch the update using editor.batch for smoother updates + editor.batch(() => { - const updateData: any = { - id: shapeId, + editor.updateShape({ + id: shapeId as TLShapeId, type: currentShape.type, x: newPagePoint.x, y: newPagePoint.y, - } - - // Only update size if it changed - if (needsSizeUpdate) { - updateData.props = { - ...currentShape.props, - w: newW, - h: newH, - } - } - - editor.updateShape(updateData) + }) }) - + lastUpdateTimeRef.current = timestamp isUpdatingRef.current = false } lastCameraRef.current = { ...currentCamera } } catch (error) { - console.error('Error updating pinned shape position/size:', error) + console.error('Error updating pinned shape position:', error) isUpdatingRef.current = false } } } - // Continue monitoring animationFrameRef.current = requestAnimationFrame(updatePinnedPosition) } - // Start the animation loop lastUpdateTimeRef.current = performance.now() animationFrameRef.current = requestAnimationFrame(updatePinnedPosition) - // Also listen for shape changes (in case user drags the shape while pinned) - // This updates the pinned position to the new location + // Listen for shape changes (user dragging while pinned) const handleShapeChange = (event: any) => { - if (isUpdatingRef.current) return // Don't update if we're programmatically moving it - - if (!editor || !shapeId || !isPinned) return - - // Only respond to changes that affect this specific shape + 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 to the shape's current screen position + // 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() } } - // Listen for shape updates (when user drags the shape) editor.on('change' as any, handleShapeChange) return () => { @@ -452,4 +375,3 @@ export function usePinnedToView( } }, [editor, shapeId, isPinned, position, offsetX, offsetY]) } -