Files
multica/packages/ui/hooks/use-scroll-fade.ts
Naiyuan Qing 35828492d5 feat(ui): extract packages/ui — shared atomic UI layer
- Move 55 shadcn components → packages/ui/components/ui/
- Move lib/utils.ts (cn function) → packages/ui/lib/
- Move 3 DOM hooks (auto-scroll, mobile, scroll-fade) → packages/ui/hooks/
- Extract CSS design tokens (@theme + :root + .dark) → packages/ui/styles/tokens.css
- Refactor 3 common components to pure-props (actor-avatar, mention-hover-card, reaction-bar)
- Move 6 markdown components with renderMention slot for IssueMentionCard decoupling
- Create wrapper components in apps/web/ for data-aware ActorAvatar and Markdown
- Update 116 import paths across apps/web/
- Add @source directives for Tailwind to scan packages

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:44:31 +08:00

69 lines
1.8 KiB
TypeScript

import { type RefObject, type CSSProperties, useEffect, useState, useCallback } from "react";
/**
* Returns a dynamic maskImage style based on scroll position.
* - At top → fade bottom only
* - At bottom → fade top only
* - In middle → fade both
* - No overflow → undefined (no mask)
*/
export function useScrollFade(
ref: RefObject<HTMLElement | null>,
fadeSize = 32
): CSSProperties | undefined {
const [fade, setFade] = useState<"none" | "top" | "bottom" | "both">("none");
const update = useCallback(() => {
const el = ref.current;
if (!el) return;
const { scrollTop, scrollHeight, clientHeight } = el;
const scrollable = scrollHeight - clientHeight;
if (scrollable <= 0) {
setFade("none");
return;
}
const atTop = scrollTop <= 1;
const atBottom = scrollTop >= scrollable - 1;
if (atTop && atBottom) setFade("none");
else if (atTop) setFade("bottom");
else if (atBottom) setFade("top");
else setFade("both");
}, [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);
return () => {
cancelAnimationFrame(frame);
el.removeEventListener("scroll", update);
ro.disconnect();
};
}, [ref, update]);
if (fade === "none") return undefined;
const top = fade === "top" || fade === "both" ? `transparent 0%, black ${fadeSize}px` : "black 0%";
const bottom =
fade === "bottom" || fade === "both"
? `black calc(100% - ${fadeSize}px), transparent 100%`
: "black 100%";
const gradient = `linear-gradient(to bottom, ${top}, ${bottom})`;
return {
maskImage: gradient,
WebkitMaskImage: gradient,
};
}