import { Fragment, type ReactNode, useCallback, useEffect, useId, useSyncExternalStore } from 'react';
import { StyleSheet } from 'react-native';
import Animated, { measure, runOnJS, runOnUI, useAnimatedRef } from 'react-native-reanimated';
import { overlayHostPageX, overlayHostPageY, setOverlayHostWindowPosition } from './overlay-host-position';
/**
* A layered, single-host portal for the HoldMenu overlay content (its backdrop
* + menu + twins) on Android.
*
* Android's target-based blur cannot render its `BlurView` inside the very
* `BlurTarget` it frosts — the peer's `RenderNodeBlurController` records the
* target's `RenderNode` into its own blur node, and a `BlurView` that is a
* descendant of the target makes that graph cycle (HWUI
* `RenderNode::prepareTreeImpl` stack-overflows). It ALSO cannot sit *behind*
* the overlay while the overlay lives inside the target: the blur draws a
* blurred copy of the whole target — overlay included — so it would frost the
* menu it is meant to sit under.
*
* The fix is to move the overlay itself OUT of the target. `BlurProvider`
* renders an `` as a SIBLING of its `BlurTarget`; HoldMenu
* renders its backdrop/menu through `` and each
* `HoldItem` renders its twin through ``. The blur
* then captures only the page (the overlay is outside the target) and the
* overlay paints after the blur, crisp on top.
*
* Layers order the host's children regardless of registration order (a twin is
* registered by a `HoldItem` deep in the tree, before the provider's
* backdrop/menu, yet must paint above the menu). The host setter is carried by
* a module-level store (not React context) because the overlay pieces live deep
* in the tree, often inside their own `Portal`/`Modal`, where a context-provided
* host would be shadowed by a nearer provider.
*
* NOT the same as `./overlay-portal` — that file's `OverlayPortal` injects
* arbitrary content into the nearest OPEN overlay's outlet; this one teleports
* HoldMenu's own overlay out of the `BlurTarget`. Internal to the package — not
* exported.
*/
/** Stacking layer. Lower renders first (bottom), higher renders last (top). */
type OverlayLayer = 'menu' | 'twin';
const LAYER_ORDER: Record = { menu: 0, twin: 1 };
type OverlayEntry = { id: string; layer: OverlayLayer; node: ReactNode };
/** Kept sorted by `LAYER_ORDER` so `getEntries` returns a stable, ordered snapshot. */
let entries: OverlayEntry[] = [];
const listeners = new Set<() => void>();
function subscribe(cb: () => void): () => void {
listeners.add(cb);
return () => {
listeners.delete(cb);
};
}
function getEntries(): OverlayEntry[] {
return entries;
}
function emit() {
for (const cb of listeners) cb();
}
type OverlayPortalProps = { layer: OverlayLayer; children: ReactNode };
/**
* Rendered by `BlurProvider` OUTSIDE its `BlurTarget`. Fills its parent and
* renders the overlay pieces in layer order. `pointerEvents="box-none"` so the
* host itself never intercepts a touch — the backdrop and menu manage their own
* hit-testing (the backdrop catches outside taps, the menu is interactive).
*/
export function OverlayHost() {
const current = useSyncExternalStore(subscribe, getEntries);
const hostRef = useAnimatedRef();
// Measure this host's own window offset so the teleported pieces (whose
// containing block is this host, not the provider root) can convert their
// root-space coords back into host space. `measure` runs on the UI thread in
// the same coordinate system the activation worklet uses for the root/item,
// so the offsets match exactly; `onLayout` re-fires on rotation so the stored
// offset stays current. The same `measure` result is mirrored into the JS
// store the Morphing* teleport reads — via `runOnJS`, because `useAnimatedRef`
// has no JS-thread `.current` to call `measureInWindow` on.
const measureOffset = useCallback(() => {
runOnUI(() => {
const m = measure(hostRef);
if (m) {
overlayHostPageX.value = m.pageX;
overlayHostPageY.value = m.pageY;
runOnJS(setOverlayHostWindowPosition)({ x: m.pageX, y: m.pageY });
}
})();
}, [hostRef]);
// Always mounted — even with no entries — so the window offset is measured at
// app start and a teleported Morphing* overlay reads a correct host position
// on its first open instead of one frame at {0, 0}. Empty, `box-none` and
// touch-transparent, so it costs nothing when no overlay is open.
return (
{current.map((entry) => (
{entry.node}
))}
);
}
/** Teleports `children` into the `OverlayHost`, at `layer`'s stacking position. */
export function OverlayPortal({ layer, children }: OverlayPortalProps) {
const id = useId();
// biome-ignore lint/plugin: pushing ReactNode into an external store is the portal's job — no derived-state or event-handler form exists
useEffect(() => {
const order = LAYER_ORDER[layer];
const insertAt = entries.findIndex((e) => LAYER_ORDER[e.layer] > order);
const entry = { id, layer, node: children };
entries = insertAt === -1 ? [...entries, entry] : [...entries.slice(0, insertAt), entry, ...entries.slice(insertAt)];
emit();
return () => {
entries = entries.filter((e) => e.id !== id);
emit();
};
}, [id, layer, children]);
return null;
}