import { useLayoutEffect, useRef, useState, type CSSProperties } from 'react' // A dropdown that hangs off its own button in viewport coordinates. // // The tone and export menus used to be absolutely positioned inside their // button's wrapper, which was fine until that wrapper became ChromeStrip — a // horizontal scroller, and so a box that clips what overflows it. An absolute // menu inside it is 36px tall and scrolls away with the pills. Positioning the // menu against the viewport instead takes it out of the strip's hands entirely: // nothing clips a fixed box unless an ancestor has a transform, and none of the // editor's chrome does. // // The trade is that a fixed box doesn't follow its anchor, so anything that // moves the button — the page scrolling under it, the strip scrolling, the // window resizing — has to re-place the menu. export function useAnchoredMenu(open: boolean, width: number) { const triggerRef = useRef(null) // Nothing to place before the first measurement; keeping it off-screen rather // than at 0,0 means no flash in the top-left corner on open. const [style, setStyle] = useState({ position: 'fixed', top: -9999, left: -9999 }) useLayoutEffect(() => { if (!open) return const place = () => { const el = triggerRef.current if (!el) return const r = el.getBoundingClientRect() // Right-aligned under the button, as it always was — but pulled back // inside the window if that would hang the menu off a narrow screen. const left = Math.max(8, Math.min(r.right - width, window.innerWidth - width - 8)) setStyle({ position: 'fixed', top: r.bottom + 6, left, width, zIndex: 40 }) } place() window.addEventListener('resize', place) // Capture, so this hears the editor pane and the pill strip scrolling — // neither of which bubbles a scroll event to the window. window.addEventListener('scroll', place, true) return () => { window.removeEventListener('resize', place) window.removeEventListener('scroll', place, true) } }, [open, width]) return { triggerRef, style } }