93 lines
3.1 KiB
TypeScript
93 lines
3.1 KiB
TypeScript
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<AnchoredOverlayPosition>({
|
|
visible: false, screenX: 0, screenY: 0, placement: 'right',
|
|
});
|
|
const rafRef = useRef<number | null>(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;
|
|
}
|