All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 40s
The emoji picker and the @-mention list were absolutely positioned inside .mention-textarea-wrap, so every ancestor with a clipping box cut them off — and TextEditor sits inside plenty: .dump-card-inner, .journal-card and .rich-content-card clip with overflow: hidden, while .modal-body and the chat scroller clip just as hard with overflow-y: auto. The picker also always opened upward, so a textarea near the top of the viewport pushed it off-screen. Both popups are now portaled to <body> and anchored to the textarea with position: fixed, which no ancestor can clip. The new useAnchoredFloat hook flips to whichever side has more room, clamps horizontally on narrow screens, and publishes the space left as --float-max-h so the list shrinks and scrolls instead of overflowing. It re-places on scroll, on resize, and through a ResizeObserver, since autoResize grows the textarea as you type. z-index goes to 1100 to clear the modal backdrop, staying under tooltips. Drops two point-fixes that only existed to work around the clipping: the hardcoded upward flip for the chat composer's mention list, whose leftover margin would now double the gap, and the claim that the chat modal's body needed overflow: visible for the popovers' sake. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
110 lines
3.8 KiB
TypeScript
110 lines
3.8 KiB
TypeScript
import {
|
|
type CSSProperties,
|
|
type RefObject,
|
|
useLayoutEffect,
|
|
useState,
|
|
} from "react";
|
|
|
|
/** Space between the anchor and the panel. */
|
|
const GAP = 4;
|
|
/** Space kept clear of the viewport edges. */
|
|
const MARGIN = 8;
|
|
|
|
interface AnchoredFloatOptions {
|
|
/**
|
|
* Preferred width in px, or "anchor" to match the anchor element's own width.
|
|
* Shrunk when the viewport is narrower either way.
|
|
*/
|
|
width: number | "anchor";
|
|
/** Height of the panel's non-scrolling chrome (search row, borders…). */
|
|
chromeHeight: number;
|
|
minContentHeight: number;
|
|
maxContentHeight: number;
|
|
}
|
|
|
|
/**
|
|
* Positions a floating panel against an anchor element with `position: fixed`,
|
|
* so no ancestor's `overflow: hidden` / `overflow: auto` can clip it — cards,
|
|
* modal bodies and scrollers all cut off an absolutely positioned popup.
|
|
*
|
|
* Flips to whichever side of the anchor has the most room, clamps horizontally
|
|
* to the viewport, and publishes the room left for the scrolling part as the
|
|
* `--float-max-h` custom property.
|
|
*
|
|
* Returns `null` while closed; the first value lands in a layout effect, so the
|
|
* panel is never painted at the wrong spot.
|
|
*/
|
|
export function useAnchoredFloat(
|
|
anchorRef: RefObject<HTMLElement | null>,
|
|
open: boolean,
|
|
{ width, chromeHeight, minContentHeight, maxContentHeight }:
|
|
AnchoredFloatOptions,
|
|
): CSSProperties | null {
|
|
const [style, setStyle] = useState<CSSProperties | null>(null);
|
|
|
|
useLayoutEffect(() => {
|
|
if (!open) return;
|
|
const anchor = anchorRef.current;
|
|
if (!anchor) return;
|
|
|
|
const place = () => {
|
|
const r = anchor.getBoundingClientRect();
|
|
const vw = globalThis.innerWidth;
|
|
const vh = globalThis.innerHeight;
|
|
|
|
// Clamp the anchor's edges into the viewport first: an auto-growing
|
|
// textarea can be taller than the screen, and an off-screen edge would
|
|
// otherwise put the panel out of reach.
|
|
const anchorTop = Math.min(Math.max(r.top, MARGIN), vh - MARGIN);
|
|
const anchorBottom = Math.min(Math.max(r.bottom, MARGIN), vh - MARGIN);
|
|
const roomAbove = anchorTop - GAP - MARGIN;
|
|
const roomBelow = vh - anchorBottom - GAP - MARGIN;
|
|
const placeAbove = roomAbove >= roomBelow;
|
|
|
|
const w = Math.min(width === "anchor" ? r.width : width, vw - MARGIN * 2);
|
|
const content = Math.max(
|
|
minContentHeight,
|
|
Math.min(maxContentHeight, (placeAbove ? roomAbove : roomBelow) -
|
|
chromeHeight),
|
|
);
|
|
|
|
// Anchoring by `bottom` when above means the panel's own height never
|
|
// enters the computation, so it can't jump as the list loads.
|
|
const next: CSSProperties & Record<string, string | number> = {
|
|
position: "fixed",
|
|
width: w,
|
|
left: Math.min(
|
|
Math.max(MARGIN, r.left),
|
|
Math.max(MARGIN, vw - w - MARGIN),
|
|
),
|
|
"--float-max-h": `${content}px`,
|
|
};
|
|
if (placeAbove) {
|
|
next.bottom = vh - anchorTop + GAP;
|
|
next.top = "auto";
|
|
} else {
|
|
next.top = anchorBottom + GAP;
|
|
next.bottom = "auto";
|
|
}
|
|
setStyle(next);
|
|
};
|
|
|
|
place();
|
|
// `true` catches scrolling in any ancestor scroller, not just the window.
|
|
globalThis.addEventListener("scroll", place, true);
|
|
globalThis.addEventListener("resize", place);
|
|
// The anchor itself moves when an auto-resizing textarea grows.
|
|
const observer = new ResizeObserver(place);
|
|
observer.observe(anchor);
|
|
return () => {
|
|
globalThis.removeEventListener("scroll", place, true);
|
|
globalThis.removeEventListener("resize", place);
|
|
observer.disconnect();
|
|
};
|
|
}, [open, anchorRef, width, chromeHeight, minContentHeight, maxContentHeight]);
|
|
|
|
// Gated on `open` rather than cleared on close: the stale value is never
|
|
// painted, since the layout effect re-places the panel before the next paint.
|
|
return open ? style : null;
|
|
}
|