diff --git a/client/src/components/DraggableContainer.tsx b/client/src/components/DraggableContainer.tsx new file mode 100644 index 0000000..730fe5f --- /dev/null +++ b/client/src/components/DraggableContainer.tsx @@ -0,0 +1,296 @@ +import { useEffect, useRef, useState } from "react"; +import type { ReactNode } from "react"; +import clsx from "clsx"; + +interface Position { + top?: number; + left?: number; + right?: number; + bottom?: number; +} + +interface DraggableContainerProps { + /** Содержимое контейнера */ + children: ReactNode; + /** Включить снэпинг к ближайшей четверти экрана при отпускании (по умолчанию true) */ + enableSnapping?: boolean; + /** Автоматическое flex-выравнивание в зависимости от прижатого угла (по умолчанию false) */ + autoAlign?: boolean; + /** Начальная позиция контейнера */ + initialPosition?: Position; + /** Отступ от краев экрана при снэпинге (по умолчанию 20px) */ + padding?: number; + /** Дополнительные CSS классы */ + className?: string; + /** Колбэк при изменении позиции */ + onPositionChange?: (position: Position) => void; +} + +/** + * Draggable Container - перетаскиваемый контейнер с опциональным снэпингом + * + * Логика снэпинга: + * - Экран делится на 4 части (пополам по ширине и высоте) + * - При отпускании определяется в какой четверти находится центр контейнера + * - Контейнер прилипает к соответствующему углу с использованием top/bottom + left/right + * + * @example + * // Базовое использование с автоматическим выравниванием + * + * + * + * + * @example + * // Без автоматического выравнивания (управляется вручную через className) + * + * + * + */ +export default function DraggableContainer({ + children, + enableSnapping = false, + autoAlign = false, + initialPosition = { top: 20, right: 20 }, + padding = 20, + className = "", + onPositionChange, +}: DraggableContainerProps) { + const containerRef = useRef(null); + const dragRef = useRef({ + isDragging: false, + startX: 0, + startY: 0, + initialPosition: { top: 0, left: 0 }, + }); + + const [position, setPosition] = useState(initialPosition); + const [isDragging, setIsDragging] = useState(false); + const [dragStartAlignment, setDragStartAlignment] = useState(""); + + const getAlignmentClassesFromPosition = (pos: Position): string => { + if (!autoAlign) return ""; + + const vertical = pos.bottom !== undefined ? "items-end" : "items-start"; + const horizontal = + pos.right !== undefined ? "justify-end" : "justify-start"; + + return `${vertical} ${horizontal}`; + }; + + const snapToQuadrant = (x: number, y: number): Position => { + const windowWidth = window.innerWidth; + const windowHeight = window.innerHeight; + const containerWidth = containerRef.current?.offsetWidth || 0; + const containerHeight = containerRef.current?.offsetHeight || 0; + + // Делим экран пополам по ширине и высоте + const halfWidth = windowWidth / 2; + const halfHeight = windowHeight / 2; + + // Определяем центр контейнера + const centerX = x + containerWidth / 2; + const centerY = y + containerHeight / 2; + + // Определяем в какой четверти находится центр контейнера + const isLeft = centerX < halfWidth; + const isTop = centerY < halfHeight; + + // Возвращаем позицию в зависимости от четверти + if (isTop && isLeft) { + // Верхняя левая четверть + return { top: padding, left: padding }; + } else if (isTop && !isLeft) { + // Верхняя правая четверть + return { top: padding, right: padding }; + } else if (!isTop && isLeft) { + // Нижняя левая четверть + return { bottom: padding, left: padding }; + } else { + // Нижняя правая четверть + return { bottom: padding, right: padding }; + } + }; + + const startDrag = (clientX: number, clientY: number) => { + if (!containerRef.current) return; + + const rect = containerRef.current.getBoundingClientRect(); + + // Сохраняем текущие классы выравнивания перед началом драга + if (autoAlign) { + setDragStartAlignment(getAlignmentClassesFromPosition(position)); + } + + dragRef.current = { + isDragging: true, + startX: clientX, + startY: clientY, + initialPosition: { + top: rect.top, + left: rect.left, + }, + }; + + setIsDragging(true); + }; + + const handleMouseDown = (e: React.MouseEvent) => { + startDrag(e.clientX, e.clientY); + }; + + const handleTouchStart = (e: React.TouchEvent) => { + if (e.touches.length > 0) { + startDrag(e.touches[0].clientX, e.touches[0].clientY); + } + }; + + const updateDragPosition = (clientX: number, clientY: number) => { + if (!dragRef.current.isDragging) return; + + const deltaX = clientX - dragRef.current.startX; + const deltaY = clientY - dragRef.current.startY; + + const newTop = dragRef.current.initialPosition.top + deltaY; + const newLeft = dragRef.current.initialPosition.left + deltaX; + + // Во время перетаскивания используем только top и left + const newPosition = { + top: newTop, + left: newLeft, + }; + + setPosition(newPosition); + }; + + const handleMouseMove = (e: MouseEvent) => { + updateDragPosition(e.clientX, e.clientY); + }; + + const handleTouchMove = (e: TouchEvent) => { + if (e.touches.length > 0) { + e.preventDefault(); // Предотвращаем скролл на мобильных + updateDragPosition(e.touches[0].clientX, e.touches[0].clientY); + } + }; + + const endDrag = () => { + if (!dragRef.current.isDragging || !containerRef.current) return; + + dragRef.current.isDragging = false; + setIsDragging(false); + setDragStartAlignment(""); // Очищаем сохраненное выравнивание + + if (enableSnapping) { + const rect = containerRef.current.getBoundingClientRect(); + const snappedPosition = snapToQuadrant(rect.left, rect.top); + + // Конвертируем текущую позицию в те же свойства, что будут в финальной + // чтобы transition работал правильно + const windowWidth = window.innerWidth; + const windowHeight = window.innerHeight; + + const intermediatePosition: Position = {}; + + // Определяем какие свойства будут в финальной позиции и устанавливаем текущие значения + if (snappedPosition.top !== undefined) { + intermediatePosition.top = rect.top; + } + if (snappedPosition.bottom !== undefined) { + intermediatePosition.bottom = windowHeight - rect.bottom; + } + if (snappedPosition.left !== undefined) { + intermediatePosition.left = rect.left; + } + if (snappedPosition.right !== undefined) { + intermediatePosition.right = windowWidth - rect.right; + } + + // Устанавливаем промежуточную позицию без transition + setPosition(intermediatePosition); + + // Через минимальную задержку устанавливаем финальную позицию с transition + setTimeout(() => { + setPosition(snappedPosition); + onPositionChange?.(snappedPosition); + }, 0); + } else { + const rect = containerRef.current.getBoundingClientRect(); + const currentPosition = { + top: rect.top, + left: rect.left, + }; + onPositionChange?.(currentPosition); + } + }; + + const handleMouseUp = () => { + endDrag(); + }; + + const handleTouchEnd = () => { + endDrag(); + }; + + useEffect(() => { + if (isDragging) { + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + document.addEventListener("touchmove", handleTouchMove, { passive: false }); + document.addEventListener("touchend", handleTouchEnd); + document.addEventListener("touchcancel", handleTouchEnd); + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + document.removeEventListener("touchmove", handleTouchMove); + document.removeEventListener("touchend", handleTouchEnd); + document.removeEventListener("touchcancel", handleTouchEnd); + }; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isDragging]); + + const getContainerStyle = (): React.CSSProperties => { + const style: React.CSSProperties = { + position: "fixed", + zIndex: 1000, + }; + + if (position.top !== undefined) style.top = `${position.top}px`; + if (position.left !== undefined) style.left = `${position.left}px`; + if (position.right !== undefined) style.right = `${position.right}px`; + if (position.bottom !== undefined) style.bottom = `${position.bottom}px`; + + return style; + }; + + const getAlignmentClasses = () => { + if (!autoAlign) return ""; + + // Во время драга используем сохраненное выравнивание + if (isDragging) { + return dragStartAlignment; + } + + // В обычном состоянии вычисляем выравнивание на основе текущей позиции + return getAlignmentClassesFromPosition(position); + }; + + return ( + + {children} + + ); +} diff --git a/client/src/components/SessionUsersPanel.tsx b/client/src/components/SessionUsersPanel.tsx index 6eb1e56..95b50b3 100644 --- a/client/src/components/SessionUsersPanel.tsx +++ b/client/src/components/SessionUsersPanel.tsx @@ -1,9 +1,12 @@ -/* eslint-disable react-hooks/exhaustive-deps */ -import { useState, useRef, useEffect } from "react"; +import { useState, useRef, useEffect, useCallback } from "react"; import UserCamera from "./ui/UserCamera"; import UserDevicesControls from "./ui/UserDevicesControls"; import clsx from "clsx"; +const DRAG_THRESHOLD = 15; +const OFFSET = 0.01111; // 1.111vw +const TRANSITION = "all 0.5s cubic-bezier(.63,.08,.37,.89)"; + export default function SessionUsersPanel() { const users = [ { @@ -33,148 +36,150 @@ export default function SessionUsersPanel() { }, ]; - function handleMute(id: number) { - console.log(`Mute user ${id}`); - } - function handleVideoOff(id: number) { - console.log(`Video off user ${id}`); - } - function handleCanControl(id: number) { - console.log(`Can control user ${id}`); - } - - const [isTop, setIsTop] = useState(false); - const [isLeft, setIsLeft] = useState(false); - const [isDragging, setIsDragging] = useState(false); - const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 }); + const [corner, setCorner] = useState({ top: false, left: false }); + const [dragState, setDragState] = useState<"idle" | "dragging" | "snapping" | "released">("idle"); + const [position, setPosition] = useState({ x: 0, y: 0 }); + const containerRef = useRef(null); - const dragOffset = useRef({ x: 0, y: 0 }); - const dragStartPos = useRef({ x: 0, y: 0 }); - const isDragStarted = useRef(false); - const DRAG_THRESHOLD = 15; + const dragDataRef = useRef({ offsetX: 0, offsetY: 0, startX: 0, startY: 0, hasStarted: false }); - const handleMove = (e: MouseEvent | TouchEvent) => { + const getPointerPos = (e: MouseEvent | TouchEvent | React.MouseEvent | React.TouchEvent) => ({ + x: "touches" in e ? e.touches[0].clientX : e.clientX, + y: "touches" in e ? e.touches[0].clientY : e.clientY, + }); + + const handleMove = useCallback((e: MouseEvent | TouchEvent) => { if (!containerRef.current) return; - if (!isDragStarted.current) { - const distance = Math.hypot( - ("clientX" in e ? e.clientX : e.touches[0].clientX) - - dragStartPos.current.x, - ("clientY" in e ? e.clientY : e.touches[0].clientY) - - dragStartPos.current.y - ); + const pos = getPointerPos(e); + const { startX, startY, offsetX, offsetY, hasStarted } = dragDataRef.current; - if (distance >= DRAG_THRESHOLD) { - isDragStarted.current = true; - setIsDragging(true); - } else { - return; - } + if (!hasStarted) { + const distance = Math.hypot(pos.x - startX, pos.y - startY); + if (distance < DRAG_THRESHOLD) return; + + dragDataRef.current.hasStarted = true; + setDragState("dragging"); } - if (isDragStarted.current) { - setDragPosition({ - x: - ("clientX" in e ? e.clientX : e.touches[0].clientX) - - dragOffset.current.x, - y: - ("clientY" in e ? e.clientY : e.touches[0].clientY) - - dragOffset.current.y, + setPosition({ x: pos.x - offsetX, y: pos.y - offsetY }); + }, []); + + const handleEnd = useCallback(() => { + if (!containerRef.current) return; + + const rect = containerRef.current.getBoundingClientRect(); + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; + const shouldBeTop = centerY < window.innerHeight / 2; + const shouldBeLeft = centerX < window.innerWidth / 2; + + if (dragDataRef.current.hasStarted) { + // Фиксируем текущую позицию без transition + setPosition({ x: rect.left, y: rect.top }); + setDragState("released"); + + // Запускаем анимацию к углу + requestAnimationFrame(() => { + requestAnimationFrame(() => { + setDragState("snapping"); + setCorner({ top: shouldBeTop, left: shouldBeLeft }); + setTimeout(() => setDragState("idle"), 500); + }); }); + } else { + setDragState("idle"); } - }; - - const handleMouseUp = () => { - if (!containerRef.current) return; - - const rect = containerRef.current.getBoundingClientRect(); - const center = { - x: rect.left + rect.width / 2, - y: rect.top + rect.height / 2, - }; - const shouldBeTop = center.y < window.innerHeight / 2; - const shouldBeLeft = center.x < window.innerWidth / 2; - - setIsDragging(!isDragStarted.current); - setIsTop(shouldBeTop); - setIsLeft(shouldBeLeft); - isDragStarted.current = false; + dragDataRef.current.hasStarted = false; window.removeEventListener("mousemove", handleMove); - window.removeEventListener("mouseup", handleMouseUp); - }; + window.removeEventListener("touchmove", handleMove); + window.removeEventListener("mouseup", handleEnd); + window.removeEventListener("touchend", handleEnd); + }, [handleMove]); - const handleMouseDown = ( - e: React.MouseEvent | React.TouchEvent - ) => { + const handleStart = (e: React.MouseEvent | React.TouchEvent) => { if (!containerRef.current) return; const rect = containerRef.current.getBoundingClientRect(); - const r_pos = { x: rect.left, y: rect.top }; - const c_pos = { - x: "clientX" in e ? e.clientX : e.touches[0].clientX, - y: "clientY" in e ? e.clientY : e.touches[0].clientY, + const pos = getPointerPos(e); + + dragDataRef.current = { + startX: pos.x, + startY: pos.y, + offsetX: pos.x - rect.left, + offsetY: pos.y - rect.top, + hasStarted: false, }; - dragStartPos.current = c_pos; - dragOffset.current = { x: c_pos.x - r_pos.x, y: c_pos.y - r_pos.y }; - setDragPosition({ x: r_pos.x, y: r_pos.y }); + setPosition({ x: rect.left, y: rect.top }); - isDragStarted.current = false; window.addEventListener("mousemove", handleMove); window.addEventListener("touchmove", handleMove); - window.addEventListener("mouseup", handleMouseUp); - window.addEventListener("touchend", handleMouseUp); + window.addEventListener("mouseup", handleEnd); + window.addEventListener("touchend", handleEnd); }; useEffect(() => { return () => { window.removeEventListener("mousemove", handleMove); - window.removeEventListener("mouseup", handleMouseUp); window.removeEventListener("touchmove", handleMove); - window.removeEventListener("touchend", handleMouseUp); + window.removeEventListener("mouseup", handleEnd); + window.removeEventListener("touchend", handleEnd); }; - }, []); + }, [handleMove, handleEnd]); - const getStyle = (): React.CSSProperties => { - if (isDragStarted.current && isDragging) { - return { - left: `${dragPosition.x}px`, - top: `${dragPosition.y}px`, - transition: "none", - }; - } + const offset = window.innerWidth * OFFSET; + + // Вычисляем финальные координаты угла + const getCornerPosition = () => { + if (!containerRef.current) return { x: offset, y: offset }; + const rect = containerRef.current.getBoundingClientRect(); return { - left: isLeft ? "1.111vw" : "calc(100vw - 1.111vw)", - top: isTop ? "1.111vw" : "calc(100vh - 1.111vw)", - transform: `translate(${isLeft ? "0" : "-100%"}, ${ - isTop ? "0" : "-100%" - })`, - transition: "all 0.5s cubic-bezier(.63,.08,.37,.89)", + x: corner.left ? offset : window.innerWidth - offset - rect.width, + y: corner.top ? offset : window.innerHeight - offset - rect.height, }; }; + + let style: React.CSSProperties; + if (dragState === "dragging" || dragState === "released") { + // Во время перетаскивания или сразу после отпускания + style = { + left: position.x, + top: position.y, + transition: "none" + }; + } else { + // Анимация к углу или покой в углу + const cornerPos = getCornerPosition(); + style = { + left: cornerPos.x, + top: cornerPos.y, + transition: dragState === "snapping" ? TRANSITION : "none", + }; + } return ( {users.map((user) => ( handleMute(user.id)} - onVideoOff={() => handleVideoOff(user.id)} - onCanControl={() => handleCanControl(user.id)} + onMute={() => console.log(`Mute user ${user.id}`)} + onVideoOff={() => console.log(`Video off user ${user.id}`)} + onCanControl={() => console.log(`Can control user ${user.id}`)} {...user} /> ))} diff --git a/client/src/components/SessionUsersPanel2.tsx b/client/src/components/SessionUsersPanel2.tsx new file mode 100644 index 0000000..0bfc8af --- /dev/null +++ b/client/src/components/SessionUsersPanel2.tsx @@ -0,0 +1,57 @@ +import UserCamera from "./ui/UserCamera"; +import UserDevicesControls from "./ui/UserDevicesControls"; +import DraggableContainer from "./DraggableContainer"; + +const users = [ + { + id: 1, + name: "John Doe", + isSpeaking: true, + isMuted: false, + isVideoOff: false, + isControlDisabled: false, + isAdmin: true, + }, + { + id: 2, + name: "Jane Doe", + isSpeaking: false, + isMuted: true, + isVideoOff: true, + isControlDisabled: true, + }, + { + id: 3, + name: "Jim Doe", + isSpeaking: false, + isMuted: false, + isVideoOff: false, + isControlDisabled: false, + }, +]; + +function SessionUsersPanel2() { + return ( + + {users.map((user) => ( + console.log(`Mute user ${user.id}`)} + onVideoOff={() => console.log(`Video off user ${user.id}`)} + onCanControl={() => console.log(`Can control user ${user.id}`)} + {...user} + /> + ))} + + + + ); +} + +export default SessionUsersPanel2; diff --git a/client/src/components/ui/UserCamera.tsx b/client/src/components/ui/UserCamera.tsx index a6ba952..49e908d 100644 --- a/client/src/components/ui/UserCamera.tsx +++ b/client/src/components/ui/UserCamera.tsx @@ -48,8 +48,8 @@ export default function UserCamera({ return ( + e.stopPropagation()} size="large" diff --git a/client/src/pages/NewSessionPage.tsx b/client/src/pages/NewSessionPage.tsx index a10b95f..9c8223a 100644 --- a/client/src/pages/NewSessionPage.tsx +++ b/client/src/pages/NewSessionPage.tsx @@ -22,7 +22,7 @@ import { PixelStreamingWrapper } from "../components/PixelStreamingWrapper"; import WarningIcon from "../components/icons/WarningIcon"; import Button from "../components/ui/Button"; import LoaderIcon from "../components/icons/LoaderIcon"; -import SessionUsersPanel from "../components/SessionUsersPanel"; +import SessionUsersPanel from "../components/SessionUsersPanel2"; function NewSessionPage() { const { setPopup, setPosition } = usePopupStore(); @@ -126,12 +126,12 @@ function NewSessionPage() { } return ( - + {session.status === "started" && session.mode === "stream" && session.server?.localIp && session.playerPort && ( - + - + - +