| 1 | import { useCallback, useEffect, useRef, useState } from 'react' |
| 2 | import { selectPreviewWindowIds } from './preview-window-utils' |
| 3 | |
| 4 | interface UsePreviewWindowOptions { |
| 5 | enabled: boolean |
| 6 | itemIds: readonly string[] |
| 7 | limit: number |
| 8 | } |
| 9 | |
| 10 | interface UsePreviewWindowResult { |
| 11 | activePreviewIds: Set<string> |
| 12 | viewportRef: React.RefObject<HTMLDivElement | null> |
| 13 | schedulePreviewWindowUpdate: () => void |
| 14 | } |
| 15 | |
| 16 | export function usePreviewWindow({ |
| 17 | enabled, |
| 18 | itemIds, |
| 19 | limit |
| 20 | }: UsePreviewWindowOptions): UsePreviewWindowResult { |
| 21 | const [activePreviewIds, setActivePreviewIds] = useState<Set<string>>(() => new Set()) |
| 22 | const viewportRef = useRef<HTMLDivElement>(null) |
| 23 | const updateRafRef = useRef<number | null>(null) |
| 24 | |
| 25 | const updatePreviewWindow = useCallback((): void => { |
| 26 | const viewport = viewportRef.current |
| 27 | if (!enabled || !viewport || itemIds.length === 0) { |
| 28 | setActivePreviewIds((current) => (current.size === 0 ? current : new Set())) |
| 29 | return |
| 30 | } |
| 31 | |
| 32 | const viewportRect = viewport.getBoundingClientRect() |
| 33 | const viewportCenterX = viewportRect.left + viewportRect.width / 2 |
| 34 | const viewportCenterY = viewportRect.top + viewportRect.height / 2 |
| 35 | const candidates = Array.from( |
| 36 | viewport.querySelectorAll<HTMLElement>('[data-preview-window-id]') |
| 37 | ).map((node) => { |
| 38 | const rect = node.getBoundingClientRect() |
| 39 | return { |
| 40 | id: node.dataset.previewWindowId || '', |
| 41 | distance: Math.hypot( |
| 42 | rect.left + rect.width / 2 - viewportCenterX, |
| 43 | rect.top + rect.height / 2 - viewportCenterY |
| 44 | ) |
| 45 | } |
| 46 | }) |
| 47 | const next = selectPreviewWindowIds(candidates, limit) |
| 48 | |
| 49 | setActivePreviewIds((current) => { |
| 50 | if (current.size === next.size && Array.from(next).every((id) => current.has(id))) { |
| 51 | return current |
| 52 | } |
| 53 | return next |
| 54 | }) |
| 55 | }, [enabled, itemIds, limit]) |
| 56 | |
| 57 | const schedulePreviewWindowUpdate = useCallback((): void => { |
| 58 | if (updateRafRef.current !== null) return |
| 59 | updateRafRef.current = window.requestAnimationFrame(() => { |
| 60 | updateRafRef.current = null |
| 61 | updatePreviewWindow() |
| 62 | }) |
| 63 | }, [updatePreviewWindow]) |
| 64 | |
| 65 | useEffect(() => { |
| 66 | schedulePreviewWindowUpdate() |
| 67 | window.addEventListener('resize', schedulePreviewWindowUpdate) |
| 68 | return () => { |
| 69 | window.removeEventListener('resize', schedulePreviewWindowUpdate) |
| 70 | if (updateRafRef.current !== null) { |
| 71 | window.cancelAnimationFrame(updateRafRef.current) |
| 72 | updateRafRef.current = null |
| 73 | } |
| 74 | } |
| 75 | }, [schedulePreviewWindowUpdate]) |
| 76 | |
| 77 | return { activePreviewIds, viewportRef, schedulePreviewWindowUpdate } |
| 78 | } |
| 79 |