mirror of
https://github.com/sussy-code/smov.git
synced 2024-12-21 14:47:41 +01:00
Floating component start
This commit is contained in:
parent
19d2b963a8
commit
cc51559c29
9 changed files with 485 additions and 0 deletions
47
src/components/popout/FloatingAnchor.tsx
Normal file
47
src/components/popout/FloatingAnchor.tsx
Normal file
|
@ -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<HTMLDivElement>(null);
|
||||||
|
const old = useRef<string | null>(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 <div ref={ref}>{props.children}</div>;
|
||||||
|
}
|
101
src/components/popout/FloatingCard.tsx
Normal file
101
src/components/popout/FloatingCard.tsx
Normal file
|
@ -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<HTMLDivElement>(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 (
|
||||||
|
<animated.div
|
||||||
|
ref={ref}
|
||||||
|
style={{
|
||||||
|
height,
|
||||||
|
width: isMobile ? "100%" : width,
|
||||||
|
}}
|
||||||
|
className="relative flex items-center justify-center overflow-hidden"
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</animated.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FloatingCard(props: RootFloatingCardProps) {
|
||||||
|
const { isMobile } = useIsMobile();
|
||||||
|
const content = <CardBase>{props.children}</CardBase>;
|
||||||
|
|
||||||
|
if (isMobile)
|
||||||
|
return (
|
||||||
|
<FloatingCardMobilePosition
|
||||||
|
className={props.className}
|
||||||
|
onClose={props.onClose}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</FloatingCardMobilePosition>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FloatingCardAnchorPosition id={props.id} className={props.className}>
|
||||||
|
{content}
|
||||||
|
</FloatingCardAnchorPosition>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PopoutFloatingCard(props: FloatingCardProps) {
|
||||||
|
return <FloatingCard className="rounded-md bg-ash-400 p-2" {...props} />;
|
||||||
|
}
|
56
src/components/popout/FloatingContainer.tsx
Normal file
56
src/components/popout/FloatingContainer.tsx
Normal file
|
@ -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<Element | null>(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(
|
||||||
|
<Transition show={props.show} animation="none">
|
||||||
|
<div className="pointer-events-auto fixed inset-0">
|
||||||
|
<Transition animation="fade" isChild>
|
||||||
|
<div
|
||||||
|
onClick={click}
|
||||||
|
className={[
|
||||||
|
"absolute inset-0",
|
||||||
|
props.darken ? "bg-black opacity-90" : "",
|
||||||
|
].join(" ")}
|
||||||
|
/>
|
||||||
|
</Transition>
|
||||||
|
<Transition animation="slide-up" className="h-0" isChild>
|
||||||
|
{props.children}
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</Transition>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
35
src/components/popout/FloatingView.tsx
Normal file
35
src/components/popout/FloatingView.tsx
Normal file
|
@ -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 (
|
||||||
|
<div
|
||||||
|
className={[props.className ?? "", "absolute"].join(" ")}
|
||||||
|
data-floating-page="true"
|
||||||
|
style={{
|
||||||
|
height: `${props.height}px`,
|
||||||
|
width: !isMobile ? `${props.width}px` : "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Transition animation="slide-up" show={props.show}>
|
||||||
|
<div data-floating-page="true" className={props.className}>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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<HTMLDivElement>(null);
|
||||||
|
const [left, setLeft] = useState<number>(0);
|
||||||
|
const [top, setTop] = useState<number>(0);
|
||||||
|
const [cardRect, setCardRect] = useState<DOMRect | null>(null);
|
||||||
|
const [anchorRect, setAnchorRect] = useState<DOMRect | null>(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<DOMRect>) {
|
||||||
|
setAnchorRect(ev.detail);
|
||||||
|
}
|
||||||
|
document.addEventListener(evtStr, listen as any);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener(evtStr, listen as any);
|
||||||
|
};
|
||||||
|
}, [props.id]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
style={{
|
||||||
|
transform: `translateX(${left}px) translateY(${top}px)`,
|
||||||
|
}}
|
||||||
|
className={[
|
||||||
|
"pointer-events-auto z-10 inline-block origin-top-left touch-none overflow-hidden",
|
||||||
|
props.className ?? "",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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<HTMLDivElement>(null);
|
||||||
|
const height = 500;
|
||||||
|
const closing = useRef<boolean>(false);
|
||||||
|
const [cardRect, setCardRect] = useState<DOMRect | null>(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 (
|
||||||
|
<div
|
||||||
|
className="absolute inset-x-0 mx-auto max-w-[400px] origin-bottom-left touch-none"
|
||||||
|
style={{
|
||||||
|
transform: `translateY(${
|
||||||
|
window.innerHeight - (cardRect?.height ?? 0) + 200
|
||||||
|
}px)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<animated.div
|
||||||
|
ref={ref}
|
||||||
|
className={[props.className ?? "", "touch-none"].join(" ")}
|
||||||
|
style={{
|
||||||
|
y,
|
||||||
|
}}
|
||||||
|
{...bind()}
|
||||||
|
>
|
||||||
|
<div className="mx-auto my-2 mb-4 h-1 w-12 rounded-full bg-ash-500 bg-opacity-30" />
|
||||||
|
{props.children}
|
||||||
|
<div className="h-[200px]" />
|
||||||
|
</animated.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -13,6 +13,7 @@ import { ProviderTesterView } from "@/views/developer/ProviderTesterView";
|
||||||
import { EmbedTesterView } from "@/views/developer/EmbedTesterView";
|
import { EmbedTesterView } from "@/views/developer/EmbedTesterView";
|
||||||
import { BannerContextProvider } from "@/hooks/useBanner";
|
import { BannerContextProvider } from "@/hooks/useBanner";
|
||||||
import { Layout } from "@/setup/Layout";
|
import { Layout } from "@/setup/Layout";
|
||||||
|
import { TestView } from "@/views/developer/TestView";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
|
@ -42,6 +43,7 @@ function App() {
|
||||||
|
|
||||||
{/* other */}
|
{/* other */}
|
||||||
<Route exact path="/dev" component={DeveloperView} />
|
<Route exact path="/dev" component={DeveloperView} />
|
||||||
|
<Route exact path="/dev/test" component={TestView} />
|
||||||
<Route exact path="/dev/video" component={VideoTesterView} />
|
<Route exact path="/dev/video" component={VideoTesterView} />
|
||||||
<Route
|
<Route
|
||||||
exact
|
exact
|
||||||
|
|
|
@ -20,6 +20,7 @@ export function DeveloperView() {
|
||||||
linkText="Embed scraper tester"
|
linkText="Embed scraper tester"
|
||||||
/>
|
/>
|
||||||
<ArrowLink to="/dev/video" direction="right" linkText="Video tester" />
|
<ArrowLink to="/dev/video" direction="right" linkText="Video tester" />
|
||||||
|
<ArrowLink to="/dev/test" direction="right" linkText="Test page" />
|
||||||
</ThinContainer>
|
</ThinContainer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
70
src/views/developer/TestView.tsx
Normal file
70
src/views/developer/TestView.tsx
Normal file
|
@ -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 (
|
||||||
|
<div className="relative h-[800px] w-full rounded border border-white">
|
||||||
|
<FloatingContainer show={show} onClose={() => setShow(false)}>
|
||||||
|
<PopoutFloatingCard id="test" onClose={() => setShow(false)}>
|
||||||
|
<FloatingView
|
||||||
|
show={page === "main"}
|
||||||
|
height={400}
|
||||||
|
width={400}
|
||||||
|
className="bg-ash-200"
|
||||||
|
>
|
||||||
|
<p>Hello world</p>
|
||||||
|
<Button onClick={() => setPage("second")}>Next</Button>
|
||||||
|
</FloatingView>
|
||||||
|
<FloatingView
|
||||||
|
show={page === "second"}
|
||||||
|
height={300}
|
||||||
|
width={500}
|
||||||
|
className="bg-ash-200"
|
||||||
|
>
|
||||||
|
<Button onClick={() => setPage("main")}>Previous</Button>
|
||||||
|
</FloatingView>
|
||||||
|
</PopoutFloatingCard>
|
||||||
|
</FloatingContainer>
|
||||||
|
<div
|
||||||
|
className="absolute bottom-0"
|
||||||
|
style={{
|
||||||
|
left: `${left}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FloatingAnchor for="test">
|
||||||
|
<div
|
||||||
|
className="h-8 w-8 bg-white"
|
||||||
|
onClick={() => setShow((v) => !v)}
|
||||||
|
/>
|
||||||
|
</FloatingAnchor>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
Loading…
Reference in a new issue