Enhance Modal and Popup components with drag-and-drop functionality for mobile responsiveness. Implement drag handling in ModalContainer and PopupContainer, and adjust layout in ModalWrapper and PopupWrapper for better user experience. Update Tooltip and ActionsPopover for improved positioning and visibility. Clean up unused code and comments in HomePage and popupStore.
This commit is contained in:
@@ -2,14 +2,17 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import useModalStore from "../store/modalStore";
|
||||
import clsx from "clsx";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { AnimatePresence, motion, type PanInfo } from "motion/react";
|
||||
|
||||
function ModalContainer() {
|
||||
const { modal, setModal, position } = useModalStore();
|
||||
|
||||
const divRef = useRef<HTMLDivElement>(null);
|
||||
const backdropRef = useRef<HTMLDivElement>(null);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const isMobile = innerWidth < 640;
|
||||
|
||||
function handleResize() {
|
||||
if (!modalRef.current) return;
|
||||
|
||||
@@ -25,6 +28,16 @@ function ModalContainer() {
|
||||
setModal(null);
|
||||
}
|
||||
|
||||
const handleDragEnd = (
|
||||
_event: MouseEvent | TouchEvent | PointerEvent,
|
||||
info: PanInfo
|
||||
) => {
|
||||
// Закрываем модалку если свайпнули вниз больше чем на 100px или со скоростью > 500
|
||||
if (info.offset.y > 100 || info.velocity.y > 500) {
|
||||
setModal(null);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("resize", handleResize);
|
||||
window.addEventListener("keydown", handleKeydown);
|
||||
@@ -47,19 +60,32 @@ function ModalContainer() {
|
||||
<div
|
||||
ref={modalRef}
|
||||
className={clsx(
|
||||
"bg-black/70 max-md:top-14 flex overflow-y-auto fixed inset-0 z-10 items-center",
|
||||
position === "center" ? "justify-center" : "justify-end"
|
||||
"bg-black/70 max-md:top-14 flex overflow-y-auto fixed inset-0 z-10",
|
||||
isMobile ? "items-end" : "items-center",
|
||||
!isMobile &&
|
||||
(position === "center" ? "justify-center" : "justify-end")
|
||||
)}
|
||||
>
|
||||
<div className="max-h-full">
|
||||
<div ref={divRef} className="2xl:p-[1.111vw]">
|
||||
<div className={clsx("max-h-full", isMobile && "w-full")}>
|
||||
<motion.div
|
||||
ref={divRef}
|
||||
className="2xl:p-[1.111vw]"
|
||||
initial={isMobile ? { y: "100%" } : undefined}
|
||||
animate={isMobile ? { y: 0 } : undefined}
|
||||
exit={isMobile ? { y: "100%" } : undefined}
|
||||
transition={{ bounce: 0, ease: "easeInOut" }}
|
||||
drag={isMobile ? "y" : false}
|
||||
dragConstraints={{ top: 0, bottom: 0 }}
|
||||
dragElastic={{ top: 0, bottom: 0.2 }}
|
||||
onDragEnd={isMobile ? handleDragEnd : undefined}
|
||||
>
|
||||
<div
|
||||
ref={backdropRef}
|
||||
className="absolute inset-0 cursor-pointer"
|
||||
onClick={() => setModal(null)}
|
||||
/>
|
||||
{modal}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@@ -17,12 +17,22 @@ function ModalWrapper({
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"bg-white 2xl:rounded-[2.222vw] rounded-[32px] relative",
|
||||
"bg-white 2xl:rounded-[2.222vw] rounded-[32px] relative max-sm:rounded-b-none max-sm:max-h-[97.5dvh]",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Полоска-ручка для свайпа на мобильных */}
|
||||
<div className="hidden absolute -top-3 left-1/2 justify-center pt-1 pb-1 -translate-x-1/2 max-sm:flex">
|
||||
<div className="w-8 h-1 bg-[#141414] rounded-full opacity-50" />
|
||||
</div>
|
||||
|
||||
<ModalHeader title={title} leftButton={leftButton} />
|
||||
<div className={clsx("2xl:p-[1.389vw] p-5", !title && "!pt-0")}>
|
||||
<div
|
||||
className={clsx(
|
||||
"2xl:p-[1.389vw] p-5 overflow-y-auto min-h-full bg-white",
|
||||
!title && "!pt-0"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { AnimatePresence, motion, type PanInfo } from "motion/react";
|
||||
import usePopupStore from "../store/popupStore";
|
||||
import { useEffect } from "react";
|
||||
|
||||
function PopupContainer() {
|
||||
const { popup } = usePopupStore();
|
||||
const { popup, setPopup } = usePopupStore();
|
||||
|
||||
const isMobile = innerWidth < 640;
|
||||
|
||||
useEffect(() => {
|
||||
console.log(popup);
|
||||
}, [popup]);
|
||||
const handleDragEnd = (
|
||||
_event: MouseEvent | TouchEvent | PointerEvent,
|
||||
info: PanInfo
|
||||
) => {
|
||||
// Закрываем попап если свайпнули вниз больше чем на 100px или со скоростью > 500
|
||||
if (info.offset.y > 100 || info.velocity.y > 500) setPopup(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
@@ -20,6 +23,9 @@ function PopupContainer() {
|
||||
animate={{ opacity: 1, y: isMobile ? "0%" : undefined }}
|
||||
exit={{ opacity: 0, y: isMobile ? "100%" : undefined }}
|
||||
transition={{ bounce: 0, ease: "easeInOut" }}
|
||||
drag={isMobile ? "y" : false}
|
||||
dragConstraints={{ top: 0, bottom: 0 }}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
{popup}
|
||||
</motion.div>
|
||||
|
||||
@@ -18,7 +18,7 @@ function PopupWrapper({
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"2xl:rounded-[2.222vw] relative bg-white shadow-[0_4px_40px_0_rgba(15,16,17,0.1)] 2xl:w-[21.667vw] sm:rounded-[32px] max-sm:w-screen max-sm:rounded-t-[32px]",
|
||||
"2xl:rounded-[2.222vw] bg-white shadow-[0_4px_40px_0_rgba(15,16,17,0.1)] 2xl:w-[21.667vw] sm:rounded-[32px] max-sm:w-screen max-sm:rounded-t-[32px]",
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function Warning({
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"2xl:size-[1.111vw] size-[4.444vw] border-[1px] border-white rounded-full flex items-center justify-center ",
|
||||
"2xl:size-[1.111vw] size-4 border-[1px] border-white rounded-full flex items-center justify-center ",
|
||||
type === "caution" && "bg-[#F9A530]",
|
||||
type === "critical" && "bg-[#FF4517]",
|
||||
className
|
||||
|
||||
@@ -354,10 +354,7 @@ function SettingsModal() {
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalWrapper
|
||||
title="Настройки"
|
||||
className="2xl:max-w-[27.778vw] max-w-[400px]"
|
||||
>
|
||||
<ModalWrapper title="Настройки" className="2xl:max-w-[27.778vw]">
|
||||
<div className="2xl:space-y-[1.389vw] space-y-5 2xl:-mt-[1.389vw] -mt-5">
|
||||
<div className="flex">
|
||||
<Button
|
||||
@@ -556,7 +553,7 @@ function SettingsModal() {
|
||||
className="2xl:rounded-[1.111vw] rounded-2xl 2xl:w-[25vw] w-[360px] aspect-[360/202] object-cover"
|
||||
/>
|
||||
{!isVideoTesting && (
|
||||
<div className="bg-[#F3F3F3] 2xl:w-[25vw] w-[360px] aspect-[360/202] flex justify-center items-center 2xl:rounded-[1.111vw] rounded-2xl">
|
||||
<div className="bg-[#F3F3F3] 2xl:w-[25vw] w-[360px] max-sm:w-full aspect-[360/202] flex justify-center items-center 2xl:rounded-[1.111vw] rounded-2xl">
|
||||
{cameras.length === 0 && !isLoadingCameras ? (
|
||||
<div className="2xl:space-y-[0.556vw] space-y-2 text-center 2xl:px-[1.111vw] px-4">
|
||||
<p className="title-s font-medium">Камеры не найдены</p>
|
||||
|
||||
@@ -40,7 +40,7 @@ export default function ChatPopup() {
|
||||
initialPosition={{ right: "5vw" }}
|
||||
>
|
||||
<PopupWrapper title="Чат" className="sm:overflow-hidden">
|
||||
<div className="flex flex-col 2xl:h-[19.444vw] max-sm:h-[97.5dvh] 2xl:-m-[1.389vw] -m-5">
|
||||
<div className="flex flex-col 2xl:h-[19.444vw] max-sm:h-[87.5dvh] 2xl:-m-[1.389vw] -m-5">
|
||||
<MessageFeed messages={messages} />
|
||||
<MessageInput onMessageSend={onMessageSend} />
|
||||
</div>
|
||||
@@ -59,7 +59,7 @@ function MessageFeed({ messages }: { messages: MessageItemProps[] }) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col w-full 2xl:h-[calc(100%-4.444vw)] bg-[#F0F0F0] 2xl:p-[1.111vw] p-4 pb-0 overflow-y-auto [-webkit-scrollbar]:hidden"
|
||||
className="flex flex-col w-full 2xl:h-[calc(100%-4.444vw)] h-full bg-[#F0F0F0] 2xl:p-[1.111vw] p-4 pb-0 overflow-y-auto [-webkit-scrollbar]:hidden"
|
||||
style={{ scrollbarWidth: "none", msOverflowStyle: "none" }}
|
||||
>
|
||||
{messages.length === 0 ? (
|
||||
@@ -165,7 +165,7 @@ function MessageInput({
|
||||
return (
|
||||
<div
|
||||
onClick={() => textareaRef.current?.focus()}
|
||||
className="flex w-full 2xl:min-h-[4.444vw] min-h-16 2xl:p-[1.111vw] p-4 items-end justify-between absolute bottom-0 left-0 bg-white"
|
||||
className="flex w-full 2xl:min-h-[4.444vw] min-h-16 2xl:p-[1.111vw] p-4 items-end justify-between bg-white"
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
|
||||
@@ -23,17 +23,15 @@ export default function ParticipantsPopup() {
|
||||
initialPosition={{ right: "5vw" }}
|
||||
>
|
||||
<PopupWrapper title="Участники">
|
||||
<div className="flex flex-col 2xl:gap-[1.667vw] gap-6 relative 2xl:-mt-[1.389vw] -mt-5">
|
||||
<ul className="flex flex-col gap-0 2xl:gap-[1.111vw] 2xl:max-h-[calc(11.944vw+1.389vw)] overflow-y-auto 2xl:pt-[1.389vw] pt-5">
|
||||
<div className="flex flex-col 2xl:gap-[1.667vw] gap-6 2xl:-mt-[1.389vw] -mt-5">
|
||||
<div className="flex flex-col gap-4 2xl:gap-[1.111vw] 2xl:max-h-[calc(11.944vw+1.389vw)] max-h-[73.75dvh] overflow-y-auto 2xl:pt-[1.389vw] pt-5">
|
||||
{participants.map((participant, index) => (
|
||||
<Fragment key={index}>
|
||||
<ParticipantItem id={participant.toString()} />
|
||||
{index !== participants.length - 1 && (
|
||||
<div className="w-full h-[1px] bg-[#F6F6F6]" />
|
||||
)}
|
||||
<hr className="w-full 2xl:h-[0.69vw] h-px border-[#F6F6F6] last:hidden" />
|
||||
</Fragment>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
@@ -62,31 +60,27 @@ export default function ParticipantsPopup() {
|
||||
function ParticipantItem({ id }: { id: string }) {
|
||||
const isMuted = true;
|
||||
const isNotControlling = true;
|
||||
const isMobile = window.innerWidth <= 360;
|
||||
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={parentRef}
|
||||
onTouchEnd={(ev) => ev.stopPropagation()}
|
||||
className="flex items-center justify-between w-full relative py-[2.222vw] 2xl:p-0"
|
||||
>
|
||||
<div className="flex items-center 2xl:gap-[0.833vw] gap-[3.333vw]">
|
||||
<div ref={parentRef} className="flex items-center justify-between w-full">
|
||||
<div className="flex items-center 2xl:gap-[0.833vw] gap-3">
|
||||
<Avatar size="medium" status="caution" />
|
||||
<div className="flex flex-col gap-[0.278vw]">
|
||||
<div className="flex flex-col 2xl:gap-[0.278vw] gap-1">
|
||||
<span className="button-m">Иван Иванович {id}</span>
|
||||
<span className="caption-s text-[#CCCCCC]">Роль</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex 2xl:gap-[0.556vw] gap-[2.222vw] items-center">
|
||||
<div className="flex 2xl:gap-[0.556vw] gap-2 items-center">
|
||||
{isNotControlling && (
|
||||
<div className="2xl:size-[1.111vw] size-[4.444vw] text-[#FF4517]">
|
||||
<div className="2xl:size-[1.111vw] size-4 text-[#FF4517]">
|
||||
<HandRaisedOffFilledIcon />
|
||||
</div>
|
||||
)}
|
||||
{isMuted && (
|
||||
<div className="2xl:size-[1.111vw] size-[4.444vw] text-[#FF4517]">
|
||||
<div className="2xl:size-[1.111vw] size-4 text-[#FF4517]">
|
||||
<MicrophoneOffFilledIcon />
|
||||
</div>
|
||||
)}
|
||||
@@ -115,8 +109,6 @@ function ParticipantItem({ id }: { id: string }) {
|
||||
onClick: () => {},
|
||||
},
|
||||
]}
|
||||
parentRef={isMobile ? parentRef : undefined}
|
||||
className={isMobile ? "left-0" : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useState, useRef } from "react";
|
||||
import Button from "./Button";
|
||||
import MoreIcon from "../icons/MoreIcon";
|
||||
import PopoverWrapper from "./PopoverWrapper";
|
||||
import clsx from "clsx";
|
||||
import { useClickAway } from "@uidotdev/usehooks";
|
||||
|
||||
interface ActionsPopoverProps {
|
||||
options: {
|
||||
@@ -12,77 +13,39 @@ interface ActionsPopoverProps {
|
||||
disabled?: boolean;
|
||||
}[];
|
||||
isOpened?: boolean;
|
||||
parentRef?: React.RefObject<HTMLDivElement | null>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param parentRef - Если передан, то кнопка активации опций не будет отображаться, а сам Popover будет отображаться по клику на parentRef.
|
||||
*/
|
||||
export default function ActionsPopover({
|
||||
options,
|
||||
isOpened = false,
|
||||
parentRef,
|
||||
className,
|
||||
}: ActionsPopoverProps) {
|
||||
const [opened, setOpened] = useState(isOpened);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let isHandling = false;
|
||||
|
||||
const handleClickOutside = (event: MouseEvent | TouchEvent) => {
|
||||
// Предотвращаем двойное срабатывание mousedown и touchstart
|
||||
if (isHandling) return;
|
||||
isHandling = true;
|
||||
setTimeout(() => {
|
||||
isHandling = false;
|
||||
}, 200);
|
||||
|
||||
const tgt = event.target as Node;
|
||||
const clickedInsideParentElement = parentRef?.current?.contains(tgt);
|
||||
const clickedInsidePopover = popoverRef.current?.contains(tgt);
|
||||
if (clickedInsideParentElement && !clickedInsidePopover) {
|
||||
//
|
||||
} else {
|
||||
// Если нет parentRef - закрытие только по клику вне popover и вне кнопки
|
||||
const clickedInsideButton = buttonRef.current?.contains(tgt);
|
||||
if (!clickedInsidePopover && !clickedInsideButton) {
|
||||
setOpened(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
document.addEventListener("touchstart", handleClickOutside);
|
||||
parentRef?.current?.addEventListener("touchend", () => setOpened(!opened));
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
document.removeEventListener("touchstart", handleClickOutside);
|
||||
parentRef?.current?.addEventListener("touchend", () =>
|
||||
setOpened(!opened)
|
||||
);
|
||||
};
|
||||
}, [parentRef]);
|
||||
const ref = useClickAway<HTMLDivElement>(() => setOpened(false));
|
||||
|
||||
return (
|
||||
<div className={!parentRef ? "relative" : ""} ref={popoverRef}>
|
||||
{!parentRef && (
|
||||
<button
|
||||
ref={buttonRef}
|
||||
className="flex items-center justify-center gap-[0.139vw] size-[1.667vw] rounded-[0.556vw] text-[#CCCCCC] hover:text-[#7D7D7D] hover:bg-[#F3F3F3] active:text-[#141414] "
|
||||
onClick={() => setOpened(!opened)}
|
||||
>
|
||||
<div className="2xl:size-[1.111vw] size-4 2xl:rounded-[0.556vw] rounded-2xl">
|
||||
<MoreIcon />
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
<div ref={ref} className="max-sm:max-h-6">
|
||||
<button
|
||||
ref={buttonRef}
|
||||
className={clsx(
|
||||
"2xl:rounded-[0.556vw] rounded-lg 2xl:p-[0.278vw] p-1 text-[#CCCCCC] 2xl:hover:text-[#7D7D7D] 2xl:hover:bg-[#F3F3F3] active:text-[#141414]",
|
||||
opened && "max-sm:bg-[#F3F3F3]"
|
||||
)}
|
||||
onClick={() => setOpened(!opened)}
|
||||
>
|
||||
<div className="2xl:size-[1.111vw] size-4">
|
||||
<MoreIcon />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<PopoverWrapper
|
||||
isOpened={opened}
|
||||
parentElRef={parentRef ? parentRef : buttonRef}
|
||||
parentElRef={buttonRef}
|
||||
position="vertical"
|
||||
className={clsx("2xl:w-[17.222vw] w-[53.333vw]", className)}
|
||||
>
|
||||
{options.map((option) => (
|
||||
@@ -93,9 +56,7 @@ export default function ActionsPopover({
|
||||
onClick={option.onClick}
|
||||
disabled={option.disabled}
|
||||
>
|
||||
<div className="2xl:size-[1.111vw] size-[4.444vw]">
|
||||
{option.icon}
|
||||
</div>
|
||||
<div className="2xl:size-[1.111vw] size-4">{option.icon}</div>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
|
||||
@@ -15,9 +15,9 @@ export default function Avatar({ size, status, src, name }: AvatarProps) {
|
||||
<div
|
||||
className={clsx(
|
||||
"rounded-full text-white relative",
|
||||
size === "small" && "2xl:size-[2.222vw] size-[8.889vw] button-s",
|
||||
size === "medium" && "2xl:size-[2.5vw] size-[10vw] button-m",
|
||||
size === "large" && "2xl:size-[3.333vw] size-[13.333vw] title-s"
|
||||
size === "small" && "2xl:size-[2.222vw] size-8 button-s",
|
||||
size === "medium" && "2xl:size-[2.5vw] size-9 button-m",
|
||||
size === "large" && "2xl:size-[3.333vw] size-12 title-s"
|
||||
)}
|
||||
>
|
||||
{GetAvatarImage(src, name)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import FloatingActionButton from "./FloatingActionButton";
|
||||
import PopoverWrapper from "./PopoverWrapper";
|
||||
import MoreIcon from "../icons/MoreIcon";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import Button from "./Button";
|
||||
import ChatFilledIcon from "../icons/ChatFilledIcon";
|
||||
import UsersFilledIcon from "../icons/UsersFilledIcon";
|
||||
@@ -14,44 +14,40 @@ import ParticipantsPopup from "../popups/ParticipantsPopup";
|
||||
import SharePopup from "../popups/SharePopup";
|
||||
import SettingsModal from "../modals/SettingsModal";
|
||||
import clsx from "clsx";
|
||||
import { useClickAway } from "@uidotdev/usehooks";
|
||||
|
||||
function ControlsPopover() {
|
||||
const [isOpened, setIsOpened] = useState(false);
|
||||
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent | TouchEvent) => {
|
||||
if (
|
||||
buttonRef.current &&
|
||||
!buttonRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpened(false);
|
||||
}
|
||||
};
|
||||
const ref = useClickAway<HTMLDivElement>(() => setIsOpened(false));
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
document.addEventListener("touchstart", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
document.removeEventListener("touchstart", handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const { popup, setPopup } = usePopupStore();
|
||||
const { setPopup } = usePopupStore();
|
||||
const { setModal } = useModalStore();
|
||||
|
||||
function handleClickOpenChatPopup() {
|
||||
console.log("handleClickOpenChatPopup");
|
||||
setIsOpened(false);
|
||||
setPopup(<ChatPopup />);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
console.log(popup);
|
||||
}, [popup]);
|
||||
function handleClickOpenParticipantsPopup() {
|
||||
setIsOpened(false);
|
||||
setPopup(<ParticipantsPopup />);
|
||||
}
|
||||
|
||||
function handleClickOpenSharePopup() {
|
||||
setIsOpened(false);
|
||||
setPopup(<SharePopup link="https://estate.stream/ahdy12jdco1" />);
|
||||
}
|
||||
|
||||
function handleClickOpenSettingsModal() {
|
||||
setIsOpened(false);
|
||||
setModal(<SettingsModal />);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative order-3 2xl:hidden">
|
||||
<div className="order-3 2xl:hidden" ref={ref}>
|
||||
<FloatingActionButton
|
||||
ref={buttonRef}
|
||||
className={clsx(isOpened && "!bg-[#7B60F3]")}
|
||||
@@ -64,7 +60,8 @@ function ControlsPopover() {
|
||||
<PopoverWrapper
|
||||
isOpened={isOpened}
|
||||
parentElRef={buttonRef}
|
||||
className="w-[248px] !bottom-[72px] min-h-full !fixed left-1/2 -translate-x-1/2"
|
||||
position="center"
|
||||
className="w-[248px]"
|
||||
>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
@@ -79,7 +76,7 @@ function ControlsPopover() {
|
||||
<Button
|
||||
variant="tertiary"
|
||||
className="w-full !justify-start"
|
||||
onClick={() => setPopup(<ParticipantsPopup />)}
|
||||
onClick={handleClickOpenParticipantsPopup}
|
||||
>
|
||||
<div className="size-4">
|
||||
<UsersFilledIcon />
|
||||
@@ -89,9 +86,7 @@ function ControlsPopover() {
|
||||
<Button
|
||||
variant="tertiary"
|
||||
className="w-full !justify-start"
|
||||
onClick={() =>
|
||||
setPopup(<SharePopup link="https://estate.stream/ahdy12jdco1" />)
|
||||
}
|
||||
onClick={handleClickOpenSharePopup}
|
||||
>
|
||||
<div className="size-4">
|
||||
<ShareFilledIcon />
|
||||
@@ -101,7 +96,7 @@ function ControlsPopover() {
|
||||
<Button
|
||||
variant="tertiary"
|
||||
className="w-full !justify-start"
|
||||
onClick={() => setModal(<SettingsModal />)}
|
||||
onClick={handleClickOpenSettingsModal}
|
||||
>
|
||||
<div className="size-4">
|
||||
<CogFilledIcon />
|
||||
|
||||
@@ -7,6 +7,7 @@ interface PopoverProps {
|
||||
parentElRef: React.RefObject<HTMLButtonElement | HTMLDivElement | null>;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
position?: "side" | "center" | "vertical"; // side - сбоку от элемента, center - по центру экрана, vertical - сверху/снизу
|
||||
}
|
||||
|
||||
function PopoverWrapper({
|
||||
@@ -14,18 +15,56 @@ function PopoverWrapper({
|
||||
parentElRef,
|
||||
children,
|
||||
className,
|
||||
position = "side",
|
||||
}: PopoverProps) {
|
||||
const [openUpwards, setOpenUpwards] = useState(false);
|
||||
const [menuHeight, setMenuHeight] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpened && parentElRef.current) {
|
||||
if (
|
||||
isOpened &&
|
||||
parentElRef.current &&
|
||||
(position === "side" || position === "vertical")
|
||||
) {
|
||||
const buttonRect = parentElRef.current.getBoundingClientRect();
|
||||
const spaceBelow = window.innerHeight - buttonRect.bottom;
|
||||
const spaceAbove = buttonRect.top;
|
||||
setOpenUpwards(spaceBelow < menuHeight && spaceAbove > menuHeight);
|
||||
}
|
||||
}, [parentElRef, isOpened, menuHeight]);
|
||||
}, [parentElRef, isOpened, menuHeight, position]);
|
||||
|
||||
const getPositionStyles = () => {
|
||||
if (!parentElRef.current) return {};
|
||||
|
||||
const buttonRect = parentElRef.current.getBoundingClientRect();
|
||||
|
||||
if (position === "center") {
|
||||
// Позиционирование по центру экрана
|
||||
return {
|
||||
left: "50%",
|
||||
bottom: buttonRect.height + 16, // 16px отступ от кнопки
|
||||
transform: "translateX(-50%)",
|
||||
};
|
||||
}
|
||||
|
||||
if (position === "vertical") {
|
||||
// Позиционирование сверху или снизу от элемента, выравнивание по правому краю
|
||||
return {
|
||||
left: buttonRect.right,
|
||||
top: openUpwards
|
||||
? buttonRect.top - menuHeight - 4
|
||||
: buttonRect.bottom + 4,
|
||||
};
|
||||
}
|
||||
|
||||
// Позиционирование сбоку (существующая логика)
|
||||
return {
|
||||
left: buttonRect.left + buttonRect.width,
|
||||
top: openUpwards
|
||||
? buttonRect.top - menuHeight - 4
|
||||
: buttonRect.bottom + 4,
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
@@ -37,9 +76,11 @@ function PopoverWrapper({
|
||||
ref={(el) => {
|
||||
if (el) setMenuHeight(el.offsetHeight);
|
||||
}}
|
||||
style={getPositionStyles()}
|
||||
className={clsx(
|
||||
"absolute z-10 right-0 shadow-[0_4px_40px_0_rgba(15,16,17,0.1)] overflow-hidden bg-white 2xl:rounded-[1.111vw] rounded-2xl",
|
||||
openUpwards ? "bottom-full" : "top-full",
|
||||
"fixed z-10 shadow-[0_4px_40px_0_rgba(15,16,17,0.1)] overflow-hidden bg-white 2xl:rounded-[1.111vw] rounded-2xl",
|
||||
position === "side" && "-translate-x-full",
|
||||
position === "vertical" && "-translate-x-full",
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -30,7 +30,7 @@ export default function Tooltip({
|
||||
useEffect(() => {
|
||||
if (!tooltipWrapperRef.current) return;
|
||||
const current = tooltipWrapperRef.current;
|
||||
|
||||
|
||||
current.addEventListener("mouseenter", () => setIsVisible(true));
|
||||
current.addEventListener("mouseleave", () => setIsVisible(false));
|
||||
return () => {
|
||||
@@ -41,18 +41,36 @@ export default function Tooltip({
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible || !tooltipRef.current || !tooltipWrapperRef.current) return;
|
||||
const pos = calculatePosition(position, tooltipRef, tooltipWrapperRef);
|
||||
setTooltipPosition(pos);
|
||||
|
||||
const updatePosition = () => {
|
||||
const pos = calculatePosition(position, tooltipRef, tooltipWrapperRef);
|
||||
setTooltipPosition(pos);
|
||||
};
|
||||
|
||||
const handleScroll = () => {
|
||||
setIsVisible(false);
|
||||
};
|
||||
|
||||
updatePosition();
|
||||
|
||||
// Скрываем tooltip при скролле, обновляем позицию при ресайзе
|
||||
window.addEventListener("scroll", handleScroll, true);
|
||||
window.addEventListener("resize", updatePosition);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("scroll", handleScroll, true);
|
||||
window.removeEventListener("resize", updatePosition);
|
||||
};
|
||||
}, [isVisible, position]);
|
||||
|
||||
return (
|
||||
<div ref={tooltipWrapperRef} className="relative hover:cursor-pointer">
|
||||
<div ref={tooltipWrapperRef} className="cursor-pointer">
|
||||
{children}
|
||||
{isVisible && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: showDelay / 1000, duration: 0 }}
|
||||
transition={{ delay: showDelay / 1000, duration: 0.3 }}
|
||||
>
|
||||
<TooltipContent
|
||||
label={label}
|
||||
@@ -78,7 +96,7 @@ function TooltipContent({
|
||||
<div
|
||||
ref={ref}
|
||||
style={{ ...position, transform: position.transform || "none" }}
|
||||
className="z-[9999] absolute px-[0.694vw] py-[0.417vw] bg-[#00000073] caption-s text-white rounded-[0.556vw] whitespace-nowrap backdrop-blur-[4px] shadow-[0_2px_4px_0_#00000059] cursor-default"
|
||||
className="z-[9999] fixed 2xl:px-[0.694vw] 2xl:py-[0.417vw] px-[10px] py-[6px] bg-[#00000073] caption-s text-white 2xl:rounded-[0.556vw] rounded-lg whitespace-nowrap backdrop-blur-[4px] shadow-[0_2px_4px_0_#00000059] cursor-default"
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
@@ -96,49 +114,77 @@ function calculatePosition(
|
||||
const tooltipRect = tooltipRef.current.getBoundingClientRect();
|
||||
const GAP = 8;
|
||||
|
||||
function adjustHorizontal() {
|
||||
let left = wrapperRect.width / 2;
|
||||
function adjustHorizontal(baseLeft: number) {
|
||||
let left = baseLeft;
|
||||
let transform = "translateX(-50%)";
|
||||
|
||||
const tooltipLeft = wrapperRect.left + left - tooltipRect.width / 2;
|
||||
const tooltipLeft = left - tooltipRect.width / 2;
|
||||
const tooltipRight = tooltipLeft + tooltipRect.width;
|
||||
|
||||
// Если не влезает горизонтально, то сдвигаем
|
||||
if (tooltipLeft < GAP) {
|
||||
left = GAP - wrapperRect.left;
|
||||
transform = "none";
|
||||
left = GAP + tooltipRect.width / 2;
|
||||
transform = "translateX(-50%)";
|
||||
} else if (tooltipRight > window.innerWidth - GAP) {
|
||||
left = window.innerWidth - GAP - wrapperRect.left - tooltipRect.width;
|
||||
transform = "none";
|
||||
left = window.innerWidth - GAP - tooltipRect.width / 2;
|
||||
transform = "translateX(-50%)";
|
||||
}
|
||||
|
||||
return { left, transform };
|
||||
}
|
||||
|
||||
function adjustVertical() {
|
||||
return { top: wrapperRect.height / 2, transform: "translateY(-50%)" };
|
||||
function adjustVertical(baseTop: number) {
|
||||
return { top: baseTop, transform: "translateY(-50%)" };
|
||||
}
|
||||
|
||||
switch (position) {
|
||||
case "top": {
|
||||
return wrapperRect.bottom + tooltipRect.height + GAP < 0
|
||||
? { bottom: -tooltipRect.height - GAP, ...adjustHorizontal() }
|
||||
: { top: -tooltipRect.height - GAP, ...adjustHorizontal() };
|
||||
const top = wrapperRect.top - tooltipRect.height - GAP;
|
||||
const left = wrapperRect.left + wrapperRect.width / 2;
|
||||
|
||||
// Если не влезает сверху, показываем снизу
|
||||
if (top < GAP) {
|
||||
const bottomTop = wrapperRect.bottom + GAP;
|
||||
return { top: bottomTop, ...adjustHorizontal(left) };
|
||||
}
|
||||
|
||||
return { top, ...adjustHorizontal(left) };
|
||||
}
|
||||
case "bottom": {
|
||||
return wrapperRect.bottom + tooltipRect.height + GAP > window.innerHeight
|
||||
? { top: -tooltipRect.height - GAP, ...adjustHorizontal() }
|
||||
: { bottom: -tooltipRect.height - GAP, ...adjustHorizontal() };
|
||||
const top = wrapperRect.bottom + GAP;
|
||||
const left = wrapperRect.left + wrapperRect.width / 2;
|
||||
|
||||
// Если не влезает снизу, показываем сверху
|
||||
if (top + tooltipRect.height > window.innerHeight - GAP) {
|
||||
const topTop = wrapperRect.top - tooltipRect.height - GAP;
|
||||
return { top: topTop, ...adjustHorizontal(left) };
|
||||
}
|
||||
|
||||
return { top, ...adjustHorizontal(left) };
|
||||
}
|
||||
case "left": {
|
||||
return wrapperRect.left - tooltipRect.width - GAP < 0
|
||||
? { right: -tooltipRect.width - GAP, ...adjustVertical() }
|
||||
: { left: -tooltipRect.width - GAP, ...adjustVertical() };
|
||||
const left = wrapperRect.left - tooltipRect.width - GAP;
|
||||
const top = wrapperRect.top + wrapperRect.height / 2;
|
||||
|
||||
// Если не влезает слева, показываем справа
|
||||
if (left < GAP) {
|
||||
const rightLeft = wrapperRect.right + GAP;
|
||||
return { left: rightLeft, ...adjustVertical(top) };
|
||||
}
|
||||
|
||||
return { left, ...adjustVertical(top) };
|
||||
}
|
||||
case "right": {
|
||||
return wrapperRect.right + tooltipRect.width + GAP > window.innerWidth
|
||||
? { left: -tooltipRect.width - GAP, ...adjustVertical() }
|
||||
: { right: -tooltipRect.width - GAP, ...adjustVertical() };
|
||||
const left = wrapperRect.right + GAP;
|
||||
const top = wrapperRect.top + wrapperRect.height / 2;
|
||||
|
||||
// Если не влезает справа, показываем слева
|
||||
if (left + tooltipRect.width > window.innerWidth - GAP) {
|
||||
const leftLeft = wrapperRect.left - tooltipRect.width - GAP;
|
||||
return { left: leftLeft, ...adjustVertical(top) };
|
||||
}
|
||||
|
||||
return { left, ...adjustVertical(top) };
|
||||
}
|
||||
case "cursor": {
|
||||
return { transform: "none" };
|
||||
|
||||
@@ -7,12 +7,10 @@ import usePopupStore from "../store/popupStore";
|
||||
import SettingsModal from "../components/modals/SettingsModal";
|
||||
import useModalStore from "../store/modalStore";
|
||||
import CogFilledIcon from "../components/icons/CogFilledIcon";
|
||||
// import SessionUsersPanel from "../components/SessionUsersPanel";
|
||||
// import { useEffect } from "react";
|
||||
// import useToastsStore from "../store/toastsStore";
|
||||
import ChatPopup from "../components/popups/ChatPopup";
|
||||
import ChatFilledIcon from "../components/icons/ChatFilledIcon";
|
||||
import ParticipantsPopup from "../components/popups/ParticipantsPopup";
|
||||
import ControlsPopover from "../components/ui/ControlsPopover";
|
||||
|
||||
function HomePage() {
|
||||
const { data: user } = useMe();
|
||||
@@ -27,31 +25,6 @@ function HomePage() {
|
||||
const { setPopup } = usePopupStore();
|
||||
const { setModal } = useModalStore();
|
||||
|
||||
// -------------------------------- Тосты --------------------------------
|
||||
// const { addToast } = useToastsStore();
|
||||
|
||||
// useEffect(() => {
|
||||
// addToast({
|
||||
// id: crypto.randomUUID(),
|
||||
// type: "warning",
|
||||
// title: "Тестовое предупреждение",
|
||||
// message: "Это тестовое предупреждение",
|
||||
// onDeny: () => {},
|
||||
// onAllow: () => {},
|
||||
// });
|
||||
// const timer = setTimeout(() => {
|
||||
// addToast({
|
||||
// id: crypto.randomUUID(),
|
||||
// type: "notification",
|
||||
// title: "Тестовое уведомление",
|
||||
// message: "Это тестовое уведомление",
|
||||
// onDeny: () => {},
|
||||
// onAllow: () => {},
|
||||
// });
|
||||
// }, 1000);
|
||||
// return () => clearTimeout(timer);
|
||||
// }, []);
|
||||
|
||||
return (
|
||||
<div className="py-8 min-h-screen bg-gray-50">
|
||||
<div className="px-4 mx-auto max-w-4xl">
|
||||
@@ -76,7 +49,6 @@ function HomePage() {
|
||||
<ChatFilledIcon />
|
||||
</div>
|
||||
</FloatingActionButton>
|
||||
{/* <SessionUsersPanel /> */}
|
||||
|
||||
<FloatingActionButton
|
||||
variant="default"
|
||||
@@ -86,6 +58,7 @@ function HomePage() {
|
||||
<CogFilledIcon />
|
||||
</div>
|
||||
</FloatingActionButton>
|
||||
<ControlsPopover />
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||
|
||||
@@ -4,15 +4,11 @@ import type { ReactNode } from "react";
|
||||
interface PopupState {
|
||||
popup: ReactNode | null;
|
||||
setPopup: (popup: ReactNode | null) => void;
|
||||
// position: { x: number; y: number };
|
||||
// setPosition: (position: { x: number; y: number }) => void;
|
||||
}
|
||||
|
||||
const usePopupStore = create<PopupState>()((set) => ({
|
||||
popup: null,
|
||||
setPopup: (popup) => set({ popup }),
|
||||
// position: { x: 0, y: 0 },
|
||||
// setPosition: (position) => set({ position }),
|
||||
}));
|
||||
|
||||
export default usePopupStore;
|
||||
|
||||
Reference in New Issue
Block a user