import { FC, ReactNode, useCallback, useEffect, useMemo, useState } from "react"; import { AnimatePresence, motion, MotionProps, useAnimation, usePresence } from "framer-motion"; import { joinClasses, Nullable, useLocale, waitAsync } from "@utils/common"; import { atom, useAtom } from "jotai"; import { ImageData, Language, LanguagePack } from "@states/global"; import bodyStyles from "@components/body/Body.module.scss"; import Image, { ImageProps } from "next/image"; import { SlowDown, Forceful } from "@utils/anims"; import { Logger } from "@utils/logger"; import { MotionBox, MotionFlex } from "@components/motion"; import { Box, Flex } from "@chakra-ui/react"; const ImageIndex = atom(2); const InZoomMode = atom(false); export const ImageGalleryInit = atom(true); export const InImageFullScreenMode = atom(false); const MouseMoveInZoomMode = atom(false); const MouseMoveInZoomModeTimeout = atom>(null); interface IImageGalleryProps { currentPage: number; unfocused?: boolean; } export const ImageGallery: FC = ({ currentPage, unfocused = false }) => { const [imageData] = useAtom(ImageData); const [inZoomMode] = useAtom(InZoomMode); const imageArray = useMemo(() => [...imageData.entries()].filter(v => !v[0].match(/(department|world)/g)), []); const [currentImageIndex] = useAtom(ImageIndex); const [currentImageURL, setCurrentImageURL] = useState(imageData.get("assets/img/bg.jpg")); useEffect(() => { switch (currentPage) { case 1: setCurrentImageURL(imageData.get("assets/img/world_bg.jpg")); break; case 2: setCurrentImageURL(imageData.get("assets/img/department_bg.jpg")); break; default: setCurrentImageURL(imageArray[currentImageIndex][1]); } }, [currentPage, currentImageIndex]); return {currentPage === 3 && <> } ; }; interface IImageViewerProps { imageArray: [string, string][]; // imageIndex: number; onImageIndexChange?: (newIndex: number) => void; onImageFocusToggle?: (index: number) => void; // inZoomMode?: boolean; } const ImageViewer: FC = ({ imageArray, onImageIndexChange, onImageFocusToggle, }) => { const [isPresent, safeToRemove] = usePresence(); const [inImageFullScreenMode, setInImageFullScreenMode] = useAtom(InImageFullScreenMode); const [,setMouseMove] = useAtom(MouseMoveInZoomMode); const [imageGalleryInit] = useAtom(ImageGalleryInit); const [,setComponentFirstInit] = useAtom(ImageGalleryInit); const [inZoomMode] = useAtom(InZoomMode); const [currentImageIndex] = useAtom(ImageIndex); const locale = useLocale(useAtom(Language)[0], useAtom(LanguagePack)[0]); const [triggeredByMenu, setTriggeredByMenu] = useState(false); const updateTriggeredOutbound = useCallback((value: boolean) => { if (triggeredByMenu !== !value) setTriggeredByMenu(() => !value); }, []); const commonTransition = { duration: 0.7, ease: SlowDown }; const imageViewVariants = { "initial-outbound": { // clipPath: "polygon(0% 0%, 0% 0%, 0% 0%, 0% 0%)", clipPath: "polygon(0% 0%, 0% 0%, 0% 100%, 0% 100%)", y: 100 }, "initial-native": { clipPath: "polygon(0% 0%, 0% 0%, 0% 100%, 0% 100%)", }, "expand": { clipPath: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", y: 0 }, "exit-outbound": { // clipPath: "polygon(100% 100%, 100% 100%, 100% 100%, 100% 100%)", clipPath: "polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%)", y: 100, transition: { duration: 1, ease: Forceful } }, "exit-native": { clipPath: "polygon(100% 0%, 100% 0%, 100% 100%, 100% 100%)", } }; const imageViewAnimController = useAnimation(); const handleExpandToggle = useCallback((zoom: boolean) => { if (zoom) imageViewAnimController.set("initial-native"); void imageViewAnimController .start(zoom ? "expand" : "exit-native"); }, []); const listener = useCallback((e: KeyboardEvent) => { Logger.instance.debug({}, "ImageViewer", "Keydown", e.key); if (e.key === "Escape") { setInImageFullScreenMode(false); // handleExpandToggle(false); } }, []); useEffect(() => { let timeout: NodeJS.Timeout; if (isPresent) { timeout = setTimeout(() => { setComponentFirstInit(false); }, 500); window.addEventListener("keydown", listener); } if (inZoomMode) void imageViewAnimController .start( isPresent ? "expand" : "exit-outbound", isPresent ? { ...commonTransition, delay: 1.2 } : undefined ); else if (imageGalleryInit) void imageViewAnimController.set(isPresent ? "initial-outbound" : "initial-native"); return () => { if (isPresent && timeout) clearTimeout(timeout); else if (!isPresent) { window.removeEventListener("keydown", listener); setComponentFirstInit(true); } safeToRemove?.(); }; }, [isPresent]); return <> { updateTriggeredOutbound(false); if (newIndex !== currentImageIndex) onImageIndexChange?.(newIndex); }} onImageFocusToggle={(i, willZoom) => { handleExpandToggle(willZoom); onImageFocusToggle?.(i); }} /> updateTriggeredOutbound(true)} text={(() => { const imgName = imageArray[currentImageIndex][0].split("/").pop()?.split(".")[0]; const text = locale(`image-desc.${imgName}`); if (text.match(/^\{@/)) return `Extra image: ${imgName}`; return text; })()} > setMouseMove(true)} key={`image-zoom-${currentImageIndex}`} className={"abs fw"} m={"auto"} layout={"position"} justifyContent={"flex-end"} alignItems={"flex-end"} initial={{ opacity: 0, // x: -20 // clipPath: "polygon(0% 0%, 0% 0%, 0% 100%, 0% 100%)" }} animate={{ opacity: 1, // x: 0 // clipPath: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)" }} exit={{ opacity: 0, // x: 20 // clipPath: "polygon(100% 0%, 100% 0%, 100% 100%, 100% 100%)" }} transition={{ duration: 0.5, ease: SlowDown }} bg={ // `#${Math.floor(Math.random() * 0xFFFFFF).toString(16).padStart(6, '0')}` `url(${imageArray[currentImageIndex][1]})` } style={{ aspectRatio: "1920 / 1080", backgroundSize: "contain", backgroundPosition: "center", backgroundRepeat: "no-repeat" }} > ; }; interface IImageDescProps { text: string; children?: ReactNode; uponExit?: () => void; hideText?: boolean; } export const ImageDesc: FC = ({ text: upcomingText, hideText = false, uponExit, children }) => { const [isExiting, setIsExiting] = useState(false); const [currentText, setCurrentText] = useState(upcomingText); const [bgText, setBgText] = useState(upcomingText); const [inZoomMode] = useAtom(InZoomMode); const [inImageFullScreenMode] = useAtom(InImageFullScreenMode); // const [componentFirstInit] = useAtom(ImageGalleryInit); useEffect(() => { setCurrentText(() => upcomingText); // The description text is wrapped instantly when the new text changes that has a shorter length, // resulting in a displeasing visual effect. This is a workaround to prevent that. if (upcomingText.length < bgText.length && !inZoomMode) waitAsync(390).then(() => setBgText(() => upcomingText)); else setBgText(() => upcomingText); return () => { uponExit?.(); setIsExiting(true); }; }, [upcomingText]); return {children} { // Widget's dimension transition is distorted when set to a relative height. // Apparently, it's unfixable, partly because // the timing between ImageGallery's first initialization vs. this component's first initialization is different. // So, I've set the height to 101 since it's the maximum height of the text when it's fully expanded. // @min-res: 800x600 return /* componentFirstInit ? "100%" : */ 101; })() }} animate={{ y: 0, maxHeight: hideText ? "0%" : "100%", }} exit={{ y: /* "100%" */ 101 }} transition={{ duration: 0.7, ease: SlowDown, y: { duration: isExiting ? 1.2 : 0.7, delay: isExiting ? 0.1 : 1, ease: isExiting ? Forceful : SlowDown, }, }} maxW={ inImageFullScreenMode ? "100vw" : "calc(100vw - (100vh - 176px) / (438 / 154.29) - clamp(100px, 20vw, 270px) - 10px)" } alignSelf={(inZoomMode || inImageFullScreenMode) ? "stretch": "flex-start"} layout > text.length ? 0.2 : 0, ease: Forceful, y: { duration: isExiting ? 1.2 : 0.7, delay: isExiting ? 0 : 0.7, ease: isExiting ? Forceful : SlowDown, }, }} bg={"#000"} layout > {currentText} {bgText} ; }; interface IMainBackgroundProps { url?: string; unfocused?: boolean; overrideDelay?: number; } const MainBackground: FC = ({ url, unfocused = false, overrideDelay }) => { return ( {""} ); }; interface IImagePickerProps { imageArray: [string, string][]; initialImageIndex: number; onImageIndexChange?: (newIndex: number) => void; onImageFocusToggle?: (index: number, willZoom: boolean) => void; } export const ImagePicker: FC = ({ imageArray, // initialImageIndex, onImageIndexChange, onImageFocusToggle, }) => { const [inZoomMode, setInZoomMode] = useAtom(InZoomMode); const [isHovering, setIsHovering] = useState(false); const [isExiting, setIsExiting] = useState(false); const [currentImageIndex, setCurrentImageIndex] = useAtom(ImageIndex); const [currentHoveredIndex, setCurrentHoveredIndex] = useState>(null); const [prevHoveredIndex, setPrevHoveredIndex] = useState>(null); const updateHoverIndex = useCallback((index: Nullable) => { setCurrentHoveredIndex(prev => { if (prev === index) return prev; setPrevHoveredIndex(prev); return index; }); }, []); useEffect(() => { return setIsExiting(true); }, []); return { setIsHovering(false); updateHoverIndex(null); }} onHoverStart={() => setIsHovering(true)} > {imageArray.map((imageURL, i) => { setCurrentImageIndex(() => { onImageIndexChange?.(i); return i; }); if (currentImageIndex === i) { const flip = !inZoomMode; setInZoomMode(flip); onImageFocusToggle?.(i, flip); } }} onHoverCapture={() => updateHoverIndex(i)} isHoveredOutbound={currentHoveredIndex === i} src={imageArray[i][1]} /> )} ; }; interface IImage2Props { isCurrent?: boolean; isHoveredOutbound?: boolean; inFocus?: boolean; trackIndex: Nullable; hoverIndex: Nullable; previousHoverIndex: Nullable; onHoverCapture?: (index: Nullable) => void; preInit?: boolean; } export const Image2: FC = ({ preInit = false, trackIndex, hoverIndex, inFocus = false, // this is clonky but bear with me previousHoverIndex, isCurrent = false, isHoveredOutbound = false, onHoverCapture, ...props }) => { const [isHovered, setIsHovered] = useState(false); const transition = { duration: 0.5, ease: SlowDown }; return { setIsHovered(true); onHoverCapture?.(trackIndex); }} onHoverEnd={() => setIsHovered(false)} > {isCurrent && } {isCurrent && inFocus && } {isHoveredOutbound && !isCurrent && } ; }; const ImageToolbar = () => { const [mouseMove, setMouseMove] = useAtom(MouseMoveInZoomMode); const [mouseTriggerTimeout, setMouseTriggerTimeout] = useAtom(MouseMoveInZoomModeTimeout); const [forceHover, setForceHover] = useState(false); const triggerMouse = useCallback(() => { if (mouseTriggerTimeout) { clearTimeout(mouseTriggerTimeout); } setMouseMove(true); setMouseTriggerTimeout(setTimeout(() => { setMouseMove(false); setMouseTriggerTimeout(null); }, 5000) as unknown as number); }, []); const commonTransition = { duration: 0.7, ease: Forceful }; return ; }; interface IIconProps { height?: number; width?: number; overrideFG?: string; } const MagnifierIcon: FC = ({ hoverAnimation, overrideFG, ...props }) => { // const [isHovered, setIsHovered] = useState(false); const [inImageFullScreenMode, setInImageFullScreenMode] = useAtom(InImageFullScreenMode); const commonTransition = { duration: 0.7, ease: Forceful }; return setInImageFullScreenMode(!inImageFullScreenMode)} {...props} // {...(hoverAnimation ? { // onHoverStart: () => setIsHovered(true), // onHoverEnd: () => setIsHovered(false), // } : {})} > ; }; const DownloadIcon: FC = ({ hoverAnimation = false, overrideFG, ...props }) => { const [isHovered, setIsHovered] = useState(false); const imageData = useAtom(ImageData)[0]; const imageIndex = useAtom(ImageIndex)[0]; const commonTransition = { duration: 0.7, ease: Forceful }; return window.open([...imageData.keys()][imageIndex])} style={{ mixBlendMode: "exclusion" }} {...props} {...(hoverAnimation ? { onHoverStart: () => setIsHovered(true), onHoverEnd: () => setIsHovered(false), } : {})} > ; };