diff --git a/components/lib/dialog/Dialog.js b/components/lib/dialog/Dialog.js index 5bcf2a1c86..3c363e3dce 100644 --- a/components/lib/dialog/Dialog.js +++ b/components/lib/dialog/Dialog.js @@ -2,7 +2,7 @@ import * as React from 'react'; import PrimeReact, { PrimeReactContext, localeOption } from '../api/Api'; import { useHandleStyle } from '../componentbase/ComponentBase'; import { CSSTransition } from '../csstransition/CSSTransition'; -import { ESC_KEY_HANDLING_PRIORITIES, useDisplayOrder, useEventListener, useGlobalOnEscapeKey, useMergeProps, useMountEffect, useUnmountEffect, useUpdateEffect } from '../hooks/Hooks'; +import { ESC_KEY_HANDLING_PRIORITIES, useDisplayOrder, useDraggable, useEventListener, useGlobalOnEscapeKey, useMergeProps, useMountEffect, useUnmountEffect, useUpdateEffect } from '../hooks/Hooks'; import { TimesIcon } from '../icons/times'; import { WindowMaximizeIcon } from '../icons/windowmaximize'; import { WindowMinimizeIcon } from '../icons/windowminimize'; @@ -21,14 +21,14 @@ export const Dialog = React.forwardRef((inProps, 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 pointerRef = 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); @@ -60,11 +60,10 @@ export const Dialog = React.forwardRef((inProps, ref) => { priority: [ESC_KEY_HANDLING_PRIORITIES.DIALOG, displayOrder] }); + 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(); @@ -146,68 +145,6 @@ export const Dialog = React.forwardRef((inProps, 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(); - const computedStyle = getComputedStyle(dialogRef.current); - const leftMargin = parseFloat(computedStyle.marginLeft); - const topMargin = parseFloat(computedStyle.marginTop); - - 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 - leftMargin + 'px'; - } - - if (topPos >= props.minY && topPos + height < viewport.height) { - lastPageY.current = event.pageY; - dialogRef.current.style.top = topPos - topMargin + 'px'; - } - } else { - lastPageX.current = event.pageX; - dialogRef.current.style.left = leftPos - leftMargin + 'px'; - lastPageY.current = event.pageY; - dialogRef.current.style.top = topPos - topMargin + '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; @@ -284,6 +221,7 @@ export const Dialog = React.forwardRef((inProps, ref) => { const onEnter = () => { dialogRef.current.setAttribute(attributeSelector.current, ''); + setDraggableState(props.draggable); }; const onEntered = () => { @@ -303,7 +241,7 @@ export const Dialog = React.forwardRef((inProps, ref) => { }; const onExited = () => { - dragging.current = false; + setDraggableState(false); ZIndexUtils.clear(maskRef.current); setMaskVisibleState(false); disableDocumentSettings(); @@ -361,11 +299,6 @@ export const Dialog = React.forwardRef((inProps, ref) => { }; const bindGlobalListeners = () => { - if (props.draggable) { - bindDocumentDragListener(); - bindDocumentDragEndListener(); - } - if (props.resizable) { bindDocumentResizeListener(); bindDocumentResizeEndListener(); @@ -375,8 +308,6 @@ export const Dialog = React.forwardRef((inProps, ref) => { }; const unbindGlobalListeners = () => { - unbindDocumentDragListener(); - unbindDocumentDragEndListener(); unbindDocumentResizeListener(); unbindDocumentResizEndListener(); unbindDocumentKeyDownListener(); @@ -555,8 +486,7 @@ export const Dialog = React.forwardRef((inProps, ref) => { { ref: headerRef, style: props.headerStyle, - className: cx('header'), - onMouseDown: onDragStart + className: cx('header') }, ptm('header') ); diff --git a/components/lib/hooks/Hooks.js b/components/lib/hooks/Hooks.js index 5458bfc113..45d200156a 100644 --- a/components/lib/hooks/Hooks.js +++ b/components/lib/hooks/Hooks.js @@ -2,6 +2,7 @@ import { useClickOutside } from './useClickOutside'; import { useCounter } from './useCounter'; import { useDebounce } from './useDebounce'; import { useDisplayOrder } from './useDisplayOrder'; +import { useDraggable } from './useDraggable'; import { useEventListener } from './useEventListener'; import { useFavicon } from './useFavicon'; import { ESC_KEY_HANDLING_PRIORITIES, useGlobalOnEscapeKey } from './useGlobalOnEscapeKey'; @@ -28,6 +29,7 @@ export { useCounter, useDebounce, useDisplayOrder, + useDraggable, useEventListener, useFavicon, useGlobalOnEscapeKey, diff --git a/components/lib/hooks/hooks.d.ts b/components/lib/hooks/hooks.d.ts index a0b943425d..2fe49a325d 100644 --- a/components/lib/hooks/hooks.d.ts +++ b/components/lib/hooks/hooks.d.ts @@ -164,6 +164,54 @@ interface ResizeEventOptions { when?: boolean; } +/** + * Custom draggable event options. + */ +interface DraggableOptions { + /** + * The target element to listen to. + */ + targetRef: React.Ref; + /** + * The draggable handle element. + */ + handleRef: React.Ref; + /** + * Callback to invoke when dragging dialog. + * @param {React.DragEvent} event - Browser event. + */ + onDrag?(event: React.DragEvent): void; + /** + * Callback to invoke when dialog dragging is completed. + * @param {React.DragEvent} event - Browser event. + */ + onDragEnd?(event: React.DragEvent): void; + /** + * Callback to invoke when dialog dragging is initiated. + * @param {React.DragEvent} event - Browser event. + */ + onDragStart?(event: React.DragEvent): void; + /** + * Enables the draggable feature. + * @defaultValue true + */ + enabled: true; + /** + * Flag to keep the draggable in the viewport, else let it go outside the bounds. + * @defaultValue false + */ + keepInViewport: false; + /** + * The reactangular limits if you want to keep draggable to a certain area. + */ + rectLimits?: DOMRect; +} + +/** + * Custom hook to handle dragging. + * @param {DraggableOptions} options - The draggable options. + */ +export declare function useDraggable(options: DraggableOptions): any; /** * Custom hook to get the previous value of a property. * @param {*} value - The value to compare. diff --git a/components/lib/hooks/useDraggable.js b/components/lib/hooks/useDraggable.js new file mode 100644 index 0000000000..5a2067420d --- /dev/null +++ b/components/lib/hooks/useDraggable.js @@ -0,0 +1,179 @@ +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 resetState = useCallback(() => { + setDelta({ x: 0, y: 0 }); + setPrevious({ x: 0, y: 0 }); + initial.current = { x: 0, y: 0 }; + }, [setDelta, setPrevious]); + + /** + * Reset when disabled + */ + useEffect(() => { + !enabled && resetState(); + }, [resetState, enabled]); + + 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) + }; + }; + + return { dragging, delta, resetState }; +}; diff --git a/components/lib/utils/DomHandler.js b/components/lib/utils/DomHandler.js index 1bc680c250..b6425d7af1 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) {