From 8d0705ee291a401f7fb8f96c205802d26d74948f Mon Sep 17 00:00:00 2001 From: melloware Date: Sat, 26 Nov 2022 13:31:51 -0500 Subject: [PATCH] Fix #2042: Dialog better handling of draggable --- components/lib/dialog/Dialog.js | 80 ++----------- components/lib/hooks/Hooks.js | 15 +-- components/lib/hooks/hooks.d.ts | 12 ++ components/lib/hooks/useDraggable.js | 171 +++++++++++++++++++++++++++ components/lib/utils/DomHandler.js | 12 +- 5 files changed, 202 insertions(+), 88 deletions(-) create mode 100644 components/lib/hooks/useDraggable.js diff --git a/components/lib/dialog/Dialog.js b/components/lib/dialog/Dialog.js index 6b55ba9845..1356dbda48 100644 --- a/components/lib/dialog/Dialog.js +++ b/components/lib/dialog/Dialog.js @@ -1,7 +1,7 @@ import * as React from 'react'; import PrimeReact, { localeOption } from '../api/Api'; import { CSSTransition } from '../csstransition/CSSTransition'; -import { useEventListener, useMountEffect, useUnmountEffect, useUpdateEffect } from '../hooks/Hooks'; +import { useDraggable, useEventListener, useMountEffect, useUnmountEffect, useUpdateEffect } from '../hooks/Hooks'; import { Portal } from '../portal/Portal'; import { Ripple } from '../ripple/Ripple'; import { classNames, DomHandler, ObjectUtils, UniqueComponentId, ZIndexUtils } from '../utils/Utils'; @@ -11,13 +11,13 @@ export const Dialog = React.forwardRef((props, ref) => { const [maskVisibleState, setMaskVisibleState] = React.useState(false); const [visibleState, setVisibleState] = React.useState(false); const [maximizedState, setMaximizedState] = React.useState(props.maximized); + const [draggableState, setDraggableState] = React.useState(false); const dialogRef = React.useRef(null); + const headerRef = React.useRef(null); const maskRef = React.useRef(null); const contentRef = React.useRef(null); - const headerRef = React.useRef(null); const footerRef = React.useRef(null); const closeRef = React.useRef(null); - const dragging = React.useRef(false); const resizing = React.useRef(false); const lastPageX = React.useRef(null); const lastPageY = React.useRef(null); @@ -25,11 +25,10 @@ export const Dialog = React.forwardRef((props, ref) => { const attributeSelector = React.useRef(''); const maximized = props.onMaximize ? props.maximized : maximizedState; + const draggable = useDraggable({ targetRef: dialogRef, handleRef: headerRef, onDragStart: props.onDragStart, onDrag: props.onDrag, onDragEnd: props.onDragEnd, enabled: draggableState, keepInViewport: props.keepInViewport }); const [bindDocumentKeyDownListener, unbindDocumentKeyDownListener] = useEventListener({ type: 'keydown', listener: (event) => onKeyDown(event) }); const [bindDocumentResizeListener, unbindDocumentResizeListener] = useEventListener({ type: 'mousemove', target: () => window.document, listener: (event) => onResize(event) }); const [bindDocumentResizeEndListener, unbindDocumentResizEndListener] = useEventListener({ type: 'mouseup', target: () => window.document, listener: (event) => onResizeEnd(event) }); - const [bindDocumentDragListener, unbindDocumentDragListener] = useEventListener({ type: 'mousemove', target: () => window.document, listener: (event) => onDrag(event) }); - const [bindDocumentDragEndListener, unbindDocumentDragEndListener] = useEventListener({ type: 'mouseup', target: () => window.document, listener: (event) => onDragEnd(event) }); const onClose = (event) => { props.onHide(); @@ -109,65 +108,6 @@ export const Dialog = React.forwardRef((props, ref) => { } }; - const onDragStart = (event) => { - if (DomHandler.hasClass(event.target, 'p-dialog-header-icon') || DomHandler.hasClass(event.target.parentElement, 'p-dialog-header-icon')) { - return; - } - - if (props.draggable) { - dragging.current = true; - lastPageX.current = event.pageX; - lastPageY.current = event.pageY; - dialogRef.current.style.margin = '0'; - DomHandler.addClass(document.body, 'p-unselectable-text'); - - props.onDragStart && props.onDragStart(event); - } - }; - - const onDrag = (event) => { - if (dragging.current) { - const width = DomHandler.getOuterWidth(dialogRef.current); - const height = DomHandler.getOuterHeight(dialogRef.current); - const deltaX = event.pageX - lastPageX.current; - const deltaY = event.pageY - lastPageY.current; - const offset = dialogRef.current.getBoundingClientRect(); - const leftPos = offset.left + deltaX; - const topPos = offset.top + deltaY; - const viewport = DomHandler.getViewport(); - - dialogRef.current.style.position = 'fixed'; - - if (props.keepInViewport) { - if (leftPos >= props.minX && leftPos + width < viewport.width) { - lastPageX.current = event.pageX; - dialogRef.current.style.left = leftPos + 'px'; - } - - if (topPos >= props.minY && topPos + height < viewport.height) { - lastPageY.current = event.pageY; - dialogRef.current.style.top = topPos + 'px'; - } - } else { - lastPageX.current = event.pageX; - dialogRef.current.style.left = leftPos + 'px'; - lastPageY.current = event.pageY; - dialogRef.current.style.top = topPos + 'px'; - } - - props.onDrag && props.onDrag(event); - } - }; - - const onDragEnd = (event) => { - if (dragging.current) { - dragging.current = false; - DomHandler.removeClass(document.body, 'p-unselectable-text'); - - props.onDragEnd && props.onDragEnd(event); - } - }; - const onResizeStart = (event) => { if (props.resizable) { resizing.current = true; @@ -251,6 +191,7 @@ export const Dialog = React.forwardRef((props, ref) => { const onEnter = () => { dialogRef.current.setAttribute(attributeSelector.current, ''); + setDraggableState(props.draggable); }; const onEntered = () => { @@ -274,7 +215,7 @@ export const Dialog = React.forwardRef((props, ref) => { }; const onExited = () => { - dragging.current = false; + setDraggableState(false); ZIndexUtils.clear(maskRef.current); setMaskVisibleState(false); disableDocumentSettings(); @@ -305,11 +246,6 @@ export const Dialog = React.forwardRef((props, ref) => { }; const bindGlobalListeners = () => { - if (props.draggable) { - bindDocumentDragListener(); - bindDocumentDragEndListener(); - } - if (props.resizable) { bindDocumentResizeListener(); bindDocumentResizeEndListener(); @@ -322,8 +258,6 @@ export const Dialog = React.forwardRef((props, ref) => { }; const unbindGlobalListeners = () => { - unbindDocumentDragListener(); - unbindDocumentDragEndListener(); unbindDocumentResizeListener(); unbindDocumentResizEndListener(); unbindDocumentKeyDownListener(); @@ -460,7 +394,7 @@ export const Dialog = React.forwardRef((props, ref) => { const headerClassName = classNames('p-dialog-header', props.headerClassName); return ( -
+
{header}
diff --git a/components/lib/hooks/Hooks.js b/components/lib/hooks/Hooks.js index 0327f1710e..35e84d63bb 100644 --- a/components/lib/hooks/Hooks.js +++ b/components/lib/hooks/Hooks.js @@ -1,13 +1,14 @@ -import { usePrevious } from './usePrevious'; -import { useMountEffect } from './useMountEffect'; -import { useUpdateEffect } from './useUpdateEffect'; -import { useUnmountEffect } from './useUnmountEffect'; +import { useDraggable } from './useDraggable'; import { useEventListener } from './useEventListener'; +import { useInterval } from './useInterval'; +import { useMountEffect } from './useMountEffect'; import { useOverlayListener } from './useOverlayListener'; import { useOverlayScrollListener } from './useOverlayScrollListener'; +import { usePrevious } from './usePrevious'; import { useResizeListener } from './useResizeListener'; -import { useInterval } from './useInterval'; -import { useStorage, useLocalStorage, useSessionStorage } from './useStorage'; +import { useLocalStorage, useSessionStorage, useStorage } from './useStorage'; import { useTimeout } from './useTimeout'; +import { useUnmountEffect } from './useUnmountEffect'; +import { useUpdateEffect } from './useUpdateEffect'; -export { usePrevious, useMountEffect, useUpdateEffect, useUnmountEffect, useEventListener, useOverlayListener, useOverlayScrollListener, useResizeListener, useInterval, useStorage, useLocalStorage, useSessionStorage, useTimeout }; +export { usePrevious, useMountEffect, useUpdateEffect, useUnmountEffect, useEventListener, useOverlayListener, useOverlayScrollListener, useResizeListener, useInterval, useStorage, useLocalStorage, useSessionStorage, useTimeout, useDraggable }; diff --git a/components/lib/hooks/hooks.d.ts b/components/lib/hooks/hooks.d.ts index 01aa810bf9..2a37b97028 100644 --- a/components/lib/hooks/hooks.d.ts +++ b/components/lib/hooks/hooks.d.ts @@ -22,6 +22,18 @@ interface ResizeEventOptions { listener?(event: Event): void; } +interface DraggableOptions { + targetRef: React.Ref; + handleRef: React.Ref; + onDrag?(e: React.DragEvent): void; + onDragEnd?(e: React.DragEvent): void; + onDragStart?(e: React.DragEvent): void; + enabled: true; + keepInViewport: false; + rectLimits?: DOMRect; +} + +export declare function useDraggable(options: DraggableOptions): any; export declare function usePrevious(value: any): any; export declare function useMountEffect(effect: React.EffectCallback): void; export declare function useUpdateEffect(effect: React.EffectCallback, deps?: React.DependencyList): void; diff --git a/components/lib/hooks/useDraggable.js b/components/lib/hooks/useDraggable.js new file mode 100644 index 0000000000..749d672a62 --- /dev/null +++ b/components/lib/hooks/useDraggable.js @@ -0,0 +1,171 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { DomHandler } from '../utils/Utils'; + +/** + * Hook to wrap up draggable logic for dialogs. + * + * @param targetRef the target ref of the draggable + * @param handleRef the handle ref of the draggable + * @param onDragStart callback + * @param onDrag callback + * @param onDragEnd callback + * @param enabled boolean whether this hook is active or not + * @param keepInViewport should the draggable be contained by the viewport + * @param rectLimits a bounding box to limit the draggable to + * @returns { dragging, delta, resetState } + */ +export const useDraggable = ({ targetRef, handleRef, onDragStart, onDragEnd, onDrag, enabled = true, keepInViewport = false, rectLimits }) => { + const [dragging, setDragging] = useState(false); + const [previous, setPrevious] = useState({ x: 0, y: 0 }); + const [delta, setDelta] = useState({ x: 0, y: 0 }); + const initial = useRef({ x: 0, y: 0 }); + const limits = useRef(null); + + /** + * Subscribe to mouse/touch events to start dragging. + */ + useEffect(() => { + const handle = handleRef.current || targetRef.current; + + if (!handle || !enabled) { + return; + } + + handle.addEventListener('mousedown', startDragging); + handle.addEventListener('touchstart', startDragging); + + return () => { + handle.removeEventListener('mousedown', startDragging); + handle.removeEventListener('touchstart', startDragging); + }; + + function startDragging(event) { + setDragging(true); + event.preventDefault(); + targetRef.current.style.willChange = 'transform'; + const source = (event.touches && event.touches[0]) || event; + + initial.current = { x: source.clientX, y: source.clientY }; + + if (keepInViewport || rectLimits) { + const { left, top, width, height } = targetRef.current.getBoundingClientRect(); + const viewport = DomHandler.getViewport(); + + if (keepInViewport) { + limits.current = { + minX: -left + delta.x, + maxX: viewport.width - width - left + delta.x, + minY: -top + delta.y, + maxY: viewport.height - height - top + delta.y + }; + } else { + limits.current = { + minX: rectLimits.left - left + delta.x, + maxX: rectLimits.right - width - left + delta.x, + minY: rectLimits.top - top + delta.y, + maxY: rectLimits.bottom - height - top + delta.y + }; + } + } + + onDragStart && onDragStart(event); + } + }, [targetRef, handleRef, onDragStart, enabled, keepInViewport, delta, rectLimits]); + + /** + * Subscribe to mouse/touch events to drag and stop dragging. + */ + useEffect(() => { + if (dragging) { + document.addEventListener('mousemove', reposition, { passive: true }); + document.addEventListener('touchmove', reposition, { passive: true }); + document.addEventListener('mouseup', stopDragging); + document.addEventListener('touchend', stopDragging); + } else { + document.removeEventListener('mousemove', reposition, { passive: true }); + document.removeEventListener('mouseup', stopDragging); + document.removeEventListener('touchmove', reposition, { passive: true }); + document.removeEventListener('touchend', stopDragging); + } + + return () => { + document.removeEventListener('mousemove', reposition, { passive: true }); + document.removeEventListener('mouseup', stopDragging); + document.removeEventListener('touchmove', reposition, { passive: true }); + document.removeEventListener('touchend', stopDragging); + }; + + function stopDragging(event) { + event.preventDefault(); + targetRef.current.style.willChange = ''; + onDragEnd && onDragEnd(event); + + setDragging(false); + setPrevious(reposition(event)); + } + + function reposition(event) { + const source = (event.changedTouches && event.changedTouches[0]) || (event.touches && event.touches[0]) || event; + const { clientX, clientY } = source; + const x = clientX - initial.current.x + previous.x; + const y = clientY - initial.current.y + previous.y; + + const newDelta = calculateDelta({ x, y, limits: limits.current }); + + setDelta(newDelta); + onDrag && onDrag(event); + + return newDelta; + } + }, [targetRef, onDrag, onDragEnd, handleRef, dragging, previous, keepInViewport, rectLimits]); + + /** + * Listen to delta drag changes and set the target position. + */ + useEffect(() => { + if (targetRef.current) { + targetRef.current.style.transform = `translate(${delta.x}px, ${delta.y}px)`; + } + }, [targetRef, delta]); + + /** + * Listen to drag start/stop and update DOM values. + */ + useEffect(() => { + const handle = handleRef.current || targetRef.current; + + if (handle) { + handle.style.cursor = dragging ? 'grabbing' : 'move'; + } + + if (targetRef.current) { + targetRef.current.setAttribute('aria-grabbed', dragging); + } + + if (dragging) { + DomHandler.addClass(document.body, 'p-unselectable-text'); + } else { + DomHandler.removeClass(document.body, 'p-unselectable-text'); + } + }, [targetRef, handleRef, dragging]); + + const calculateDelta = ({ x, y, limits }) => { + if (!limits) { + return { x, y }; + } + + const { minX, maxX, minY, maxY } = limits; + + return { + x: Math.min(Math.max(x, minX), maxX), + y: Math.min(Math.max(y, minY), maxY) + }; + }; + + const resetState = useCallback(() => { + setDelta({ x: 0, y: 0 }); + setPrevious({ x: 0, y: 0 }); + }, [setDelta, setPrevious]); + + return { dragging, delta, resetState }; +}; diff --git a/components/lib/utils/DomHandler.js b/components/lib/utils/DomHandler.js index 5c27fdba91..10073a1c7e 100644 --- a/components/lib/utils/DomHandler.js +++ b/components/lib/utils/DomHandler.js @@ -115,14 +115,10 @@ export default class DomHandler { } static getViewport() { - let win = window, - d = document, - e = d.documentElement, - g = d.getElementsByTagName('body')[0], - w = win.innerWidth || e.clientWidth || g.clientWidth, - h = win.innerHeight || e.clientHeight || g.clientHeight; - - return { width: w, height: h }; + const vw = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0); + const vh = Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0); + + return { width: vw, height: vh }; } static getOffset(el) {