- Move ref assignments from render to useEffect (useViewportCommentRead, useViewportObserver) - Copy ref.current to local variable for effect cleanup closure - Add eslint-disable for intentional ref-as-cache pattern (useStableTeamMentionMeta) - Fix !== always-true comparison between undefined and null (store) - Add missing return types (sentry, composerDraftStorage) - Remove unused import isLeadAgentType (memberHelpers) - Suppress naming-convention warning for Vite-injected __APP_VERSION__
138 lines
4.7 KiB
TypeScript
138 lines
4.7 KiB
TypeScript
import { useCallback, useEffect, useRef } from 'react';
|
|
|
|
/** Data attribute name used to store arbitrary string data on observed elements. */
|
|
const DATA_ATTR = 'data-viewport-value';
|
|
|
|
interface UseViewportObserverOptions {
|
|
/**
|
|
* Scrollable ancestor DOM element used as IntersectionObserver root.
|
|
* Pass the actual element (not a RefObject) so that the hook can
|
|
* react to the element becoming available (e.g. after a Dialog portal mounts).
|
|
*
|
|
* Use a callback-ref + useState pattern in the consumer:
|
|
* const [rootEl, setRootEl] = useState<HTMLElement | null>(null);
|
|
* <DialogContent ref={setRootEl}>
|
|
* useViewportObserver({ root: rootEl, ... })
|
|
*/
|
|
root?: HTMLElement | null;
|
|
/** Visibility ratio threshold (0..1). Default: 0.1 (10% visible). */
|
|
threshold?: number;
|
|
/**
|
|
* Called when the set of visible elements changes.
|
|
* Receives the data-viewport-value strings of all currently intersecting elements.
|
|
*/
|
|
onVisibleChange: (visibleValues: string[]) => void;
|
|
}
|
|
|
|
/**
|
|
* Generic reusable hook for detecting which elements are visible in a
|
|
* scrollable container using IntersectionObserver.
|
|
*
|
|
* Usage:
|
|
* 1. Call the hook with a root element and a callback.
|
|
* 2. Attach `registerElement(value)` as a ref callback on each element.
|
|
* `value` is an arbitrary string stored in a data attribute for identification.
|
|
* 3. The callback fires with the list of currently visible values whenever
|
|
* the intersection state changes.
|
|
*
|
|
* Important: pass the root as a plain DOM element (not a RefObject) so the
|
|
* hook can recreate the observer when the element becomes available.
|
|
* Use `useState` + callback ref in the consumer for this.
|
|
*
|
|
* The hook manages a single IntersectionObserver instance and handles
|
|
* element registration/deregistration automatically.
|
|
*/
|
|
export function useViewportObserver({
|
|
root,
|
|
threshold = 0.1,
|
|
onVisibleChange,
|
|
}: UseViewportObserverOptions): {
|
|
/** Ref callback factory. Attach the returned ref to an observed element. */
|
|
registerElement: (value: string) => (el: HTMLElement | null) => void;
|
|
} {
|
|
const onVisibleChangeRef = useRef(onVisibleChange);
|
|
|
|
useEffect(() => {
|
|
onVisibleChangeRef.current = onVisibleChange;
|
|
}, [onVisibleChange]);
|
|
|
|
const observerRef = useRef<IntersectionObserver | null>(null);
|
|
const visibleValuesRef = useRef<Set<string>>(new Set());
|
|
const elementsByValue = useRef<Map<string, HTMLElement>>(new Map());
|
|
|
|
// Create / recreate observer when root element or threshold changes.
|
|
// root is a plain DOM element (not a RefObject), so when the consumer
|
|
// updates state (e.g. Dialog portal mounts), this effect re-runs and
|
|
// creates an IO with the correct root.
|
|
useEffect(() => {
|
|
// When root is not yet available (e.g. portal not mounted), skip
|
|
// creating the observer — it would default to document viewport
|
|
// and produce false positives for all visible elements.
|
|
if (!root) return;
|
|
|
|
// Capture ref values for cleanup closure
|
|
const visibleValues = visibleValuesRef.current;
|
|
|
|
const observer = new IntersectionObserver(
|
|
(entries) => {
|
|
let changed = false;
|
|
for (const entry of entries) {
|
|
const value = entry.target.getAttribute(DATA_ATTR);
|
|
if (!value) continue;
|
|
|
|
if (entry.isIntersecting) {
|
|
if (!visibleValues.has(value)) {
|
|
visibleValues.add(value);
|
|
changed = true;
|
|
}
|
|
} else {
|
|
if (visibleValues.has(value)) {
|
|
visibleValues.delete(value);
|
|
changed = true;
|
|
}
|
|
}
|
|
}
|
|
if (changed) {
|
|
onVisibleChangeRef.current(Array.from(visibleValues));
|
|
}
|
|
},
|
|
{ root, threshold }
|
|
);
|
|
|
|
// Re-observe elements that were registered before observer was created
|
|
// (or after root changed).
|
|
for (const [value, el] of elementsByValue.current) {
|
|
el.setAttribute(DATA_ATTR, value);
|
|
observer.observe(el);
|
|
}
|
|
|
|
observerRef.current = observer;
|
|
|
|
return () => {
|
|
observer.disconnect();
|
|
observerRef.current = null;
|
|
visibleValues.clear();
|
|
};
|
|
}, [root, threshold]);
|
|
|
|
const registerElement = useCallback((value: string) => {
|
|
return (el: HTMLElement | null) => {
|
|
// Cleanup previous element for this value
|
|
const prev = elementsByValue.current.get(value);
|
|
if (prev) {
|
|
observerRef.current?.unobserve(prev);
|
|
elementsByValue.current.delete(value);
|
|
visibleValuesRef.current.delete(value);
|
|
}
|
|
|
|
// Register new element
|
|
if (el) {
|
|
el.setAttribute(DATA_ATTR, value);
|
|
elementsByValue.current.set(value, el);
|
|
observerRef.current?.observe(el);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
return { registerElement };
|
|
}
|