|
| 1 | +import { |
| 2 | + createContext, |
| 3 | + useCallback, |
| 4 | + useContext, |
| 5 | + useEffect, |
| 6 | + useRef, |
| 7 | + useState, |
| 8 | +} from 'react'; |
| 9 | +import type { PropsWithChildren } from 'react'; |
| 10 | + |
| 11 | +/** |
| 12 | + * Slot geometry — an opt-in module that reports which registered "slots" are visually **obscured**. |
| 13 | + * |
| 14 | + * The ChatView `LayoutController` is purely logical: slots, bindings, a `hidden` flag, history — it |
| 15 | + * has no idea where slots land on screen. Whether one panel visually *covers* another is a physical |
| 16 | + * concern owned by the app's CSS (e.g. at narrow widths a secondary panel might be laid out at full |
| 17 | + * width, collapsing the primary one). This module is the missing physical half: |
| 18 | + * |
| 19 | + * - App code registers the DOM element backing each slot (`useRegisterSlotGeometry`). |
| 20 | + * - It observes those elements (`ResizeObserver` + window resize) and measures real rects. |
| 21 | + * - It reports which slots are currently obscured — collapsed to ~0 area, or covered by another |
| 22 | + * registered slot past a threshold — with no knowledge of breakpoints or class names. |
| 23 | + * |
| 24 | + * It is opt-in (wrap a subtree in `SlotGeometryProvider`) and **detection only**: it never mutates |
| 25 | + * the layout. Consumers read `isObscured(slot)` and decide what to do (e.g. release a covering |
| 26 | + * slot). A declarative rules/auto-close layer could sit on top of this. |
| 27 | + * |
| 28 | + * Dependency direction is one-way: this observes the DOM the layout produces; it does not depend on |
| 29 | + * the `LayoutController`, and slot ids are plain strings, so it works with any slotted layout. |
| 30 | + */ |
| 31 | + |
| 32 | +/** A slot identifier. Plain string so the module is layout-agnostic (e.g. any `SlotName`). */ |
| 33 | +export type SlotId = string; |
| 34 | + |
| 35 | +/** Fraction of a slot's area another slot must overlap before we treat it as covered. */ |
| 36 | +const COVER_THRESHOLD = 0.6; |
| 37 | +/** Rendered area (px²) below which a slot counts as collapsed/hidden — effectively obscured. */ |
| 38 | +const MIN_VISIBLE_AREA = 1; |
| 39 | + |
| 40 | +export type SlotCoverage = { |
| 41 | + /** slot id -> whether it is currently obscured (collapsed, or covered by another slot). */ |
| 42 | + obscured: Record<SlotId, boolean>; |
| 43 | +}; |
| 44 | + |
| 45 | +export type SlotGeometryContextValue = { |
| 46 | + coverage: SlotCoverage; |
| 47 | + /** |
| 48 | + * Live, synchronous obscured check — measures the DOM at call time, so it is safe to call from |
| 49 | + * event handlers where the reactive `coverage` snapshot might lag a layout change. |
| 50 | + */ |
| 51 | + isObscured: (slot: SlotId) => boolean; |
| 52 | + register: (slot: SlotId, element: HTMLElement | null) => void; |
| 53 | +}; |
| 54 | + |
| 55 | +const noopContextValue: SlotGeometryContextValue = { |
| 56 | + coverage: { obscured: {} }, |
| 57 | + isObscured: () => false, |
| 58 | + register: () => { |
| 59 | + // No-op: outside a SlotGeometryProvider there is no geometry to track, so registration is |
| 60 | + // silently ignored (consumers still render; `isObscured` just always returns false). |
| 61 | + }, |
| 62 | +}; |
| 63 | + |
| 64 | +const SlotGeometryContext = createContext<SlotGeometryContextValue>(noopContextValue); |
| 65 | + |
| 66 | +const intersectionArea = (a: DOMRect, b: DOMRect) => { |
| 67 | + const width = Math.max(0, Math.min(a.right, b.right) - Math.max(a.left, b.left)); |
| 68 | + const height = Math.max(0, Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top)); |
| 69 | + return width * height; |
| 70 | +}; |
| 71 | + |
| 72 | +/** |
| 73 | + * @internal Exported for unit tests. A target is obscured when its own rendered box is ~0 area |
| 74 | + * (collapsed/hidden) or another element covers at least `COVER_THRESHOLD` of it. |
| 75 | + */ |
| 76 | +export const measureObscured = (target: HTMLElement, others: HTMLElement[]) => { |
| 77 | + const rect = target.getBoundingClientRect(); |
| 78 | + const area = rect.width * rect.height; |
| 79 | + if (area < MIN_VISIBLE_AREA) return true; |
| 80 | + let maxCoveredFraction = 0; |
| 81 | + for (const other of others) { |
| 82 | + const fraction = intersectionArea(rect, other.getBoundingClientRect()) / area; |
| 83 | + if (fraction > maxCoveredFraction) maxCoveredFraction = fraction; |
| 84 | + } |
| 85 | + return maxCoveredFraction >= COVER_THRESHOLD; |
| 86 | +}; |
| 87 | + |
| 88 | +const coverageChanged = (previous: SlotCoverage, next: Record<SlotId, boolean>) => { |
| 89 | + const keys = new Set([...Object.keys(previous.obscured), ...Object.keys(next)]); |
| 90 | + for (const key of keys) { |
| 91 | + if (previous.obscured[key] !== next[key]) return true; |
| 92 | + } |
| 93 | + return false; |
| 94 | +}; |
| 95 | + |
| 96 | +export const SlotGeometryProvider = ({ children }: PropsWithChildren) => { |
| 97 | + const elementsRef = useRef<Map<SlotId, HTMLElement>>(new Map()); |
| 98 | + const observerRef = useRef<ResizeObserver | null>(null); |
| 99 | + const rafRef = useRef<number | null>(null); |
| 100 | + const [coverage, setCoverage] = useState<SlotCoverage>({ obscured: {} }); |
| 101 | + |
| 102 | + const othersOf = (slot: SlotId) => |
| 103 | + [...elementsRef.current.entries()] |
| 104 | + .filter(([id]) => id !== slot) |
| 105 | + .map(([, element]) => element); |
| 106 | + |
| 107 | + const isObscured = useCallback((slot: SlotId) => { |
| 108 | + const element = elementsRef.current.get(slot); |
| 109 | + if (!element) return false; |
| 110 | + return measureObscured(element, othersOf(slot)); |
| 111 | + }, []); |
| 112 | + |
| 113 | + const recompute = useCallback(() => { |
| 114 | + const next: Record<SlotId, boolean> = {}; |
| 115 | + for (const [slot, element] of elementsRef.current.entries()) { |
| 116 | + next[slot] = measureObscured(element, othersOf(slot)); |
| 117 | + } |
| 118 | + setCoverage((previous) => |
| 119 | + coverageChanged(previous, next) ? { obscured: next } : previous, |
| 120 | + ); |
| 121 | + }, []); |
| 122 | + |
| 123 | + // Coalesce bursts of ResizeObserver/resize callbacks into one measurement per frame. This also |
| 124 | + // keeps us clear of the "ResizeObserver loop" warning and avoids feedback churn: recompute only |
| 125 | + // pushes new state when an obscured flag actually flips, which is a fixed point. |
| 126 | + const scheduleRecompute = useCallback(() => { |
| 127 | + if (typeof requestAnimationFrame === 'undefined') { |
| 128 | + recompute(); |
| 129 | + return; |
| 130 | + } |
| 131 | + if (rafRef.current !== null) return; |
| 132 | + rafRef.current = requestAnimationFrame(() => { |
| 133 | + rafRef.current = null; |
| 134 | + recompute(); |
| 135 | + }); |
| 136 | + }, [recompute]); |
| 137 | + |
| 138 | + const register = useCallback( |
| 139 | + (slot: SlotId, element: HTMLElement | null) => { |
| 140 | + const elements = elementsRef.current; |
| 141 | + const previous = elements.get(slot); |
| 142 | + if (previous && previous !== element) observerRef.current?.unobserve(previous); |
| 143 | + |
| 144 | + if (element) { |
| 145 | + elements.set(slot, element); |
| 146 | + observerRef.current?.observe(element); |
| 147 | + } else { |
| 148 | + elements.delete(slot); |
| 149 | + } |
| 150 | + scheduleRecompute(); |
| 151 | + }, |
| 152 | + [scheduleRecompute], |
| 153 | + ); |
| 154 | + |
| 155 | + useEffect(() => { |
| 156 | + if (typeof ResizeObserver === 'undefined') return; |
| 157 | + const observer = new ResizeObserver(() => scheduleRecompute()); |
| 158 | + observerRef.current = observer; |
| 159 | + // Ref callbacks fire during commit (before this passive effect), so pick up anything that |
| 160 | + // registered before the observer existed. |
| 161 | + for (const element of elementsRef.current.values()) observer.observe(element); |
| 162 | + |
| 163 | + const onResize = () => scheduleRecompute(); |
| 164 | + window.addEventListener('resize', onResize); |
| 165 | + scheduleRecompute(); |
| 166 | + |
| 167 | + return () => { |
| 168 | + observer.disconnect(); |
| 169 | + observerRef.current = null; |
| 170 | + window.removeEventListener('resize', onResize); |
| 171 | + if (rafRef.current !== null && typeof cancelAnimationFrame !== 'undefined') { |
| 172 | + cancelAnimationFrame(rafRef.current); |
| 173 | + rafRef.current = null; |
| 174 | + } |
| 175 | + }; |
| 176 | + }, [scheduleRecompute]); |
| 177 | + |
| 178 | + const value: SlotGeometryContextValue = { coverage, isObscured, register }; |
| 179 | + |
| 180 | + return ( |
| 181 | + <SlotGeometryContext.Provider value={value}>{children}</SlotGeometryContext.Provider> |
| 182 | + ); |
| 183 | +}; |
| 184 | + |
| 185 | +export const useSlotGeometry = () => useContext(SlotGeometryContext); |
| 186 | + |
| 187 | +/** Returns a stable callback ref that registers/unregisters the element backing `slot`. */ |
| 188 | +export const useRegisterSlotGeometry = (slot: SlotId) => { |
| 189 | + const { register } = useSlotGeometry(); |
| 190 | + return useCallback( |
| 191 | + (element: HTMLElement | null) => register(slot, element), |
| 192 | + [register, slot], |
| 193 | + ); |
| 194 | +}; |
0 commit comments