Files
stream.graff.tech-new/client/src/components/DraggableContainer.tsx
T

525 lines
19 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import { flushSync } from "react-dom";
import type { ReactNode } from "react";
import clsx from "clsx";
export interface Position {
top?: number | string;
left?: number | string;
right?: number | string;
bottom?: number | string;
transform?: string;
}
export type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
interface DraggableContainerProps {
/** Содержимое контейнера */
children: ReactNode;
/** Включить весь функционал компонента (по умолчанию true). Если false, компонент просто рендерит children без стилей и позиционирования */
enabled?: boolean;
/** Включить возможность перетаскивания (по умолчанию true) */
draggable?: boolean;
/** Ref элемента-хэндла для перетаскивания. Если указан, перетаскивание будет работать только при клике на этот элемент */
dragHandleRef?: React.RefObject<HTMLElement | null>;
/** Включить снэпинг к ближайшей четверти экрана при отпускании (по умолчанию true) */
enableSnapping?: boolean;
/** Автоматическое flex-выравнивание в зависимости от прижатого угла (по умолчанию false) */
autoAlign?: boolean;
/** Ограничить перетаскивание границами окна (по умолчанию false) */
constrainToBounds?: boolean;
/** Центрировать контейнер по вертикали (имеет приоритет над initialCorner и initialPosition) */
centerVertical?: boolean;
/** Центрировать контейнер по горизонтали (имеет приоритет над initialCorner и initialPosition) */
centerHorizontal?: boolean;
/** Начальный угол экрана (имеет приоритет над initialPosition) */
initialCorner?: Corner;
/** Начальная позиция контейнера (используется если не указан initialCorner) */
initialPosition?: Position;
/** Отступ от краев экрана при снэпинге и начальной позиции (по умолчанию "20px"). Можно указать в px, %, vw, vh */
padding?: number | string;
/** Дополнительные CSS классы */
className?: string;
/** Колбэк при изменении позиции */
onPositionChange?: (position: Position) => void;
}
/**
* Draggable Container - перетаскиваемый контейнер с опциональным снэпингом
*
* Логика снэпинга:
* - Экран делится на 4 части (пополам по ширине и высоте)
* - При отпускании определяется в какой четверти находится центр контейнера
* - Контейнер прилипает к соответствующему углу с использованием top/bottom + left/right
*
* @example
* // Базовое использование с автоматическим выравниванием
* <DraggableContainer enableSnapping={true} autoAlign={true}>
* <YourContent />
* </DraggableContainer>
*
* @example
* // Размещение по центру экрана (вертикально и горизонтально)
* <DraggableContainer centerVertical={true} centerHorizontal={true}>
* <YourContent />
* </DraggableContainer>
*
* @example
* // Центрирование только по вертикали
* <DraggableContainer centerVertical={true}>
* <YourContent />
* </DraggableContainer>
*
* @example
* // Центрирование только по горизонтали
* <DraggableContainer centerHorizontal={true}>
* <YourContent />
* </DraggableContainer>
*
* @example
* // Центрирование по вертикали с указанием позиции справа
* <DraggableContainer centerVertical={true} initialPosition={{ right: "4.444vw" }}>
* <YourContent />
* </DraggableContainer>
*
* @example
* // Отключение перетаскивания (статичный контейнер)
* <DraggableContainer draggable={false} initialPosition={{ top: "20px", right: "20px" }}>
* <YourContent />
* </DraggableContainer>
*
* @example
* // Полное отключение функционала (компонент не применяет никаких стилей)
* <DraggableContainer enabled={false}>
* <YourContent />
* </DraggableContainer>
*
* @example
* // С указанием начального угла и отступами в процентах
* <DraggableContainer initialCorner="bottom-right" padding="2%">
* <YourContent />
* </DraggableContainer>
*
* @example
* // С отступами в vw
* <DraggableContainer initialCorner="top-left" padding="5vw">
* <YourContent />
* </DraggableContainer>
*
* @example
* // С ограничением перетаскивания границами окна
* <DraggableContainer constrainToBounds={true} initialCorner="top-left">
* <YourContent />
* </DraggableContainer>
*
* @example
* // Без автоматического выравнивания (управляется вручную через className)
* <DraggableContainer enableSnapping={true} className="flex flex-col gap-4">
* <YourContent />
* </DraggableContainer>
*
* @example
* // С указанием элемента-хэндла для перетаскивания (например, только за шапку)
* const headerRef = useRef<HTMLDivElement>(null);
* <DraggableContainer dragHandleRef={headerRef}>
* <div>
* <div ref={headerRef}>Header (drag me!)</div>
* <div>Content</div>
* </div>
* </DraggableContainer>
*/
export default function DraggableContainer({
children,
enabled = true,
draggable = true,
dragHandleRef,
enableSnapping = false,
autoAlign = false,
constrainToBounds = false,
centerVertical = false,
centerHorizontal = false,
initialCorner,
initialPosition,
padding = "1.111vw",
className = "",
onPositionChange,
}: DraggableContainerProps) {
const containerRef = useRef<HTMLDivElement>(null);
const dragRef = useRef({
isDragging: false,
startX: 0,
startY: 0,
initialPosition: { top: 0, left: 0 },
});
// Функция для преобразования угла в позицию
const getPositionFromCorner = (corner: Corner): Position => {
switch (corner) {
case "top-left":
return { top: padding, left: padding };
case "top-right":
return { top: padding, right: padding };
case "bottom-left":
return { bottom: padding, left: padding };
case "bottom-right":
return { bottom: padding, right: padding };
}
};
// Определяем начальную позицию
const getInitialPosition = (): Position => {
// Если используется центрирование
if (centerVertical || centerHorizontal) {
const position: Position = {};
const transforms: string[] = [];
// Вертикальное позиционирование
if (centerVertical) {
position.top = "50%";
transforms.push("translateY(-50%)");
} else if (initialPosition?.top !== undefined) {
position.top = initialPosition.top;
} else if (initialPosition?.bottom !== undefined) {
position.bottom = initialPosition.bottom;
} else {
position.top = padding;
}
// Горизонтальное позиционирование
if (centerHorizontal) {
position.left = "50%";
transforms.push("translateX(-50%)");
} else if (initialPosition?.left !== undefined) {
position.left = initialPosition.left;
} else if (initialPosition?.right !== undefined) {
position.right = initialPosition.right;
} else {
position.left = padding;
}
if (transforms.length > 0) {
position.transform = transforms.join(" ");
}
return position;
}
if (initialCorner) {
return getPositionFromCorner(initialCorner);
}
if (initialPosition) {
return initialPosition;
}
// По умолчанию - верхний правый угол
return { top: padding, left: padding };
};
const [position, setPosition] = useState<Position>(getInitialPosition());
const [isDragging, setIsDragging] = useState(false);
const [dragStartAlignment, setDragStartAlignment] = useState<string>("");
// Проверяет, что событие началось на элементе-хэндле или его потомках
const isEventOnDragHandle = (target: EventTarget | null): boolean => {
if (!dragHandleRef?.current || !target) return true; // Если хэндл не указан, разрешаем перетаскивание откуда угодно
const element = target as Node;
return (
dragHandleRef.current === element ||
dragHandleRef.current.contains(element)
);
};
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));
}
// Конвертируем позицию в left/top перед началом драга
// Это нужно чтобы избежать скачка при переходе с right/bottom на left/top
// Используем flushSync для синхронного обновления DOM
if (
position.transform ||
position.right !== undefined ||
position.bottom !== undefined
) {
flushSync(() => {
setPosition({
top: rect.top,
left: rect.left,
});
});
}
dragRef.current = {
isDragging: true,
startX: clientX,
startY: clientY,
initialPosition: {
top: rect.top,
left: rect.left,
},
};
setIsDragging(true);
};
const handleMouseDown = (e: React.MouseEvent) => {
if (!draggable) return;
if (!isEventOnDragHandle(e.target)) return;
startDrag(e.clientX, e.clientY);
};
const handleTouchStart = (e: React.TouchEvent) => {
if (!draggable) return;
if (!isEventOnDragHandle(e.target)) return;
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;
// Вычисляем новую позицию
let newTop = dragRef.current.initialPosition.top + deltaY;
let newLeft = dragRef.current.initialPosition.left + deltaX;
// Ограничиваем позицию границами окна, если включено
if (constrainToBounds && containerRef.current) {
const containerWidth = containerRef.current.offsetWidth;
const containerHeight = containerRef.current.offsetHeight;
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
newTop = Math.max(0, Math.min(newTop, windowHeight - containerHeight));
newLeft = Math.max(0, Math.min(newLeft, windowWidth - containerWidth));
}
// Во время перетаскивания используем только 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]);
// Устанавливаем cursor стили на элемент-хэндл
useEffect(() => {
if (!draggable || !dragHandleRef?.current) return;
const handleElement = dragHandleRef.current;
handleElement.style.cursor = isDragging ? "grabbing" : "grab";
handleElement.style.userSelect = "none";
handleElement.style.touchAction = "none";
return () => {
handleElement.style.cursor = "";
handleElement.style.userSelect = "";
handleElement.style.touchAction = "";
};
}, [draggable, dragHandleRef, isDragging]);
const getContainerStyle = (): React.CSSProperties => {
const style: React.CSSProperties = {
position: "fixed",
zIndex: 1000,
};
const formatValue = (value: number | string): string => {
return typeof value === "number" ? `${value}px` : value;
};
if (position.top !== undefined) style.top = formatValue(position.top);
if (position.left !== undefined) style.left = formatValue(position.left);
if (position.right !== undefined) style.right = formatValue(position.right);
if (position.bottom !== undefined)
style.bottom = formatValue(position.bottom);
if (position.transform !== undefined) style.transform = position.transform;
return style;
};
const getAlignmentClasses = () => {
if (!autoAlign) return "";
// Во время драга используем сохраненное выравнивание
if (isDragging) {
return dragStartAlignment;
}
// В обычном состоянии вычисляем выравнивание на основе текущей позиции
return getAlignmentClassesFromPosition(position);
};
// Если компонент отключен, просто рендерим children без стилей и логики
if (!enabled) {
return <>{children}</>;
}
return (
<div
ref={containerRef}
className={clsx(
"pointer-events-auto",
// draggable && "touch-none",
!isDragging &&
enableSnapping &&
"transition-[inset] duration-500 ease-out",
draggable &&
!dragHandleRef &&
(isDragging ? "cursor-grabbing" : "cursor-grab"),
getAlignmentClasses(),
className
)}
style={getContainerStyle()}
onMouseDown={draggable ? handleMouseDown : undefined}
onTouchStart={draggable ? handleTouchStart : undefined}
>
{children}
</div>
);
}