diff --git a/src/components/popout/FloatingAnchor.tsx b/src/components/popout/FloatingAnchor.tsx new file mode 100644 index 00000000..1a66af55 --- /dev/null +++ b/src/components/popout/FloatingAnchor.tsx @@ -0,0 +1,47 @@ +import { ReactNode, useEffect, useRef } from "react"; + +export function createFloatingAnchorEvent(id: string): string { + return `__floating::anchor::${id}`; +} + +interface Props { + for: string; + children?: ReactNode; +} + +export function FloatingAnchor(props: Props) { + const ref = useRef(null); + const old = useRef(null); + + useEffect(() => { + if (!ref.current) return; + + let cancelled = false; + function render() { + if (cancelled) return; + + if (ref.current) { + const current = old.current; + const newer = ref.current.getBoundingClientRect(); + const newerStr = JSON.stringify(newer); + if (current !== newerStr) { + old.current = newerStr; + const evtStr = createFloatingAnchorEvent(props.for); + (window as any)[evtStr] = newer; + const evObj = new CustomEvent(createFloatingAnchorEvent(props.for), { + detail: newer, + }); + document.dispatchEvent(evObj); + } + } + window.requestAnimationFrame(render); + } + + window.requestAnimationFrame(render); + return () => { + cancelled = true; + }; + }, [props]); + + return
{props.children}
; +} diff --git a/src/components/popout/FloatingCard.tsx b/src/components/popout/FloatingCard.tsx new file mode 100644 index 00000000..bb8a3fb3 --- /dev/null +++ b/src/components/popout/FloatingCard.tsx @@ -0,0 +1,101 @@ +import { FloatingCardAnchorPosition } from "@/components/popout/positions/FloatingCardAnchorPosition"; +import { FloatingCardMobilePosition } from "@/components/popout/positions/FloatingCardMobilePosition"; +import { useIsMobile } from "@/hooks/useIsMobile"; +import { useSpringValue, animated, easings } from "@react-spring/web"; +import { ReactNode, useCallback, useEffect, useRef } from "react"; + +interface FloatingCardProps { + children?: ReactNode; + onClose?: () => void; + id: string; +} + +interface RootFloatingCardProps extends FloatingCardProps { + className?: string; +} + +function CardBase(props: { children: ReactNode }) { + const ref = useRef(null); + const { isMobile } = useIsMobile(); + const height = useSpringValue(0, { + config: { easing: easings.easeInOutSine, duration: 300 }, + }); + const width = useSpringValue(0, { + config: { easing: easings.easeInOutSine, duration: 300 }, + }); + + const getNewHeight = useCallback(() => { + if (!ref.current) return; + const children = ref.current.querySelectorAll( + ":scope > *[data-floating-page='true']" + ); + if (children.length === 0) { + height.start(0); + width.start(0); + return; + } + const lastChild = children[children.length - 1]; + const rect = lastChild.getBoundingClientRect(); + if (height.get() === 0) { + height.set(rect.height); + width.set(rect.width); + } else { + height.start(rect.height); + width.start(rect.width); + } + }, [height, width]); + + useEffect(() => { + if (!ref.current) return; + getNewHeight(); + const observer = new MutationObserver(() => { + getNewHeight(); + }); + observer.observe(ref.current, { + attributes: false, + childList: true, + subtree: false, + }); + return () => { + observer.disconnect(); + }; + }, [getNewHeight]); + + return ( + + {props.children} + + ); +} + +export function FloatingCard(props: RootFloatingCardProps) { + const { isMobile } = useIsMobile(); + const content = {props.children}; + + if (isMobile) + return ( + + {content} + + ); + + return ( + + {content} + + ); +} + +export function PopoutFloatingCard(props: FloatingCardProps) { + return ; +} diff --git a/src/components/popout/FloatingContainer.tsx b/src/components/popout/FloatingContainer.tsx new file mode 100644 index 00000000..0bab1ac0 --- /dev/null +++ b/src/components/popout/FloatingContainer.tsx @@ -0,0 +1,56 @@ +import { Transition } from "@/components/Transition"; +import React, { ReactNode, useCallback, useEffect, useRef } from "react"; +import { createPortal } from "react-dom"; + +interface Props { + children?: ReactNode; + onClose?: () => void; + show?: boolean; + darken?: boolean; +} + +export function FloatingContainer(props: Props) { + const target = useRef(null); + + useEffect(() => { + function listen(e: MouseEvent) { + target.current = e.target as Element; + } + document.addEventListener("mousedown", listen); + return () => { + document.removeEventListener("mousedown", listen); + }; + }); + + const click = useCallback( + (e: React.MouseEvent) => { + const startedTarget = target.current; + target.current = null; + if (e.currentTarget !== e.target) return; + if (!startedTarget) return; + if (!startedTarget.isEqualNode(e.currentTarget as Element)) return; + if (props.onClose) props.onClose(); + }, + [props] + ); + + return createPortal( + +
+ +
+ + + {props.children} + +
+
, + document.body + ); +} diff --git a/src/components/popout/FloatingView.tsx b/src/components/popout/FloatingView.tsx new file mode 100644 index 00000000..d0590795 --- /dev/null +++ b/src/components/popout/FloatingView.tsx @@ -0,0 +1,35 @@ +import { Transition } from "@/components/Transition"; +import { useIsMobile } from "@/hooks/useIsMobile"; +import { ReactNode } from "react"; + +interface Props { + children?: ReactNode; + show?: boolean; + className?: string; + height: number; + width: number; +} + +export function FloatingView(props: Props) { + const { isMobile } = useIsMobile(); + if (!props.show) return null; + return ( +
+ {props.children} +
+ ); + return ( + +
+ {props.children} +
+
+ ); +} diff --git a/src/components/popout/positions/FloatingCardAnchorPosition.tsx b/src/components/popout/positions/FloatingCardAnchorPosition.tsx new file mode 100644 index 00000000..4e022834 --- /dev/null +++ b/src/components/popout/positions/FloatingCardAnchorPosition.tsx @@ -0,0 +1,80 @@ +import { createFloatingAnchorEvent } from "@/components/popout/FloatingAnchor"; +import { ReactNode, useCallback, useEffect, useRef, useState } from "react"; + +interface AnchorPositionProps { + children?: ReactNode; + id: string; + className?: string; +} + +export function FloatingCardAnchorPosition(props: AnchorPositionProps) { + const ref = useRef(null); + const [left, setLeft] = useState(0); + const [top, setTop] = useState(0); + const [cardRect, setCardRect] = useState(null); + const [anchorRect, setAnchorRect] = useState(null); + + const calculateAndSetCoords = useCallback( + (anchor: DOMRect, card: DOMRect) => { + const buttonCenter = anchor.left + anchor.width / 2; + const bottomReal = window.innerHeight - anchor.bottom; + + setTop( + window.innerHeight - bottomReal - anchor.height - card.height - 30 + ); + setLeft( + Math.min( + buttonCenter - card.width / 2, + window.innerWidth - card.width - 30 + ) + ); + }, + [] + ); + + useEffect(() => { + if (!anchorRect || !cardRect) return; + calculateAndSetCoords(anchorRect, cardRect); + }, [anchorRect, calculateAndSetCoords, cardRect]); + + useEffect(() => { + if (!ref.current) return; + function checkBox() { + const divRect = ref.current?.getBoundingClientRect(); + setCardRect(divRect ?? null); + } + checkBox(); + const observer = new ResizeObserver(checkBox); + observer.observe(ref.current); + return () => { + observer.disconnect(); + }; + }, []); + + useEffect(() => { + const evtStr = createFloatingAnchorEvent(props.id); + if ((window as any)[evtStr]) setAnchorRect((window as any)[evtStr]); + function listen(ev: CustomEvent) { + setAnchorRect(ev.detail); + } + document.addEventListener(evtStr, listen as any); + return () => { + document.removeEventListener(evtStr, listen as any); + }; + }, [props.id]); + + return ( +
+ {props.children} +
+ ); +} diff --git a/src/components/popout/positions/FloatingCardMobilePosition.tsx b/src/components/popout/positions/FloatingCardMobilePosition.tsx new file mode 100644 index 00000000..ed8905f7 --- /dev/null +++ b/src/components/popout/positions/FloatingCardMobilePosition.tsx @@ -0,0 +1,93 @@ +import { useSpring, animated, config } from "@react-spring/web"; +import { useDrag } from "@use-gesture/react"; +import { ReactNode, useEffect, useRef, useState } from "react"; + +interface MobilePositionProps { + children?: ReactNode; + className?: string; + onClose?: () => void; +} + +export function FloatingCardMobilePosition(props: MobilePositionProps) { + const ref = useRef(null); + const height = 500; + const closing = useRef(false); + const [cardRect, setCardRect] = useState(null); + const [{ y }, api] = useSpring(() => ({ + y: 0, + onRest() { + if (!closing.current) return; + if (props.onClose) props.onClose(); + }, + })); + + const bind = useDrag( + ({ last, velocity: [, vy], direction: [, dy], movement: [, my] }) => { + if (closing.current) return; + if (last) { + // if past half height downwards + // OR Y velocity is past 0.5 AND going down AND 20 pixels below start position + if (my > height * 0.5 || (vy > 0.5 && dy > 0 && my > 20)) { + api.start({ + y: height * 1.2, + immediate: false, + config: { ...config.wobbly, velocity: vy, clamp: true }, + }); + closing.current = true; + } else { + api.start({ + y: 0, + immediate: false, + config: config.wobbly, + }); + } + } else { + api.start({ y: my, immediate: true }); + } + }, + { + from: () => [0, y.get()], + filterTaps: true, + bounds: { top: 0 }, + rubberband: true, + } + ); + + useEffect(() => { + if (!ref.current) return; + function checkBox() { + const divRect = ref.current?.getBoundingClientRect(); + setCardRect(divRect ?? null); + } + checkBox(); + const observer = new ResizeObserver(checkBox); + observer.observe(ref.current); + return () => { + observer.disconnect(); + }; + }, []); + + return ( +
+ +
+ {props.children} +
+ +
+ ); +} diff --git a/src/setup/App.tsx b/src/setup/App.tsx index e82f57d7..331337db 100644 --- a/src/setup/App.tsx +++ b/src/setup/App.tsx @@ -13,6 +13,7 @@ import { ProviderTesterView } from "@/views/developer/ProviderTesterView"; import { EmbedTesterView } from "@/views/developer/EmbedTesterView"; import { BannerContextProvider } from "@/hooks/useBanner"; import { Layout } from "@/setup/Layout"; +import { TestView } from "@/views/developer/TestView"; function App() { return ( @@ -42,6 +43,7 @@ function App() { {/* other */} + +
); diff --git a/src/views/developer/TestView.tsx b/src/views/developer/TestView.tsx new file mode 100644 index 00000000..88e47c47 --- /dev/null +++ b/src/views/developer/TestView.tsx @@ -0,0 +1,70 @@ +import { Button } from "@/components/Button"; +import { FloatingAnchor } from "@/components/popout/FloatingAnchor"; +import { PopoutFloatingCard } from "@/components/popout/FloatingCard"; +import { FloatingContainer } from "@/components/popout/FloatingContainer"; +import { FloatingView } from "@/components/popout/FloatingView"; +import { useEffect, useRef, useState } from "react"; + +// simple empty view, perfect for putting in tests +export function TestView() { + const [show, setShow] = useState(false); + const [page, setPage] = useState("main"); + const [left, setLeft] = useState(600); + const direction = useRef(1); + + useEffect(() => { + const step = 0; + const interval = setInterval(() => { + setLeft((v) => { + const newVal = v + direction.current * step; + if (newVal > window.innerWidth || newVal < 0) { + direction.current *= -1; + } + return v + direction.current * step; + }); + }, 10); + + return () => { + clearInterval(interval); + }; + }, []); + + return ( +
+ setShow(false)}> + setShow(false)}> + +

Hello world

+ +
+ + + +
+
+
+ +
setShow((v) => !v)} + /> + +
+
+ ); +}