import { MouseEventType, TouchEventType } from '@nitrots/nitro-renderer'; import { CSSProperties, FC, Key, MouseEvent as ReactMouseEvent, ReactNode, TouchEvent as ReactTouchEvent, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { GetLocalStorage, SetLocalStorage, WindowSaveOptions } from '../../api'; import { DraggableWindowPosition } from './DraggableWindowPosition'; const CURRENT_WINDOWS: HTMLElement[] = []; const POS_MEMORY: Map = new Map(); const BOUNDS_THRESHOLD_TOP: number = 0; const BOUNDS_THRESHOLD_LEFT: number = 0; const DRAG_OUTSIDE_PERCENT: number = 0.80; export interface DraggableWindowProps { uniqueKey?: Key; handleSelector?: string; windowPosition?: string; disableDrag?: boolean; dragStyle?: CSSProperties; offsetLeft?: number; offsetTop?: number; children?: ReactNode; } export const DraggableWindow: FC = props => { const { uniqueKey = null, handleSelector = '.drag-handler', windowPosition = DraggableWindowPosition.CENTER, disableDrag = false, dragStyle = {}, children = null, offsetLeft = 0, offsetTop = 0 } = props; const [delta, setDelta] = useState<{ x: number, y: number }>({ x: 0, y: 0 }); const [offset, setOffset] = useState<{ x: number, y: number }>({ x: 0, y: 0 }); const [start, setStart] = useState<{ x: number, y: number }>({ x: 0, y: 0 }); const [isDragging, setIsDragging] = useState(false); const [isPositioned, setIsPositioned] = useState(false); const [dragHandler, setDragHandler] = useState(null); const elementRef = useRef(); const bringToTop = useCallback(() => { let zIndex = 400; for (const existingWindow of CURRENT_WINDOWS) { zIndex += 1; existingWindow.style.zIndex = zIndex.toString(); } }, []); const moveCurrentWindow = useCallback(() => { const index = CURRENT_WINDOWS.indexOf(elementRef.current); if (index === -1) { CURRENT_WINDOWS.push(elementRef.current); } else if (index === (CURRENT_WINDOWS.length - 1)) return; else if (index >= 0) { CURRENT_WINDOWS.splice(index, 1); CURRENT_WINDOWS.push(elementRef.current); } bringToTop(); }, [bringToTop]); const onMouseDown = useCallback((event: ReactMouseEvent) => { moveCurrentWindow(); }, [moveCurrentWindow]); const onTouchStart = useCallback((event: ReactTouchEvent) => { moveCurrentWindow(); }, [moveCurrentWindow]); const startDragging = useCallback((startX: number, startY: number) => { setStart({ x: startX, y: startY }); setIsDragging(true); }, []); const onDragMouseDown = useCallback((event: MouseEvent) => { startDragging(event.clientX, event.clientY); }, [startDragging]); const onTouchDown = useCallback((event: TouchEvent) => { const touch = event.touches[0]; startDragging(touch.clientX, touch.clientY); }, [startDragging]); const clampPosition = useCallback((newX: number, newY: number) => { if (!elementRef.current) return { x: newX, y: newY }; const windowWidth = elementRef.current.offsetWidth; const windowHeight = elementRef.current.offsetHeight; const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; const maxOutX = windowWidth * DRAG_OUTSIDE_PERCENT; const maxOutY = windowHeight * DRAG_OUTSIDE_PERCENT; const clampedX = Math.max(-maxOutX, Math.min(newX, viewportWidth - windowWidth + maxOutX)); const clampedY = Math.max(BOUNDS_THRESHOLD_TOP, Math.min(newY, viewportHeight - windowHeight + maxOutY)); return { x: clampedX, y: clampedY }; }, []); const onDragMouseMove = useCallback((event: MouseEvent) => { if (!elementRef.current || !isDragging) return; const newDeltaX = event.clientX - start.x; const newDeltaY = event.clientY - start.y; const newOffsetX = offset.x + newDeltaX; const newOffsetY = offset.y + newDeltaY; const clampedPos = clampPosition(newOffsetX, newOffsetY); setDelta({ x: clampedPos.x - offset.x, y: clampedPos.y - offset.y }); }, [start, offset, clampPosition, isDragging]); const onDragTouchMove = useCallback((event: TouchEvent) => { if (!elementRef.current || !isDragging) return; const touch = event.touches[0]; const newDeltaX = touch.clientX - start.x; const newDeltaY = touch.clientY - start.y; const newOffsetX = offset.x + newDeltaX; const newOffsetY = offset.y + newDeltaY; const clampedPos = clampPosition(newOffsetX, newOffsetY); setDelta({ x: clampedPos.x - offset.x, y: clampedPos.y - offset.y }); }, [start, offset, clampPosition, isDragging]); const completeDrag = useCallback(() => { if (!elementRef.current || !dragHandler || !isDragging) return; const finalOffsetX = offset.x + delta.x; const finalOffsetY = offset.y + delta.y; const clampedPos = clampPosition(finalOffsetX, finalOffsetY); setDelta({ x: 0, y: 0 }); setOffset({ x: clampedPos.x, y: clampedPos.y }); setIsDragging(false); if (uniqueKey !== null) { const newStorage = { ...GetLocalStorage(`nitro.windows.${uniqueKey}`) }; newStorage.offset = { x: clampedPos.x, y: clampedPos.y }; SetLocalStorage(`nitro.windows.${uniqueKey}`, newStorage); } }, [dragHandler, delta, offset, uniqueKey, clampPosition, isDragging]); const onDragMouseUp = useCallback((event: MouseEvent) => { completeDrag(); }, [completeDrag]); const onDragTouchUp = useCallback((event: TouchEvent) => { completeDrag(); }, [completeDrag]); useLayoutEffect(() => { const element = elementRef.current as HTMLElement; if (!element) return; CURRENT_WINDOWS.push(element); bringToTop(); if (!disableDrag) { const handle = element.querySelector(handleSelector); if (handle) setDragHandler(handle as HTMLElement); } const windowWidth = element.offsetWidth || 340; const windowHeight = element.offsetHeight || 462; let offsetX = 0; let offsetY = 0; switch (windowPosition) { case DraggableWindowPosition.TOP_CENTER: offsetY = 50 + offsetTop; offsetX = (window.innerWidth - windowWidth) / 2 + offsetLeft; break; case DraggableWindowPosition.CENTER: offsetY = (window.innerHeight - windowHeight) / 2 + offsetTop; offsetX = (window.innerWidth - windowWidth) / 2 + offsetLeft; break; case DraggableWindowPosition.TOP_LEFT: offsetY = 50 + offsetTop; offsetX = 50 + offsetLeft; break; } const clampedPos = clampPosition(offsetX, offsetY); setOffset({ x: clampedPos.x, y: clampedPos.y }); setDelta({ x: 0, y: 0 }); setIsPositioned(true); return () => { const index = CURRENT_WINDOWS.indexOf(element); if (index >= 0) CURRENT_WINDOWS.splice(index, 1); }; }, [handleSelector, windowPosition, uniqueKey, disableDrag, offsetLeft, offsetTop, bringToTop]); useEffect(() => { if (!dragHandler) return; dragHandler.addEventListener(MouseEventType.MOUSE_DOWN, onDragMouseDown); dragHandler.addEventListener(TouchEventType.TOUCH_START, onTouchDown); return () => { dragHandler.removeEventListener(MouseEventType.MOUSE_DOWN, onDragMouseDown); dragHandler.removeEventListener(TouchEventType.TOUCH_START, onTouchDown); }; }, [dragHandler, onDragMouseDown, onTouchDown]); useEffect(() => { if (!isDragging) return; document.addEventListener(MouseEventType.MOUSE_UP, onDragMouseUp); document.addEventListener(TouchEventType.TOUCH_END, onDragTouchUp); document.addEventListener(MouseEventType.MOUSE_MOVE, onDragMouseMove); document.addEventListener(TouchEventType.TOUCH_MOVE, onDragTouchMove); return () => { document.removeEventListener(MouseEventType.MOUSE_UP, onDragMouseUp); document.removeEventListener(TouchEventType.TOUCH_END, onDragTouchUp); document.removeEventListener(MouseEventType.MOUSE_MOVE, onDragMouseMove); document.removeEventListener(TouchEventType.TOUCH_MOVE, onDragTouchMove); }; }, [isDragging, onDragMouseUp, onDragMouseMove, onDragTouchUp, onDragTouchMove]); useEffect(() => { if (!uniqueKey) return; const localStorage = GetLocalStorage(`nitro.windows.${uniqueKey}`); if (!localStorage || !localStorage.offset) return; const clampedPos = clampPosition(localStorage.offset.x, localStorage.offset.y); setDelta({ x: 0, y: 0 }); setOffset({ x: clampedPos.x, y: clampedPos.y }); setIsPositioned(true); }, [uniqueKey, clampPosition]); return createPortal(
{children}
, document.getElementById('draggable-windows-container') ); };