diff --git a/package-lock.json b/package-lock.json index 2779226..917cb39 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-icons": "^4.9.0", + "react-merge-refs": "^2.0.2", "react-scripts": "5.0.1", "web-vitals": "^2.1.4" }, @@ -35,9 +36,9 @@ } }, "node_modules/@adobe/css-tools": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.2.0.tgz", - "integrity": "sha512-E09FiIft46CmH5Qnjb0wsW54/YQd69LsxeKUOWawmws1XWvyFGURnAChH0mlr7YPFR1ofwvUQfcL0J3lMxXqPA==" + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.3.1.tgz", + "integrity": "sha512-/62yikz7NLScCGAAST5SHdnjaDJQBDq0M2muyRTpf2VQhw6StBg2ALiu73zSJQ4fMVLA+0uBhBHAle7Wg+2kSg==" }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", @@ -15431,6 +15432,15 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" }, + "node_modules/react-merge-refs": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/react-merge-refs/-/react-merge-refs-2.0.2.tgz", + "integrity": "sha512-V5BGTwGa2r+/t0A/BZMS6L7VPXY0CU8xtAhkT3XUoI1WJJhhtvulvoiZkJ5Jt9YAW23m4xFWmhQ+C5HwjtTFhQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, "node_modules/react-refresh": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz", diff --git a/package.json b/package.json index dd6cf29..ea82f01 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-icons": "^4.9.0", + "react-merge-refs": "^2.0.2", "react-scripts": "5.0.1", "web-vitals": "^2.1.4" }, diff --git a/src/components/card/StreamingCard.tsx b/src/components/card/StreamingCard.tsx index 68df02f..45f37c7 100644 --- a/src/components/card/StreamingCard.tsx +++ b/src/components/card/StreamingCard.tsx @@ -29,19 +29,19 @@ export const StreamingCard = React.memo( const { isMobile, isDesktopSize } = useWindowSize(); const { config } = useConfig(); - const isHoverable = !isMobile && isDesktopSize; + const expand = isMobile || !isDesktopSize; return ( window.open(url)} aria-label={title} - {...(isHoverable ? hoverSpread : {})} + {...hoverSpread} > ( thumbnail={thumbnail} name={name} icon={icon} - isExpand={config.isExpandAlways || !isHoverable || hovered} + isExpand={config.isExpandAlways || expand || hovered} + hovered={hovered} /> diff --git a/src/components/card/ThumbnailBlock.tsx b/src/components/card/ThumbnailBlock.tsx index 65e19d8..bbd2710 100644 --- a/src/components/card/ThumbnailBlock.tsx +++ b/src/components/card/ThumbnailBlock.tsx @@ -1,9 +1,9 @@ -import React from "react"; +import React, { useMemo } from "react"; import styled from "styled-components"; import { animated, easings, useSpring } from "@react-spring/web"; import { breakpoints } from "../../configs"; import { useConfig, useWindowSize } from "../../hooks"; -import { Marquee } from "../marquee"; +import { MarqueeScroll } from "../marquee"; import { ThumbnailBlockProps } from "../../types"; const Panel = styled(animated.div)` @@ -74,7 +74,7 @@ const Contents = styled(animated.div)` `} `; -const MarqueeTitle = styled(Marquee)` +const MarqueeTitle = styled(MarqueeScroll)` font-family: "Zen Kaku Gothic New", sans-serif; font-size: 10px; width: 100%; @@ -109,6 +109,7 @@ export const ThumbnailBlock: React.FC = ({ name, icon, isExpand, + hovered, ...props }) => { const { isPhoneSize } = useWindowSize(); @@ -139,14 +140,16 @@ export const ThumbnailBlock: React.FC = ({ ...(isPhoneSize ? mobileSpringConfig : {}), }); - const { opacity } = useSpring({ - opacity: isExpand ? 1 : 0, + const { x } = useSpring({ + x: isExpand ? 1 : 0, config: { - duration: 250, + duration: 500, easing: easings.easeOutExpo, }, }); + const speed = useMemo(() => (isPhoneSize ? 0.45 : 0.9), [isPhoneSize]); + return ( = ({ />
- + {title} diff --git a/src/components/marquee/Marquee.tsx b/src/components/marquee/Marquee.tsx deleted file mode 100644 index 814d60f..0000000 --- a/src/components/marquee/Marquee.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import React, { useEffect, useLayoutEffect, useRef, useState } from "react"; -import styled from "styled-components"; -import { MarqueeProps } from "../../types"; -import { animated, useSpring, useSpringRef } from "@react-spring/web"; -import { useWindowSize } from "../../hooks"; - -const Container = styled.div` - width: 100%; - display: flex; - overflow: hidden; - mask-image: linear-gradient( - to right, - transparent, - #fff 5%, - #fff 95%, - transparent - ); -`; - -const Item = styled(animated.div)` - white-space: nowrap; - padding: 0 20% 0 3%; -`; - -export const Marquee: React.FC = ({ - children, - animate = true, - speed = 0.05, - ...props -}) => { - const { isPhoneSize } = useWindowSize(); - const refParent = useRef(null!); - const refChild = useRef(null!); - const rect = useRef<{ parent: DOMRect; child: DOMRect }>(null!); - const [canMarquee, setCanMarquee] = useState(false); - - const animation = useSpringRef(); - const transform = useSpring({ - ref: animation, - from: { - x: "0%", - }, - }); - - const reset = () => { - animation.start({ - from: { - x: "0%", - }, - immediate: true, - }); - }; - - const restart = (duration: number) => { - animation.start({ - from: { - x: "0%", - }, - to: { - x: "-100%", - }, - reset: true, - loop: true, - delay: 900, - immediate: false, - config: { - duration, - }, - }); - }; - - useLayoutEffect(() => { - reset(); - const parent = refParent.current.getBoundingClientRect(); - const child = refChild.current.getBoundingClientRect(); - rect.current = { parent, child }; - setCanMarquee(parent.width < child.width); - }, [children, isPhoneSize]); - - useEffect(() => { - canMarquee && animate ? restart(rect.current.child.width / speed) : reset(); - }, [canMarquee, animate, speed]); - - return ( - - - {children} - - {canMarquee && {children}} - - ); -}; diff --git a/src/components/marquee/MarqueeItem.tsx b/src/components/marquee/MarqueeItem.tsx new file mode 100644 index 0000000..6d8c465 --- /dev/null +++ b/src/components/marquee/MarqueeItem.tsx @@ -0,0 +1,58 @@ +import React, { ReactNode, forwardRef, useEffect, useRef } from "react"; +import styled from "styled-components"; +import { useAnimationFrame, useWindowSize } from "../../hooks"; +import { mergeRefs } from "react-merge-refs"; + +const Container = styled.div` + white-space: nowrap; + padding: 0 20% 0 3%; +`; + +type Props = { + children: ReactNode; + isAnimate: boolean; + speed?: number; + waitTime?: number; +}; + +export const MarqueeItem = forwardRef( + ({ children, isAnimate, speed = 1, waitTime = 1500 }, forwardedRef) => { + const { isPhoneSize } = useWindowSize(); + const item = useRef(null!); + const rect = useRef(null!); + const start = useRef(null); + const x = useRef(0); + + useEffect(() => { + rect.current = item.current.getBoundingClientRect(); + }, [children, isPhoneSize]); + + useEffect(() => { + item.current.style.transform = `translateX(0)`; + x.current = 0; + start.current = null; + }, [isAnimate]); + + useAnimationFrame((timestamp) => { + if (!isAnimate || !item.current || !rect.current) return; + + if (!start.current) start.current = timestamp; + + if (timestamp - start.current < waitTime) return; + + x.current -= speed; + if (x.current < -rect.current.width) { + x.current = 0; + start.current = null; + } + + item.current.style.transform = `translateX(${ + (x.current / rect.current.width) * 100 + }%)`; + }); + + return ( + {children} + ); + } +); diff --git a/src/components/marquee/MarqueeScroll.tsx b/src/components/marquee/MarqueeScroll.tsx new file mode 100644 index 0000000..0f005fd --- /dev/null +++ b/src/components/marquee/MarqueeScroll.tsx @@ -0,0 +1,59 @@ +import React, { ReactNode, useLayoutEffect, useRef, useState } from "react"; +import { MarqueeItem } from "./MarqueeItem"; +import styled from "styled-components"; +import { useWindowSize } from "../../hooks"; + +const Container = styled.div` + width: 100%; + display: flex; + overflow: hidden; + mask-image: linear-gradient( + to right, + transparent, + #fff 5%, + #fff 95%, + transparent + ); +`; + +type Props = { + children: ReactNode; + isAnimate: boolean; + speed?: number; +}; + +export const MarqueeScroll: React.FC = ({ + children, + isAnimate, + speed = 1, + ...props +}) => { + const { isPhoneSize } = useWindowSize(); + const parentRef = useRef(null!); + const childRef = useRef(null!); + const [canMarquee, setCanMarquee] = useState(false); + + useLayoutEffect(() => { + setCanMarquee( + parentRef.current.getBoundingClientRect().width < + childRef.current.getBoundingClientRect().width + ); + }, [children, isPhoneSize]); + + return ( + + + {children} + + {canMarquee && ( + + {children} + + )} + + ); +}; diff --git a/src/components/marquee/index.ts b/src/components/marquee/index.ts index 8bee1a4..12c3921 100644 --- a/src/components/marquee/index.ts +++ b/src/components/marquee/index.ts @@ -1 +1 @@ -export { Marquee } from "./Marquee"; +export { MarqueeScroll } from "./MarqueeScroll"; diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 41f7017..ce1c1e3 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -6,3 +6,4 @@ export { useStreamInfo } from "./useStreamInfo"; export { useWindowSize } from "./useWindowSize"; export { useConfig } from "./useConfig"; export { useBoolStateCache } from "./useBoolStateCache"; +export { useAnimationFrame } from "./useAnimationFrame"; diff --git a/src/hooks/useAnimationFrame.ts b/src/hooks/useAnimationFrame.ts new file mode 100644 index 0000000..801ac76 --- /dev/null +++ b/src/hooks/useAnimationFrame.ts @@ -0,0 +1,20 @@ +import { useCallback, useEffect, useRef } from "react"; + +export const useAnimationFrame = ( + callback = (timestamp: DOMHighResTimeStamp) => {} +) => { + const ref = useRef(0); + + const loop = useCallback( + (timestamp: DOMHighResTimeStamp) => { + ref.current = requestAnimationFrame(loop); + callback(timestamp); + }, + [callback] + ); + + useEffect(() => { + ref.current = requestAnimationFrame(loop); + return () => cancelAnimationFrame(ref.current); + }, [loop]); +}; diff --git a/src/hooks/useHover.ts b/src/hooks/useHover.ts index 0f0f3a8..95fd44b 100644 --- a/src/hooks/useHover.ts +++ b/src/hooks/useHover.ts @@ -5,7 +5,10 @@ export const useHover = () => { return { hovered, hoverSpread: { - onPointerOver: (e: any) => (e.stopPropagation(), setHover(true)), + onPointerOver: (e: any) => { + e.stopPropagation(); + setHover(true); + }, onPointerOut: () => setHover(false), }, }; diff --git a/src/types/frontUI.ts b/src/types/frontUI.ts index 850b00b..183da51 100644 --- a/src/types/frontUI.ts +++ b/src/types/frontUI.ts @@ -18,6 +18,7 @@ export type ThumbnailBlockProps = { name: string; icon: string; isExpand: boolean; + hovered: boolean; } & React.HTMLAttributes; export type ServiceIconProps = { @@ -29,7 +30,7 @@ export type ServiceIconProps = { export type StreamingHeaderProps = Omit; export type StreamingCardProps = Omit< - ServiceIconProps & ThumbnailBlockProps, + ServiceIconProps & Omit, "isExpand" > & { url: string;