loading
Just a moment.

Web Exploding Menu

November 2024

monks doing ambient music
import { useState } from 'react';
import {
  AnimatePresence,
  MotionValue,
  motion,
  useMotionValue,
  useSpring,
} from 'framer-motion';
import useLongPress from '@/hook/useLongPress';
import { useIsTouchDevice } from '@/hook/useIsTouchDevice';

interface IconProps {
  className?: string
}

const HeartIcon = (props: IconProps) => {
  return (
    <svg className={props.className} xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
      <path fill="black" fillRule="evenodd" d="M5.624 4.424C3.965 5.182 2.75 6.986 2.75 9.137c0 2.197.9 3.891 2.188 5.343c1.063 1.196 2.349 2.188 3.603 3.154q.448.345.885.688c.526.415.995.778 1.448 1.043s.816.385 1.126.385s.674-.12 1.126-.385c.453-.265.922-.628 1.448-1.043q.437-.344.885-.687c1.254-.968 2.54-1.959 3.603-3.155c1.289-1.452 2.188-3.146 2.188-5.343c0-2.15-1.215-3.955-2.874-4.713c-1.612-.737-3.778-.542-5.836 1.597a.75.75 0 0 1-1.08 0C9.402 3.882 7.236 3.687 5.624 4.424M12 4.46C9.688 2.39 7.099 2.1 5 3.059C2.786 4.074 1.25 6.426 1.25 9.138c0 2.665 1.11 4.699 2.567 6.339c1.166 1.313 2.593 2.412 3.854 3.382q.43.33.826.642c.513.404 1.063.834 1.62 1.16s1.193.59 1.883.59s1.326-.265 1.883-.59c.558-.326 1.107-.756 1.62-1.16q.396-.312.826-.642c1.26-.97 2.688-2.07 3.854-3.382c1.457-1.64 2.567-3.674 2.567-6.339c0-2.712-1.535-5.064-3.75-6.077c-2.099-.96-4.688-.67-7 1.399" clipRule="evenodd" />
    </svg>
  )
}

const PlusIcon = (props: IconProps) => {
  return (
    <svg className={props.className} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
      <path fill="none" stroke="black" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 12h14m-7-7v14" />
    </svg>
  )
}

const CopyIcon = (props: IconProps) => {
  return (
    <svg className={props.className} xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
      <g fill="none" stroke="black" strokeWidth="1.5">
        <path d="M6 11c0-2.828 0-4.243.879-5.121C7.757 5 9.172 5 12 5h3c2.828 0 4.243 0 5.121.879C21 6.757 21 8.172 21 11v5c0 2.828 0 4.243-.879 5.121C19.243 22 17.828 22 15 22h-3c-2.828 0-4.243 0-5.121-.879C6 20.243 6 18.828 6 16z" />
        <path d="M6 19a3 3 0 0 1-3-3v-6c0-3.771 0-5.657 1.172-6.828S7.229 2 11 2h4a3 3 0 0 1 3 3" opacity="0.5" />
      </g>
    </svg>
  )
}

const DownloadIcon = (props: IconProps) => {
  return (
    <svg className={props.className} xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
      <path fill="none" stroke="black" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M4 16.004V17a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3v-1M12 4.5v11m3.5-3.5L12 15.5L8.5 12" />
    </svg>
  )
}

const MENU_ITEMS = [
  {
    icon: <HeartIcon className="text-primary-light-12 w-5 h-5" />,
    label: 'Like',
    onClick: () => console.log('like'),
  },
  {
    icon: <DownloadIcon className="text-primary-light-12 w-5 h-5" />,
    label: 'Download',
    onClick: () => console.log('download'),
  },
  {
    icon: <CopyIcon className="text-primary-light-12 w-5 h-5" />,
    label: 'Copy',
    onClick: () => console.log('Copy'),
  },
  {
    icon: <PlusIcon className="text-primary-light-12 w-5 h-5" />,
    label: 'Plus',
    onClick: () => console.log('add'),
  },
];

const mapRange = (
  inputLower: number,
  inputUpper: number,
  outputLower: number,
  outputUpper: number
) => {
  const INPUT_RANGE = inputUpper - inputLower;
  const OUTPUT_RANGE = outputUpper - outputLower;

  return (value: number) =>
    outputLower + (((value - inputLower) / INPUT_RANGE) * OUTPUT_RANGE || 0);
};

function ExplodingMenuItem({
  item,
  index,
}: {
  item: typeof MENU_ITEMS[0];
  index: number;
}) {
  const DISTANCE_INCREMENT = 14;
  const x = useSpring(useMotionValue(0), { mass: 0.005, stiffness: 100 });
  const y = useSpring(useMotionValue(0), { mass: 0.005, stiffness: 100 });
  const distance = `${index * DISTANCE_INCREMENT}%`;

  const setTransform = (
    item: HTMLElement & EventTarget,
    event: React.PointerEvent,
    x: MotionValue,
    y: MotionValue
  ) => {
    const bounds = item.getBoundingClientRect();
    const relativeX = event.clientX - bounds.left;
    const relativeY = event.clientY - bounds.top;
    const xRange = mapRange(0, bounds.width, -1, 1)(relativeX);
    const yRange = mapRange(0, bounds.height, -1, 1)(relativeY);

    x.set(xRange * 3);
    y.set(yRange * 3);
  };

  return (
    <motion.li
      key={item.label}
      style={{
        offsetDistance: distance,
        position: 'absolute',
        offsetRotate: '0deg',
        left: 16,
        top: 16,
        x,
        y,
      }}
      whileHover={{ scale: 1.15 }}
      initial={{
        opacity: 0,
        scale: 0.5,
        offsetPath: `path("M 0 0 m 0 0 a 9.6 9.6 90 1 0 0 0 a 9.6 9.6 90 1 0 0 0")`,
      }}
      animate={{
        opacity: 1,
        scale: 1,
        offsetPath: `path("M 0 0 m -0 -48 a 48 48 180 1 0 0 96 a 48 48 180 1 0 -0 -96")`,
      }}
      exit={{
        opacity: 0,
        scale: 0.5,
        offsetPath: `path("M 0 0 m 0 0 a 9.6 9.6 90 1 0 0 0 a 9.6 9.6 90 1 0 0 0")`,
      }}
      onPointerMove={(event) => {
        const item = event.currentTarget;
        setTransform(item, event, x, y);
      }}
      onPointerLeave={() => {
        x.set(0);
        y.set(0);
      }}
      transition={{ duration: 0.2, delay: index * 0.02, type: 'easeInOut' }}
    >
      <motion.button
        onClick={item.onClick}
        className="flex h-8 w-8 p-2 cursor-move items-center justify-center rounded-full bg-white/90"
      >
        {item.icon}
      </motion.button>
    </motion.li>
  );
}

export default function ExplodingMenu() {
  const [isOpen, setIsOpen] = useState(false);
  const mouseXValue = useMotionValue(0);
  const mouseYValue = useMotionValue(0);
  const isTouchDevice = useIsTouchDevice();

  const x = useMotionValue(0);
  const y = useMotionValue(0);

  const onLongPress = () => {
    x.set(mouseXValue.get());
    y.set(mouseYValue.get());

    setIsOpen(true);
  };

  const defaultOptions = {
    isPreventDefault: true,
    delay: 0,
  };

  const longPressHandlers = useLongPress(onLongPress, () => setIsOpen(false), defaultOptions);



  const handleMouseMove = (event: React.MouseEvent) => {
    const rect = event.currentTarget.getBoundingClientRect();
    mouseXValue.set(event.clientX - rect.left - 18);
    mouseYValue.set(event.clientY - rect.top - 18);
  };

  return (
    <div
      className="relative flex h-[400px] w-full flex-col items-center justify-center active:cursor-move"
      onTouchEnd={longPressHandlers.onTouchEnd}
      onMouseUp={longPressHandlers.onMouseUp}
    >
      {isTouchDevice ? (
        <p className="py-6 text-center text-sm">
          {/* This component is not made for touch devices. */}
        </p>
      ) : null}
      <div
        className="relative z-0  select-none h-96 w-72 md:h-96 md:w-96"
        onMouseMove={handleMouseMove}
        onTouchStart={(e) => longPressHandlers.onTouchStart(e as unknown as MouseEvent)}
        onMouseDown={(e) => longPressHandlers.onMouseDown(e as unknown as MouseEvent)}
      >
        <img
          src="https://i.pinimg.com/736x/73/3f/61/733f61f6a03e9c798f19762cb965f455.jpg"
          alt="monks doing ambient music"
          className="pointer-events-none h-full w-full select-none rounded-[12px] object-cover"
        />
        <AnimatePresence>
          {isOpen && (
            <motion.div
              className="absolute left-0 top-0"
              style={{
                x,
                y,
              }}
            >
              <ul className="relative">
                <li
                  className="absolute h-8 w-8 rounded-full border-4 border-neutral-200/50"
                  style={{
                    transformOrigin: 'center',
                    left: 0,
                    top: 0,
                  }}
                />
                {[...MENU_ITEMS].map((item, index) => {
                  return (
                    <ExplodingMenuItem item={item} index={index} key={index} />
                  );
                })}
              </ul>
            </motion.div>
          )}
        </AnimatePresence>
      </div>
    </div>
  );
}

//
//
//
//
// @/hook/useIsTouchDevice.tsx
//
//
//
//

// import { useEffect, useState } from "react";

// export function useIsTouchDevice() {
//   const [isTouchDevice, setIsTouchDevice] = useState(false);

//   useEffect(() => {
//     function onResize() {
//       setIsTouchDevice(
//         "ontouchstart" in window ||
//           navigator.maxTouchPoints > 0 ||
//           navigator.maxTouchPoints > 0
//       );
//     }

//     window.addEventListener("resize", onResize);
//     onResize();

//     return () => {
//       window.removeEventListener("resize", onResize);
//     };
//   }, []);

//   return isTouchDevice;
// }
// import { useCallback, useRef } from 'react';
//
//
//
//
// @/hook/useLongPress.tsx
//
//
//
//
// export const noop = () => {};

// export function on<T extends Window | Document | HTMLElement | EventTarget>(
//   obj: T | null,
//   // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
//   ...args: Parameters<T['addEventListener']> | [string, Function | null, ...unknown[]]
// ): void {
//   if (obj && obj.addEventListener) {
//     obj.addEventListener(...(args as Parameters<HTMLElement['addEventListener']>));
//   }
// }

// export function off<T extends Window | Document | HTMLElement | EventTarget>(
//   obj: T | null,
//   // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
//   ...args: Parameters<T['removeEventListener']> | [string, Function | null, ...unknown[]]
// ): void {
//   if (obj && obj.removeEventListener) {
//     obj.removeEventListener(...(args as Parameters<HTMLElement['removeEventListener']>));
//   }
// }

// export const isBrowser = typeof window !== 'undefined';

// export const isNavigator = typeof navigator !== 'undefined';

// interface Options {
//   isPreventDefault?: boolean;
//   delay?: number;
// }

// const isTouchEvent = (ev: Event): ev is TouchEvent => {
//   return 'touches' in ev;
// };

// const preventDefault = (ev: Event) => {
//   if (!isTouchEvent(ev)) return;

//   if (ev.touches.length < 2 && ev.preventDefault) {
//     ev.preventDefault();
//   }
// };

// const useLongPress = (
//   callback: (e: TouchEvent | MouseEvent) => void,
//   onCancel?: () => void,
//   { isPreventDefault = true, delay = 300 }: Options = {},
// ) => {
//   const timeout = useRef<ReturnType<typeof setTimeout>>();
//   const target = useRef<EventTarget>();

//   const start = useCallback(
//     (event: TouchEvent | MouseEvent) => {
//       // prevent ghost click on mobile devices
//       if (isPreventDefault && event.target) {
//         on(event.target, 'touchend', preventDefault, { passive: false });
//         target.current = event.target;
//       }
//       timeout.current = setTimeout(() => callback(event), delay);
//     },
//     [callback, delay, isPreventDefault]
//   );

//   const clear = useCallback(() => {
//     // eslint-disable-next-line @typescript-eslint/no-unused-expressions
//     timeout.current && clearTimeout(timeout.current);

//     if (isPreventDefault && target.current) {
//       off(target.current, 'touchend', preventDefault);
//     }
//     onCancel?.();
//   }, [isPreventDefault]);

//   return {
//     onMouseDown: (e: MouseEvent) => start(e),
//     onTouchStart: (e: MouseEvent) => start(e),
//     onMouseUp: clear,
//     onMouseLeave: clear,
//     onTouchEnd: clear,
//   } as const;
// };

// export default useLongPress;