Files
multica/packages/ui/hooks/use-scroll-fade.ts
Naiyuan Qing 595f785ac9 fix(desktop): make overflowing tab additions visible (#5215)
* fix(desktop): animate overflowing tab additions

* fix(desktop): recalculate tabs after pin layout changes

* docs: document daemon log rotation settings

Co-Authored-By: OpenAI Codex <noreply@openai.com>

* Revert "docs: document daemon log rotation settings"

This reverts commit a6cbaa99ec.

---------

Co-authored-by: OpenAI Codex <noreply@openai.com>
2026-07-10 17:16:35 +08:00

85 lines
2.6 KiB
TypeScript

import { type RefObject, type CSSProperties, useEffect, useState, useCallback } from "react";
export type ScrollFadeAxis = "vertical" | "horizontal";
/**
* Returns a dynamic maskImage style based on scroll position.
* - At start → fade end only
* - At end → fade start only
* - In middle → fade both
* - No overflow → undefined (no mask)
*/
export function useScrollFade(
ref: RefObject<HTMLElement | null>,
fadeSize = 32,
axis: ScrollFadeAxis = "vertical",
): CSSProperties | undefined {
const [fade, setFade] = useState<"none" | "start" | "end" | "both">("none");
const update = useCallback(() => {
const el = ref.current;
if (!el) return;
const position = axis === "horizontal" ? el.scrollLeft : el.scrollTop;
const scrollSize = axis === "horizontal" ? el.scrollWidth : el.scrollHeight;
const clientSize = axis === "horizontal" ? el.clientWidth : el.clientHeight;
const scrollable = scrollSize - clientSize;
if (scrollable <= 0) {
setFade("none");
return;
}
const atStart = position <= 1;
const atEnd = position >= scrollable - 1;
if (atStart && atEnd) setFade("none");
else if (atStart) setFade("end");
else if (atEnd) setFade("start");
else setFade("both");
}, [axis, ref]);
useEffect(() => {
const el = ref.current;
if (!el) return;
const frame = requestAnimationFrame(update);
el.addEventListener("scroll", update, { passive: true });
const ro = new ResizeObserver(update);
ro.observe(el);
// ResizeObserver only fires on the container's own box. When children
// grow inside a flex/auto-sized parent, the scroll extent can change while
// the viewport does not — the mask would stay "none" until the user scrolls.
// MutationObserver on childList catches those content insertions.
const mo = new MutationObserver(update);
mo.observe(el, { childList: true, subtree: true });
return () => {
cancelAnimationFrame(frame);
el.removeEventListener("scroll", update);
ro.disconnect();
mo.disconnect();
};
}, [ref, update]);
if (fade === "none") return undefined;
const start =
fade === "start" || fade === "both"
? `transparent 0%, black ${fadeSize}px`
: "black 0%";
const end =
fade === "end" || fade === "both"
? `black calc(100% - ${fadeSize}px), transparent 100%`
: "black 100%";
const direction = axis === "horizontal" ? "right" : "bottom";
const gradient = `linear-gradient(to ${direction}, ${start}, ${end})`;
return {
maskImage: gradient,
WebkitMaskImage: gradient,
};
}