Refactor SessionUsersPanel to improve drag-and-drop functionality with enhanced state management and animations. Update UserCamera and UserDevicesControls for consistent styling and layout adjustments. Modify NewSessionPage to import updated SessionUsersPanel component.

This commit is contained in:
2025-10-21 17:14:50 +05:00
parent e8e781ae99
commit 0ce81e2e4f
6 changed files with 467 additions and 109 deletions
@@ -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
* // Базовое использование с автоматическим выравниванием
* <DraggableContainer enableSnapping={true} autoAlign={true}>
* <YourContent />
* </DraggableContainer>
*
* @example
* // Без автоматического выравнивания (управляется вручную через className)
* <DraggableContainer enableSnapping={true} className="flex flex-col gap-4">
* <YourContent />
* </DraggableContainer>
*/
export default function DraggableContainer({
children,
enableSnapping = false,
autoAlign = false,
initialPosition = { top: 20, right: 20 },
padding = 20,
className = "",
onPositionChange,
}: DraggableContainerProps) {
const containerRef = useRef<HTMLDivElement>(null);
const dragRef = useRef({
isDragging: false,
startX: 0,
startY: 0,
initialPosition: { top: 0, left: 0 },
});
const [position, setPosition] = useState<Position>(initialPosition);
const [isDragging, setIsDragging] = useState(false);
const [dragStartAlignment, setDragStartAlignment] = useState<string>("");
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 (
<div
ref={containerRef}
className={clsx(
"pointer-events-auto select-none touch-none",
!isDragging && "transition-all duration-500 ease-out",
isDragging ? "cursor-grabbing" : "cursor-grab",
getAlignmentClasses(),
className
)}
style={getContainerStyle()}
onMouseDown={handleMouseDown}
onTouchStart={handleTouchStart}
>
{children}
</div>
);
}
+105 -100
View File
@@ -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<HTMLDivElement>(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<HTMLDivElement> | React.TouchEvent<HTMLDivElement>
) => {
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 (
<div
ref={containerRef}
onMouseDown={handleMouseDown}
onTouchStart={handleMouseDown}
className="flex gap-4 active:cursor-grabbing cursor-grab absolute"
style={getStyle()}
onMouseDown={handleStart}
onTouchStart={handleStart}
className="flex absolute gap-4 active:cursor-grabbing cursor-grab"
style={style}
>
<div
className={clsx(
"flex gap-4 w-max",
isLeft ? "flex-row-reverse" : "flex-row",
isTop ? "items-start" : "items-end"
corner.left ? "flex-row-reverse" : "flex-row",
corner.top ? "items-start" : "items-end"
)}
>
{users.map((user) => (
<UserCamera
key={user.id}
onMute={() => 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}
/>
))}
@@ -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 (
<DraggableContainer
enableSnapping={true}
autoAlign={true}
initialPosition={{ top: 20, left: 20 }}
padding={20}
className="flex gap-4"
>
{users.map((user) => (
<UserCamera
key={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}
/>
))}
<UserDevicesControls />
</DraggableContainer>
);
}
export default SessionUsersPanel2;
+3 -3
View File
@@ -48,8 +48,8 @@ export default function UserCamera({
return (
<div
className={clsx(
"aspect-square group 2xl:rounded-[1.667vw] rounded-2xl relative flex-shrink-0 transition-[width,box-shadow,background-color] duration-300 pointer-events-auto hover:w-[10.833vw] w-[6.944vw] shadow-[0_4px_40px_0_rgba(15,16,17,0.1),0_2px_2px_0_rgba(0,0,0,0.06)]",
isAdmin && "order-3",
"aspect-square h-fit group 2xl:rounded-[1.667vw] rounded-2xl relative flex-shrink-0 transition-[width,box-shadow,background-color] duration-300 pointer-events-auto hover:w-[10.833vw] w-[6.944vw] shadow-[0_4px_40px_0_rgba(15,16,17,0.1),0_2px_2px_0_rgba(0,0,0,0.06)]",
isAdmin && "order-last",
isSpeaking
? "ring-[0.139vw] ring-[#7B60F3]"
: "ring-[0.069vw] ring-[#FFFFFF4D]",
@@ -67,7 +67,7 @@ export default function UserCamera({
<video
ref={ref}
className="size-full object-cover"
className="object-cover size-full"
autoPlay
muted={isMuted}
playsInline
@@ -23,7 +23,7 @@ export default function UserDevicesControls() {
}
return (
<div className="hidden order-4 2xl:grid grid-cols-2 gap-[0.278vw] aspect-square p-[0.556vw] flex-wrap justify-between items-center size-[6.944vw] rounded-[1.667vw] bg-[#00000040] shadow-[0_4px_40px_0_#0F10111A] backdrop-blur-[10px] pointer-events-auto">
<div className="hidden order-last 2xl:grid grid-cols-2 gap-[0.278vw] aspect-square p-[0.556vw] flex-wrap justify-between items-center size-[6.944vw] rounded-[1.667vw] bg-[#00000040] shadow-[0_4px_40px_0_#0F10111A] backdrop-blur-[10px] pointer-events-auto">
<ControlButton
onMouseDown={(e) => e.stopPropagation()}
size="large"
+5 -5
View File
@@ -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 (
<div className="relative w-screen h-screen bg-black order-3 overflow-hidden flex justify-center_items-center">
<div className="flex overflow-hidden relative order-3 w-screen h-screen bg-black justify-center_items-center">
{session.status === "started" &&
session.mode === "stream" &&
session.server?.localIp &&
session.playerPort && (
<div className="aspect-video w-full h-full">
<div className="w-full h-full aspect-video">
<PixelStreamingWrapper
initialSettings={{
ss: `ws://${session.server.localIp}:${session.playerPort}`,
@@ -189,12 +189,12 @@ function NewSessionPage() {
</div>
</FloatingActionButton>
<FloatingActionButton className="2xl:hidden">
<div className="size-4 text-white">
<div className="text-white size-4">
<MicrophoneFilledIcon />
</div>
</FloatingActionButton>
<FloatingActionButton className="2xl:hidden">
<div className="size-4 text-white">
<div className="text-white size-4">
<VideoOffFilledIcon />
</div>
</FloatingActionButton>