diff --git a/frontend/src/hooks/useAnchoredOverlayPosition.ts b/frontend/src/hooks/useAnchoredOverlayPosition.ts new file mode 100644 index 0000000..70c215c --- /dev/null +++ b/frontend/src/hooks/useAnchoredOverlayPosition.ts @@ -0,0 +1,92 @@ +import { useState, useEffect, useRef } from 'react'; +import type { Viewport } from 'pixi-viewport'; +import type { SceneManager } from '../canvas/SceneManager'; +import { getPointAnchorWorld } from '../canvas/reviewAnchors'; + +export interface AnchoredOverlayAnchor { + objectId: string; + pinX: number; + pinY: number; +} + +export interface AnchoredOverlayPosition { + visible: boolean; + screenX: number; + screenY: number; + /** 'right' if the composer fits to the right of the pin, 'left' if it would overflow */ + placement: 'right' | 'left'; +} + +const COMPOSER_WIDTH = 260; +const OFFSET_X = 16; // gap between pin and composer edge + +/** + * Converts a world-space anchor (objectId + relative pin coords) to screen + * coordinates. Recomputes on viewport move/zoom and when sceneVersion changes + * (object moved/resized). Returns { visible: false } when anchor is null or + * target object is missing. + * + * Reusable for any anchored overlay: inline composer, quick reply, popovers. + * + * @param sceneVersion - A counter that increments whenever scene objects move + * or resize. Editor.tsx already tracks this as the value returned by + * useSyncExternalStore or an equivalent counter bumped by onChange. Pass it + * here so the hook recomputes when the target object's world bounds change. + */ +export function useAnchoredOverlayPosition( + viewport: Viewport | null, + scene: SceneManager | null, + anchor: AnchoredOverlayAnchor | null, + sceneVersion: number = 0, +): AnchoredOverlayPosition { + const [pos, setPos] = useState({ + visible: false, screenX: 0, screenY: 0, placement: 'right', + }); + const rafRef = useRef(null); + + useEffect(() => { + if (!viewport || !scene || !anchor) { + setPos({ visible: false, screenX: 0, screenY: 0, placement: 'right' }); + return; + } + + const compute = () => { + const world = getPointAnchorWorld(scene, anchor.objectId, anchor.pinX, anchor.pinY); + if (!world) { + setPos((p) => p.visible ? { visible: false, screenX: 0, screenY: 0, placement: 'right' } : p); + return; + } + const screen = viewport.toScreen(world.x, world.y); + // Determine if composer fits to the right; if not, place left + const rightEdge = screen.x + OFFSET_X + COMPOSER_WIDTH; + const placement: 'right' | 'left' = rightEdge > viewport.screenWidth ? 'left' : 'right'; + + setPos({ visible: true, screenX: screen.x, screenY: screen.y, placement }); + }; + + // Initial compute + compute(); + + // Recompute on viewport move/zoom (RAF-throttled) + const onMoved = () => { + if (rafRef.current) return; + rafRef.current = requestAnimationFrame(() => { + rafRef.current = null; + compute(); + }); + }; + viewport.on('moved', onMoved); + viewport.on('wheel-scroll', onMoved); + + return () => { + viewport.off('moved', onMoved); + viewport.off('wheel-scroll', onMoved); + if (rafRef.current) { + cancelAnimationFrame(rafRef.current); + rafRef.current = null; + } + }; + }, [viewport, scene, anchor?.objectId, anchor?.pinX, anchor?.pinY, sceneVersion]); + + return pos; +}