From 210b285aebcf8cd5de80f4c347b371b357eb7df8 Mon Sep 17 00:00:00 2001 From: Artem Yefimenko Date: Fri, 9 Dec 2022 21:59:05 +0200 Subject: [PATCH] Use ResizeObserver to compensate component height when it changes --- lib/Draggable.js | 93 ++++++++++++++++++++++++++++++++++------ lib/DraggableCore.js | 5 ++- lib/utils/domFns.js | 16 ++++--- lib/utils/positionFns.js | 29 +++++++++---- lib/utils/types.js | 6 ++- specs/draggable.spec.jsx | 2 +- 6 files changed, 122 insertions(+), 29 deletions(-) diff --git a/lib/Draggable.js b/lib/Draggable.js index cd726128..8a997869 100644 --- a/lib/Draggable.js +++ b/lib/Draggable.js @@ -3,13 +3,13 @@ import * as React from 'react'; import PropTypes from 'prop-types'; import ReactDOM from 'react-dom'; import clsx from 'clsx'; -import {createCSSTransform, createSVGTransform} from './utils/domFns'; +import {createCSSTransform, createSVGTransform } from './utils/domFns'; import {canDragX, canDragY, createDraggableData, getBoundPosition} from './utils/positionFns'; import {dontSetMe} from './utils/shims'; import DraggableCore from './DraggableCore'; import type {ControlPosition, PositionOffsetControlPosition, DraggableCoreProps, DraggableCoreDefaultProps} from './DraggableCore'; import log from './utils/log'; -import type {Bounds, DraggableEventHandler} from './utils/types'; +import type {Bounds, DraggableEventHandler, LastCorePositionChangeHandler, ContentResizeHandler} from './utils/types'; import type {Element as ReactElement} from 'react'; type DraggableState = { @@ -19,6 +19,14 @@ type DraggableState = { slackX: number, slackY: number, isElementSVG: boolean, prevPropsPosition: ?ControlPosition, + prevContentDimensions: ?{ + width: number, + height: number + }, + lastCorePosition: ?{ + x: number, + y: number + } }; export type DraggableDefaultProps = { @@ -45,6 +53,8 @@ export type DraggableProps = { class Draggable extends React.Component { + resizeObserver: null|ResizeObserver = null; + static displayName: ?string = 'Draggable'; static propTypes = { @@ -180,6 +190,7 @@ class Draggable extends React.Component { // React 16.3+ // Arity (props, state) static getDerivedStateFromProps({position}: DraggableProps, {prevPropsPosition}: DraggableState): ?$Shape { + const newState: $Shape = {}; // Set x/y if a new position is provided in props that is different than the previous. if ( position && @@ -188,13 +199,11 @@ class Draggable extends React.Component { ) ) { log('Draggable: getDerivedStateFromProps %j', {position, prevPropsPosition}); - return { - x: position.x, - y: position.y, - prevPropsPosition: {...position} - }; + newState.x = position.x; + newState.y = position.y; + newState.prevPropsPosition = {...position}; } - return null; + return Object.keys(newState).length ? newState : null; } constructor(props: DraggableProps) { @@ -217,7 +226,9 @@ class Draggable extends React.Component { slackX: 0, slackY: 0, // Can only determine if SVG after mounting - isElementSVG: false + isElementSVG: false, + prevContentDimensions: null, + lastCorePosition: null }; if (props.position && !(props.onDrag || props.onStop)) { @@ -228,15 +239,67 @@ class Draggable extends React.Component { } } + onContentResize: ContentResizeHandler = ({ width: newWidth, height: newHeight, target }) => { + const prevHeight = this.state.prevContentDimensions?.height; + if(this.state.lastCorePosition && prevHeight && newHeight!==prevHeight){ + const { x: lastCoreX, y: lastCoreY } = this.state.lastCorePosition; + const heightDelta = newHeight-prevHeight; + const node = this.findDOMNode(); + if (node) { + const handleNode = this.props.handle ? target.querySelector(this.props.handle) || node : node; + // get current component position + const { left, top } = handleNode.getBoundingClientRect(); + // Emulate mouse move to the component position + const mouseEvent = new MouseEvent('mousemove', { + bubbles: true, + cancelable: true, + clientX: left, + clientY: top + }); + // compensate height delta to keep component at the position it was before resize + this.onDrag(mouseEvent, { + x: lastCoreX, + y: top > 0 ? lastCoreY : (heightDelta + top), + deltaX: 0, + deltaY: top > 0 ? 0 : heightDelta, + lastX: lastCoreX, + lastY: lastCoreY, + node + }, true); + } + } + this.setState({prevContentDimensions: { + width: newWidth, + height: newHeight + }}); + }; + + componentDidMount() { + const node = this.findDOMNode(); // Check to see if the element passed is an instanceof SVGElement - if(typeof window.SVGElement !== 'undefined' && this.findDOMNode() instanceof window.SVGElement) { + if(typeof window.SVGElement !== 'undefined' && node instanceof window.SVGElement) { this.setState({isElementSVG: true}); } + // observe draggable content resize + if(node){ + this.resizeObserver = new ResizeObserver((entries) => { + entries.forEach(entry => { + const { target } = entry; + const { width, height } = entry.contentRect; + this.onContentResize({width, height, target}); + }); + }); + this.resizeObserver.observe(node); + } } componentWillUnmount() { this.setState({dragging: false}); // prevents invariant if unmounted while dragging + // stop observing draggable content resize + if (this.resizeObserver) { + this.resizeObserver.disconnect(); + } } // React Strict Mode compatibility: if `nodeRef` is passed, we will use it instead of trying to find @@ -256,8 +319,8 @@ class Draggable extends React.Component { this.setState({dragging: true, dragged: true}); }; - onDrag: DraggableEventHandler = (e, coreData) => { - if (!this.state.dragging) return false; + onDrag: DraggableEventHandler = (e, coreData, force) => { + if (!this.state.dragging && !force) return false; log('Draggable: onDrag: %j', coreData); const uiData = createDraggableData(this, coreData); @@ -328,6 +391,10 @@ class Draggable extends React.Component { this.setState(newState); }; + onLastCorePositionChange: LastCorePositionChangeHandler = (x, y) => { + this.setState({lastCorePosition: {x, y}}); + }; + render(): ReactElement { const { axis, @@ -383,7 +450,7 @@ class Draggable extends React.Component { // Reuse the child provided // This makes it flexible to use whatever element is wanted (div, ul, etc) return ( - + {React.cloneElement(React.Children.only(children), { className: className, style: {...children.props.style, ...style}, diff --git a/lib/DraggableCore.js b/lib/DraggableCore.js index 4827f36c..b95c3de4 100644 --- a/lib/DraggableCore.js +++ b/lib/DraggableCore.js @@ -8,7 +8,7 @@ import {createCoreData, getControlPosition, snapToGrid} from './utils/positionFn import {dontSetMe} from './utils/shims'; import log from './utils/log'; -import type {EventHandler, MouseTouchEvent} from './utils/types'; +import type {EventHandler, MouseTouchEvent, LastCorePositionChangeHandler} from './utils/types'; import type {Element as ReactElement} from 'react'; // Simple abstraction for dragging events names. @@ -55,6 +55,7 @@ export type DraggableCoreDefaultProps = { onDrag: DraggableEventHandler, onStop: DraggableEventHandler, onMouseDown: (e: MouseEvent) => void, + onLastCorePositionChange: LastCorePositionChangeHandler, scale: number, }; @@ -224,6 +225,7 @@ export default class DraggableCore extends React.Component = (e) => { diff --git a/lib/utils/domFns.js b/lib/utils/domFns.js index 65fe32fe..78461caf 100644 --- a/lib/utils/domFns.js +++ b/lib/utils/domFns.js @@ -106,15 +106,21 @@ interface EventWithOffset { clientX: number, clientY: number } +export function getOffsetParentRect(offsetParent: HTMLElement): { + left: number; + top: number; +} { + const isBody = offsetParent === offsetParent.ownerDocument.body; + const { top, left } = isBody ? {left: 0, top: 0} : offsetParent.getBoundingClientRect(); + return { top, left }; +} + // Get from offsetParent export function offsetXYFromParent(evt: EventWithOffset, offsetParent: HTMLElement, scale: number): ControlPosition { - const isBody = offsetParent === offsetParent.ownerDocument.body; - const offsetParentRect = isBody ? {left: 0, top: 0} : offsetParent.getBoundingClientRect(); - + const offsetParentRect = getOffsetParentRect(offsetParent); const x = (evt.clientX + offsetParent.scrollLeft - offsetParentRect.left) / scale; const y = (evt.clientY + offsetParent.scrollTop - offsetParentRect.top) / scale; - - return {x, y}; + return { x, y }; } export function createCSSTransform(controlPos: ControlPosition, positionOffset: PositionOffsetControlPosition): Object { diff --git a/lib/utils/positionFns.js b/lib/utils/positionFns.js index 31346524..a4e6b135 100644 --- a/lib/utils/positionFns.js +++ b/lib/utils/positionFns.js @@ -1,22 +1,28 @@ // @flow -import {isNum, int} from './shims'; -import {getTouch, innerWidth, innerHeight, offsetXYFromParent, outerWidth, outerHeight} from './domFns'; +import { isNum, int } from './shims'; +import { getTouch, innerWidth, innerHeight, offsetXYFromParent, outerWidth, outerHeight } from './domFns'; import type Draggable from '../Draggable'; -import type {Bounds, ControlPosition, DraggableData, MouseTouchEvent} from './types'; +import type { Bounds, ControlPosition, DraggableData, MouseTouchEvent } from './types'; import type DraggableCore from '../DraggableCore'; -export function getBoundPosition(draggable: Draggable, x: number, y: number): [number, number] { - // If no bounds, short-circuit and move on - if (!draggable.props.bounds) return [x, y]; +export function getBounds(draggable: Draggable): null | { + bottom?: number, + left?: number, + right?: number, + top?: number +} { + if (!draggable.props.bounds) { + return null; + } // Clone new bounds - let {bounds} = draggable.props; + let { bounds } = draggable.props; bounds = typeof bounds === 'string' ? bounds : cloneBounds(bounds); const node = findDOMNode(draggable); if (typeof bounds === 'string') { - const {ownerDocument} = node; + const { ownerDocument } = node; const ownerWindow = ownerDocument.defaultView; let boundNode; if (bounds === 'parent') { @@ -40,6 +46,13 @@ export function getBoundPosition(draggable: Draggable, x: number, y: number): [n int(boundNodeStyle.paddingBottom) - int(nodeStyle.marginBottom) }; } + return bounds; +} + +export function getBoundPosition(draggable: Draggable, x: number, y: number): [number, number] { + const bounds = getBounds(draggable); + // If no bounds, short-circuit and move on + if (!bounds) return [x, y]; // Keep x and y below right and bottom limits... if (isNum(bounds.right)) x = Math.min(x, bounds.right); diff --git a/lib/utils/types.js b/lib/utils/types.js index 31d58136..94a0bdfd 100644 --- a/lib/utils/types.js +++ b/lib/utils/types.js @@ -1,7 +1,11 @@ // @flow // eslint-disable-next-line no-use-before-define -export type DraggableEventHandler = (e: MouseEvent, data: DraggableData) => void | false; +export type DraggableEventHandler = (e: MouseEvent, data: DraggableData, force: ?boolean) => void | false; + +export type LastCorePositionChangeHandler = (x: number, y: number ) => void | false; + +export type ContentResizeHandler = (params: {width: number, height: number, target: Element }) => void | false; export type DraggableData = { node: HTMLElement, diff --git a/specs/draggable.spec.jsx b/specs/draggable.spec.jsx index 51ed3b4d..58be9556 100644 --- a/specs/draggable.spec.jsx +++ b/specs/draggable.spec.jsx @@ -96,7 +96,7 @@ describe('react-draggable', function () { ); // Not easy to actually test equality here. The functions are bound as static props so we can't test those easily. - const toOmit = ['onStart', 'onStop', 'onDrag', 'onMouseDown', 'children']; + const toOmit = ['onStart', 'onStop', 'onDrag', 'onMouseDown', 'children', 'onLastCorePositionChange']; assert.deepEqual( _.omit(output.props, toOmit), _.omit(expected.props, toOmit)