525 lines
19 KiB
TypeScript
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>
|
|
);
|
|
}
|