411 lines
13 KiB
TypeScript
411 lines
13 KiB
TypeScript
import type {
|
|
ElementRef } from '../lib/teact/teact';
|
|
import {
|
|
useEffect, useSignal, useState,
|
|
} from '../lib/teact/teact';
|
|
|
|
import type { Point, Size } from '../types';
|
|
|
|
import { RESIZE_HANDLE_SELECTOR } from '../config';
|
|
import buildStyle from '../util/buildStyle';
|
|
import { captureEvents } from '../util/captureEvents';
|
|
import getPointerPosition from '../util/events/getPointerPosition';
|
|
import useFlag from './useFlag';
|
|
import useLastCallback from './useLastCallback';
|
|
|
|
export enum ResizeHandleType {
|
|
Top,
|
|
Bottom,
|
|
Left,
|
|
Right,
|
|
TopLeft,
|
|
TopRight,
|
|
BottomLeft,
|
|
BottomRight,
|
|
}
|
|
|
|
type ResizeHandleSelectorType = 'top' | 'bottom' | 'left'
|
|
| 'right' | 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight';
|
|
|
|
const resizeHandleSelectorsMap: Record<ResizeHandleSelectorType, ResizeHandleType> = {
|
|
top: ResizeHandleType.Top,
|
|
bottom: ResizeHandleType.Bottom,
|
|
left: ResizeHandleType.Left,
|
|
right: ResizeHandleType.Right,
|
|
topLeft: ResizeHandleType.TopLeft,
|
|
topRight: ResizeHandleType.TopRight,
|
|
bottomLeft: ResizeHandleType.BottomLeft,
|
|
bottomRight: ResizeHandleType.BottomRight,
|
|
};
|
|
|
|
const resizeHandleSelectors = Object.keys(resizeHandleSelectorsMap) as ResizeHandleSelectorType[];
|
|
|
|
let resizeTimeout: number | undefined;
|
|
const FULLSCREEN_POSITION = { x: 0, y: 0 };
|
|
|
|
export default function useDraggable(
|
|
ref: ElementRef<HTMLElement>,
|
|
dragHandleElementRef: ElementRef<HTMLElement>,
|
|
isDragEnabled: boolean = true,
|
|
originalSize: Size,
|
|
isFullscreen: boolean = false,
|
|
minimumSize: Size = { width: 0, height: 0 },
|
|
cachedPosition?: Point,
|
|
) {
|
|
const [elementCurrentPosition, setElementCurrentPosition] = useState<Point | undefined>(cachedPosition);
|
|
const [elementCurrentSize, setElementCurrentSize] = useState<Size | undefined>(undefined);
|
|
|
|
const [getElementPositionOnStartTransform, setElementPositionOnStartTransform] = useSignal({ x: 0, y: 0 });
|
|
const [getElementSizeOnStartTransform, setElementSizeOnStartTransform] = useSignal({ width: 0, height: 0 });
|
|
const [getTransformStartPoint, setTransformStartPoint] = useSignal({ x: 0, y: 0 });
|
|
|
|
const elementPositionOnStartTransform = getElementPositionOnStartTransform();
|
|
const transformStartPoint = getTransformStartPoint();
|
|
|
|
const element = ref.current;
|
|
const dragHandleElement = dragHandleElementRef.current;
|
|
|
|
const [isInitiated, setIsInitiated] = useFlag(false);
|
|
const [wasElementShown, setWasElementShown] = useFlag(false);
|
|
const [isDragging, startDragging, stopDragging] = useFlag(false);
|
|
const [isResizing, startResizing, stopResizing] = useFlag(false);
|
|
const [isWindowsResizing, startWindowResizing, stopWindowResizing] = useFlag(false);
|
|
|
|
const [hitResizeHandle, setHitResizeHandle] = useState<ResizeHandleType | undefined>(undefined);
|
|
|
|
function getVisibleArea() {
|
|
return {
|
|
width: window.innerWidth,
|
|
height: window.innerHeight,
|
|
};
|
|
}
|
|
|
|
const updateCurrentPosition = useLastCallback((position: Point) => {
|
|
if (!isFullscreen) setElementCurrentPosition({ x: position.x, y: position.y });
|
|
});
|
|
|
|
const getActualPosition = useLastCallback(() => {
|
|
return isFullscreen ? FULLSCREEN_POSITION : elementCurrentPosition;
|
|
});
|
|
|
|
const getCenteredPosition = useLastCallback(() => {
|
|
if (!elementCurrentSize) return undefined;
|
|
const { width, height } = elementCurrentSize;
|
|
|
|
const visibleArea = getVisibleArea();
|
|
const viewportWidth = visibleArea.width;
|
|
const viewportHeight = visibleArea.height;
|
|
|
|
const centeredX = (viewportWidth - width) / 2;
|
|
const centeredY = (viewportHeight - height) / 2;
|
|
|
|
return { x: centeredX, y: centeredY };
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (element) setWasElementShown();
|
|
}, [element]);
|
|
|
|
useEffect(() => {
|
|
if (!isInitiated && elementCurrentSize) {
|
|
const centeredPosition = getCenteredPosition();
|
|
if (!centeredPosition) return;
|
|
|
|
updateCurrentPosition(centeredPosition);
|
|
setIsInitiated();
|
|
}
|
|
}, [elementCurrentSize, isInitiated, element]);
|
|
|
|
const handleStartDrag = useLastCallback((event: MouseEvent | TouchEvent) => {
|
|
if (event instanceof MouseEvent && event.button !== 0) {
|
|
return;
|
|
}
|
|
|
|
const targetElement = event.target as HTMLElement;
|
|
if (targetElement.closest('.no-drag') || !element) {
|
|
return;
|
|
}
|
|
const { x, y } = getPointerPosition(event);
|
|
|
|
const { left, top } = element.getBoundingClientRect();
|
|
setElementPositionOnStartTransform({ x: left, y: top });
|
|
setTransformStartPoint({ x, y });
|
|
|
|
startDragging();
|
|
});
|
|
|
|
function getResizeHandleFromTarget(targetElement: HTMLElement) {
|
|
const closest = (selector: string) => targetElement.closest(selector);
|
|
|
|
if (!closest(RESIZE_HANDLE_SELECTOR)) return undefined;
|
|
for (const selector of resizeHandleSelectors) {
|
|
if (closest(`.${selector}`)) {
|
|
return resizeHandleSelectorsMap[selector];
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
const handleStartResize = useLastCallback((event: MouseEvent | TouchEvent) => {
|
|
if (event instanceof MouseEvent && event.button !== 0) {
|
|
return;
|
|
}
|
|
|
|
const targetElement = event.target as HTMLElement;
|
|
if (!element || !targetElement) {
|
|
return;
|
|
}
|
|
const resizeHandle = getResizeHandleFromTarget(targetElement);
|
|
|
|
if (resizeHandle === undefined) return;
|
|
setHitResizeHandle(resizeHandle);
|
|
|
|
const { x, y } = getPointerPosition(event);
|
|
|
|
const {
|
|
left, right, top, bottom,
|
|
} = element.getBoundingClientRect();
|
|
setElementPositionOnStartTransform({ x: left, y: top });
|
|
setElementSizeOnStartTransform({ width: right - left, height: bottom - top });
|
|
setTransformStartPoint({ x, y });
|
|
|
|
startResizing();
|
|
});
|
|
|
|
const handleDragRelease = useLastCallback(() => {
|
|
stopDragging();
|
|
});
|
|
|
|
const handleResizeRelease = useLastCallback(() => {
|
|
stopResizing();
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (!isDragEnabled) {
|
|
stopDragging();
|
|
}
|
|
}, [isDragEnabled]);
|
|
|
|
const ensurePositionInVisibleArea = (x: number, y: number) => {
|
|
const visibleArea = getVisibleArea();
|
|
|
|
const visibleAreaWidth = visibleArea.width;
|
|
const visibleAreaHeight = visibleArea.height;
|
|
|
|
const componentWidth = elementCurrentSize!.width;
|
|
const componentHeight = elementCurrentSize!.height;
|
|
|
|
let newX = x;
|
|
let newY = y;
|
|
|
|
if (newX < 0) newX = 0;
|
|
if (newY < 0) newY = 0;
|
|
if (newX + componentWidth > visibleAreaWidth) newX = visibleAreaWidth - componentWidth;
|
|
if (newY + componentHeight > visibleAreaHeight) newY = visibleAreaHeight - componentHeight;
|
|
|
|
return { x: newX, y: newY };
|
|
};
|
|
|
|
const adjustPositionWithinBounds = useLastCallback(() => {
|
|
if (isFullscreen) return;
|
|
const position = !wasElementShown && !cachedPosition ? getCenteredPosition() : elementCurrentPosition;
|
|
if (!elementCurrentSize || !position) return;
|
|
const newPosition = ensurePositionInVisibleArea(position.x, position.y);
|
|
updateCurrentPosition(newPosition);
|
|
});
|
|
|
|
const ensureSizeInVisibleArea = useLastCallback((sizeForCheck: Size) => {
|
|
const newSize = sizeForCheck;
|
|
|
|
const visibleArea = getVisibleArea();
|
|
|
|
const originalWidth = originalSize.width;
|
|
const originalHeight = originalSize.height;
|
|
newSize.width = Math.min(visibleArea.width, Math.max(originalWidth, newSize.width));
|
|
newSize.height = Math.min(visibleArea.height, Math.max(originalHeight, newSize.height));
|
|
|
|
return newSize;
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (isResizing) return;
|
|
const newSize = ensureSizeInVisibleArea({ width: originalSize.width, height: originalSize.height });
|
|
if (newSize) setElementCurrentSize(newSize);
|
|
}, [originalSize, isResizing]);
|
|
|
|
const adjustSizeWithinBounds = useLastCallback(() => {
|
|
if (!elementCurrentSize || isResizing) return;
|
|
const newSize = ensureSizeInVisibleArea(elementCurrentSize);
|
|
if (newSize) setElementCurrentSize(newSize);
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (isResizing) return;
|
|
adjustPositionWithinBounds();
|
|
}, [elementCurrentSize, isResizing]);
|
|
|
|
useEffect(() => {
|
|
const handleWindowResize = () => {
|
|
startWindowResizing();
|
|
adjustSizeWithinBounds();
|
|
adjustPositionWithinBounds();
|
|
if (resizeTimeout) {
|
|
clearTimeout(resizeTimeout);
|
|
resizeTimeout = undefined;
|
|
}
|
|
resizeTimeout = window.setTimeout(() => {
|
|
resizeTimeout = undefined;
|
|
stopWindowResizing();
|
|
}, 250);
|
|
};
|
|
|
|
window.addEventListener('resize', handleWindowResize);
|
|
|
|
return () => {
|
|
clearTimeout(resizeTimeout);
|
|
resizeTimeout = undefined;
|
|
window.removeEventListener('resize', handleWindowResize);
|
|
};
|
|
}, [adjustPositionWithinBounds]);
|
|
|
|
const handleDrag = useLastCallback((event: MouseEvent | TouchEvent) => {
|
|
if (!isDragging || !element) return;
|
|
const { x, y } = getPointerPosition(event);
|
|
|
|
const offsetX = x - transformStartPoint.x;
|
|
const offsetY = y - transformStartPoint.y;
|
|
|
|
const newX = elementPositionOnStartTransform.x + offsetX;
|
|
const newY = elementPositionOnStartTransform.y + offsetY;
|
|
|
|
if (elementCurrentSize) setElementCurrentPosition(ensurePositionInVisibleArea(newX, newY));
|
|
});
|
|
|
|
const handleResize = useLastCallback((event: MouseEvent | TouchEvent) => {
|
|
if (!isResizing || !element || hitResizeHandle === undefined) return;
|
|
const { x, y } = getPointerPosition(event);
|
|
const sizeOnStartTransform = getElementSizeOnStartTransform();
|
|
|
|
const pageVisibleX = Math.min(Math.max(0, x), getVisibleArea().width);
|
|
const pageVisibleY = Math.min(Math.max(0, y), getVisibleArea().height);
|
|
|
|
const offsetX = pageVisibleX - transformStartPoint.x;
|
|
const offsetY = pageVisibleY - transformStartPoint.y;
|
|
|
|
const maxX = elementPositionOnStartTransform.x + sizeOnStartTransform.width - minimumSize.width;
|
|
const maxY = elementPositionOnStartTransform.y + sizeOnStartTransform.height - minimumSize.height;
|
|
|
|
const originalBounds = {
|
|
x: elementPositionOnStartTransform.x,
|
|
y: elementPositionOnStartTransform.y,
|
|
width: sizeOnStartTransform.width,
|
|
height: sizeOnStartTransform.height,
|
|
};
|
|
|
|
const newBounds = { ...originalBounds };
|
|
|
|
if (hitResizeHandle === ResizeHandleType.Left
|
|
|| hitResizeHandle === ResizeHandleType.TopLeft
|
|
|| hitResizeHandle === ResizeHandleType.BottomLeft
|
|
) {
|
|
newBounds.width = Math.max(sizeOnStartTransform.width - offsetX, minimumSize.width);
|
|
newBounds.x = Math.min(newBounds.x + offsetX, maxX);
|
|
}
|
|
|
|
if (hitResizeHandle === ResizeHandleType.Right
|
|
|| hitResizeHandle === ResizeHandleType.TopRight
|
|
|| hitResizeHandle === ResizeHandleType.BottomRight
|
|
) {
|
|
newBounds.width = Math.max(sizeOnStartTransform.width + offsetX, minimumSize.width);
|
|
}
|
|
|
|
if (hitResizeHandle === ResizeHandleType.Top
|
|
|| hitResizeHandle === ResizeHandleType.TopLeft
|
|
|| hitResizeHandle === ResizeHandleType.TopRight
|
|
) {
|
|
newBounds.height = Math.max(sizeOnStartTransform.height - offsetY, minimumSize.height);
|
|
newBounds.y = Math.min(newBounds.y + offsetY, maxY);
|
|
}
|
|
|
|
if (hitResizeHandle === ResizeHandleType.Bottom
|
|
|| hitResizeHandle === ResizeHandleType.BottomLeft
|
|
|| hitResizeHandle === ResizeHandleType.BottomRight
|
|
) {
|
|
newBounds.height = Math.max(sizeOnStartTransform.height + offsetY, minimumSize.height);
|
|
}
|
|
|
|
setElementCurrentSize({ width: newBounds.width, height: newBounds.height });
|
|
setElementCurrentPosition({ x: newBounds.x, y: newBounds.y });
|
|
});
|
|
|
|
useEffect(() => {
|
|
let cleanup: NoneToVoidFunction | undefined;
|
|
if (dragHandleElement && isDragEnabled) {
|
|
cleanup = captureEvents(dragHandleElement, {
|
|
onCapture: handleStartDrag,
|
|
onDrag: handleDrag,
|
|
onRelease: handleDragRelease,
|
|
onClick: handleDragRelease,
|
|
onDoubleClick: handleDragRelease,
|
|
});
|
|
}
|
|
return cleanup;
|
|
}, [isDragEnabled, dragHandleElement]);
|
|
|
|
useEffect(() => {
|
|
const cleanups: NoneToVoidFunction[] = [];
|
|
if (element && isDragEnabled) {
|
|
for (const selector of resizeHandleSelectors) {
|
|
const resizeHandler = element.querySelector(`.resizeHandle.${selector}`) as HTMLElement;
|
|
|
|
if (resizeHandler) {
|
|
const cleanup = captureEvents(resizeHandler, {
|
|
onCapture: handleStartResize,
|
|
onDrag: handleResize,
|
|
onRelease: handleResizeRelease,
|
|
onClick: handleResizeRelease,
|
|
onDoubleClick: handleResizeRelease,
|
|
});
|
|
|
|
if (cleanup) {
|
|
cleanups.push(cleanup);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return () => {
|
|
cleanups.forEach((cleanup) => cleanup());
|
|
};
|
|
}, [isDragEnabled, element]);
|
|
|
|
const cursorStyle = isDragging ? 'cursor: grabbing !important; ' : '';
|
|
|
|
const actualPosition = getActualPosition();
|
|
|
|
if (!isInitiated || !elementCurrentSize || !actualPosition) {
|
|
return {
|
|
isDragging: false,
|
|
style: cursorStyle,
|
|
};
|
|
}
|
|
|
|
const style = buildStyle(
|
|
`left: ${actualPosition.x}px;`,
|
|
`top: ${actualPosition.y}px;`,
|
|
!isFullscreen && `max-width: ${elementCurrentSize.width}px;`,
|
|
!isFullscreen && `max-height: ${elementCurrentSize.height}px;`,
|
|
'position: fixed;',
|
|
(isDragging || isResizing || isWindowsResizing) && 'transition: none !important;',
|
|
cursorStyle,
|
|
);
|
|
|
|
return {
|
|
position: elementCurrentPosition,
|
|
size: elementCurrentSize,
|
|
isDragging,
|
|
isResizing,
|
|
style,
|
|
};
|
|
}
|