Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Migrate DragNDrop #23589

Merged
merged 39 commits into from
Jul 28, 2023
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
65cac31
Rename dropZoneID prop to follow conventions
roryabraham Jul 25, 2023
9851ad1
Add prop types on default components
roryabraham Jul 25, 2023
f5f8743
Rename disabled prop to isDisabled
roryabraham Jul 25, 2023
faa216b
Create useEffectOnPageLoad hook
roryabraham Jul 25, 2023
931043b
Convert DragAndDrop to functional component
roryabraham Jul 25, 2023
7665425
Handle dragOver event to fix onDrop
roryabraham Jul 25, 2023
401e2c6
Fix passing throttled function to useEffectOnPageLoad
roryabraham Jul 25, 2023
1918bfa
Fix yet another whoops with the throttled function
roryabraham Jul 25, 2023
e0bd6f7
Re-implement DragAndDrop with context
roryabraham Jul 27, 2023
6be2fe0
Create useThrottledEffect hook
roryabraham Jul 27, 2023
23f2d4f
remove unused import
roryabraham Jul 27, 2023
5949c3e
Add flex 1 style to fix display
roryabraham Jul 27, 2023
2094ca0
Fix overlay style
roryabraham Jul 27, 2023
f5afe54
Remove throttled effect hook so measure can happen onLayout
roryabraham Jul 27, 2023
989f5fa
Fix drag state by nesting portal inside transparent overlay and imple…
roryabraham Jul 27, 2023
c9a1680
Simplify by removing measurement
roryabraham Jul 27, 2023
58b31c9
Remove unused useEffectOnPageLoad hook
roryabraham Jul 27, 2023
a0f6afa
Remove now-unused DropZone component
roryabraham Jul 27, 2023
807d819
Correctly pass event to subscriber
roryabraham Jul 27, 2023
48ce5e4
Fix storybook
roryabraham Jul 27, 2023
0a09b5b
Get rid of unnecessary hostName
roryabraham Jul 27, 2023
aa27f70
Merge branch 'main' into Rory-MigrateDragNDrop
roryabraham Jul 27, 2023
da885c4
Create useDragAndDrop hook to DRY things up
roryabraham Jul 27, 2023
ebfb46c
Add missing JSDoc
roryabraham Jul 27, 2023
d510e1e
useDragAndDrop in NoDropZone
roryabraham Jul 27, 2023
d140024
Fix ref inconsistencies which were making listeners get unsubscribed
roryabraham Jul 27, 2023
be377db
Fix click events in the drop zone
roryabraham Jul 27, 2023
f0685c3
Fix lint
roryabraham Jul 27, 2023
c6fa4fa
Merge branch 'main' into Rory-MigrateDragNDrop
roryabraham Jul 27, 2023
4373de1
Fix tests
roryabraham Jul 27, 2023
8a4dcf5
Add comment in mock
roryabraham Jul 27, 2023
834791e
Rename DNDUtils to DragAndDropUtils
roryabraham Jul 27, 2023
578c22b
Remove subscriber optimization
roryabraham Jul 28, 2023
ca84268
Fix lint
roryabraham Jul 28, 2023
8a028bb
Simplify context implementation
roryabraham Jul 28, 2023
0e90a13
Remove unused DragAndDropUtils
roryabraham Jul 28, 2023
8972845
Remove unused constant
roryabraham Jul 28, 2023
8711b49
fix lint
roryabraham Jul 28, 2023
cce276f
onDropHandler not onDropListener
roryabraham Jul 28, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/components/DragAndDrop/DropZone/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const propTypes = {
children: PropTypes.node.isRequired,

/** Required for drag and drop to properly detect dropzone */
dropZoneId: PropTypes.string.isRequired,
dropZoneID: PropTypes.string.isRequired,
};

function DropZone(props) {
Expand All @@ -21,7 +21,7 @@ function DropZone(props) {
<View style={[styles.fullScreenTransparentOverlay, styles.alignItemsCenter, styles.justifyContentCenter]}>{props.children}</View>
{/* Necessary for blocking events on content which can publish unwanted dragleave even if we are inside dropzone */}
<View
nativeID={props.dropZoneId}
nativeID={props.dropZoneID}
style={styles.dropZoneTopInvisibleOverlay}
/>
</Portal>
Expand Down
10 changes: 8 additions & 2 deletions src/components/DragAndDrop/dragAndDropPropTypes.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,14 @@ export default {
shouldAcceptDrop: PropTypes.func,

/** Id of the element on which we want to detect drag */
dropZoneId: PropTypes.string.isRequired,
dropZoneID: PropTypes.string.isRequired,

/** Id of the element which is shown while drag is active */
activeDropZoneId: PropTypes.string.isRequired,
activeDropZoneID: PropTypes.string.isRequired,

/** Whether drag & drop should be disabled */
isDisabled: PropTypes.bool,

/** Rendered child component */
children: PropTypes.node.isRequired,
};
296 changes: 122 additions & 174 deletions src/components/DragAndDrop/index.js
Original file line number Diff line number Diff line change
@@ -1,197 +1,145 @@
import React from 'react';
import PropTypes from 'prop-types';
import {useEffect, useRef, useCallback} from 'react';
import _ from 'underscore';

import variables from '../../styles/variables';
import {useIsFocused} from '@react-navigation/native';
import DragAndDropPropTypes from './dragAndDropPropTypes';
import withNavigationFocus from '../withNavigationFocus';
import useEffectOnPageLoad from '../../hooks/useEffectOnPageLoad';
import useWindowDimensions from '../../hooks/useWindowDimensions';

const COPY_DROP_EFFECT = 'copy';
const NONE_DROP_EFFECT = 'none';
const DRAG_ENTER_EVENT = 'dragenter';
const DRAG_OVER_EVENT = 'dragover';
const DRAG_LEAVE_EVENT = 'dragleave';
const DROP_EVENT = 'drop';

/**
* @param {Event} event – drag event
* @returns {Boolean}
*/
function shouldAcceptDrop(event) {
return _.some(event.dataTransfer.types, (type) => type === 'Files');
}

const propTypes = {
...DragAndDropPropTypes,

/** Callback to fire when a file has being dragged over the text input & report body. This prop is necessary to be inlined to satisfy the linter */
onDragOver: DragAndDropPropTypes.onDragOver,

/** Guard for accepting drops in drop zone. Drag event is passed to this function as first parameter. This prop is necessary to be inlined to satisfy the linter */
shouldAcceptDrop: PropTypes.func,

/** Whether drag & drop should be disabled */
disabled: PropTypes.bool,

/** Rendered child component */
children: PropTypes.node.isRequired,
};

const defaultProps = {
onDragOver: () => {},
shouldAcceptDrop: (e) => {
if (e.dataTransfer.types) {
for (let i = 0; i < e.dataTransfer.types.length; i++) {
if (e.dataTransfer.types[i] === 'Files') {
return true;
}
}
}
return false;
},
disabled: false,
};

class DragAndDrop extends React.Component {
constructor(props) {
super(props);

this.throttledDragOverHandler = _.throttle(this.dragOverHandler.bind(this), 100);
this.throttledDragNDropWindowResizeListener = _.throttle(this.dragNDropWindowResizeListener.bind(this), 100);
this.dropZoneDragHandler = this.dropZoneDragHandler.bind(this);
this.dropZoneDragListener = this.dropZoneDragListener.bind(this);

/*
Last detected drag state on the dropzone -> we start with dragleave since user is not dragging initially.
This state is updated when drop zone is left/entered entirely(not taking the children in the account) or entire window is left
*/
this.dropZoneDragState = 'dragleave';
}

componentDidMount() {
if (this.props.disabled) {
return;
}
this.addEventListeners();
}
function DragAndDrop({onDragEnter, onDragLeave, onDrop, dropZoneID, activeDropZoneID, children, isDisabled = false}) {
const isFocused = useIsFocused();
const {windowWidth, isSmallScreenWidth} = useWindowDimensions();

componentDidUpdate(prevProps) {
const isDisabled = this.props.disabled;
if (this.props.isFocused === prevProps.isFocused && isDisabled === prevProps.disabled) {
return;
}
if (!this.props.isFocused || isDisabled) {
this.removeEventListeners();
} else {
this.addEventListeners();
}
}
const dropZone = useRef(null);
const dropZoneRect = useRef(null);

componentWillUnmount() {
if (this.props.disabled) {
return;
useEffect(() => {
const dropZoneElement = document.getElementById(dropZoneID);
if (!dropZoneElement) {
throw new Error('Error: element specified by dropZoneID not found');
}
this.removeEventListeners();
}

addEventListeners() {
this.dropZone = document.getElementById(this.props.dropZoneId);
this.dropZoneRect = this.calculateDropZoneClientReact();
document.addEventListener('dragover', this.dropZoneDragListener);
document.addEventListener('dragenter', this.dropZoneDragListener);
document.addEventListener('dragleave', this.dropZoneDragListener);
document.addEventListener('drop', this.dropZoneDragListener);
window.addEventListener('resize', this.throttledDragNDropWindowResizeListener);
}

removeEventListeners() {
document.removeEventListener('dragover', this.dropZoneDragListener);
document.removeEventListener('dragenter', this.dropZoneDragListener);
document.removeEventListener('dragleave', this.dropZoneDragListener);
document.removeEventListener('drop', this.dropZoneDragListener);
window.removeEventListener('resize', this.throttledDragNDropWindowResizeListener);
}

/**
* @param {Object} event native Event
dropZone.current = dropZoneElement;
}, [dropZoneID]);

useEffectOnPageLoad(
_.throttle(() => {
const boundingClientRect = dropZone.current.getBoundingClientRect();
dropZoneRect.current = {
width: boundingClientRect.width,
left: isSmallScreenWidth ? 0 : boundingClientRect.left,
right: isSmallScreenWidth ? windowWidth : boundingClientRect.right,
top: boundingClientRect.top,
bottom: boundingClientRect.bottom,
};
}, 100),
[windowWidth, isSmallScreenWidth],
);

/*
* Last detected drag state on the dropzone -> we start with dragleave since user is not dragging initially.
* This state is updated when drop zone is left/entered entirely(not taking the children in the account) or entire window is left
*/
dragOverHandler(event) {
this.props.onDragOver(event);
}

dragNDropWindowResizeListener() {
// Update bounding client rect on window resize
this.dropZoneRect = this.calculateDropZoneClientReact();
}

calculateDropZoneClientReact() {
const boundingClientRect = this.dropZone.getBoundingClientRect();
const dropZoneDragState = useRef(DRAG_LEAVE_EVENT);

// Handle edge case when we are under responsive breakpoint the browser doesn't normalize rect.left to 0 and rect.right to window.innerWidth
return {
width: boundingClientRect.width,
left: window.innerWidth <= variables.mobileResponsiveWidthBreakpoint ? 0 : boundingClientRect.left,
right: window.innerWidth <= variables.mobileResponsiveWidthBreakpoint ? window.innerWidth : boundingClientRect.right,
top: boundingClientRect.top,
bottom: boundingClientRect.bottom,
};
}

/**
* @param {Object} event native Event
*/
dropZoneDragHandler(event) {
// Setting dropEffect for dragover is required for '+' icon on certain platforms/browsers (eg. Safari)
switch (event.type) {
case 'dragover':
// Continuous event -> can hurt performance, be careful when subscribing
// eslint-disable-next-line no-param-reassign
event.dataTransfer.dropEffect = COPY_DROP_EFFECT;
this.throttledDragOverHandler(event);
break;
case 'dragenter':
// Avoid reporting onDragEnter for children views -> not performant
if (this.dropZoneDragState === 'dragleave') {
this.dropZoneDragState = 'dragenter';
// eslint-disable-next-line no-param-reassign
event.dataTransfer.dropEffect = COPY_DROP_EFFECT;
this.props.onDragEnter(event);
}
break;
case 'dragleave':
if (this.dropZoneDragState === 'dragenter') {
if (
event.clientY <= this.dropZoneRect.top ||
event.clientY >= this.dropZoneRect.bottom ||
event.clientX <= this.dropZoneRect.left ||
event.clientX >= this.dropZoneRect.right ||
// Cancel drag when file manager is on top of the drop zone area - works only on chromium
(event.target.getAttribute('id') === this.props.activeDropZoneId && !event.relatedTarget)
) {
this.dropZoneDragState = 'dragleave';
this.props.onDragLeave(event);
}
}
break;
case 'drop':
this.dropZoneDragState = 'dragleave';
this.props.onDrop(event);
break;
default:
break;
// If this component is out of focus or disabled, reset the drag state back to the default
useEffect(() => {
if (isFocused && !isDisabled) {
return;
}
}
dropZoneDragState.current = DRAG_LEAVE_EVENT;
}, [isFocused, isDisabled]);

/**
* Handles all types of drag-N-drop events on the drop zone associated with composer
*
* @param {Object} event native Event
*/
dropZoneDragListener(event) {
event.preventDefault();
const dropZoneDragHandler = useCallback(
(event) => {
if (!isFocused || isDisabled) {
return;
}

if (this.dropZone.contains(event.target) && this.props.shouldAcceptDrop(event)) {
this.dropZoneDragHandler(event);
} else {
// eslint-disable-next-line no-param-reassign
event.dataTransfer.dropEffect = NONE_DROP_EFFECT;
}
}
event.preventDefault();

if (dropZone.current.contains(event.target) && shouldAcceptDrop(event)) {
switch (event.type) {
case DRAG_OVER_EVENT:
// Nothing needed here, just needed to preventDefault in order for the drop event to fire later
break;
case DRAG_ENTER_EVENT:
// Avoid reporting onDragEnter for children views -> not performant
if (dropZoneDragState.current === DRAG_LEAVE_EVENT) {
dropZoneDragState.current = DRAG_ENTER_EVENT;
// eslint-disable-next-line no-param-reassign
event.dataTransfer.dropEffect = COPY_DROP_EFFECT;
onDragEnter(event);
}
break;
case DRAG_LEAVE_EVENT:
if (dropZoneDragState.current === DRAG_ENTER_EVENT) {
if (
event.clientY <= dropZoneRect.current.top ||
event.clientY >= dropZoneRect.current.bottom ||
event.clientX <= dropZoneRect.current.left ||
event.clientX >= dropZoneRect.current.right ||
// Cancel drag when file manager is on top of the drop zone area - works only on chromium
(event.target.getAttribute('id') === activeDropZoneID && !event.relatedTarget)
) {
dropZoneDragState.current = DRAG_LEAVE_EVENT;
onDragLeave(event);
}
}
break;
case DROP_EVENT:
dropZoneDragState.current = DRAG_LEAVE_EVENT;
onDrop(event);
break;
default:
break;
}
} else {
// eslint-disable-next-line no-param-reassign
event.dataTransfer.dropEffect = NONE_DROP_EFFECT;
}
},
[isFocused, isDisabled, onDragEnter, onDragLeave, activeDropZoneID, onDrop],
);

useEffect(() => {
// Note that the dragover event needs to be called with `event.preventDefault` in order for the drop event to be fired:
// https://stackoverflow.com/questions/21339924/drop-event-not-firing-in-chrome
document.addEventListener(DRAG_OVER_EVENT, dropZoneDragHandler);
document.addEventListener(DRAG_ENTER_EVENT, dropZoneDragHandler);
document.addEventListener(DRAG_LEAVE_EVENT, dropZoneDragHandler);
document.addEventListener(DROP_EVENT, dropZoneDragHandler);
return () => {
document.removeEventListener(DRAG_OVER_EVENT, dropZoneDragHandler);
document.removeEventListener(DRAG_ENTER_EVENT, dropZoneDragHandler);
document.removeEventListener(DRAG_ENTER_EVENT, dropZoneDragHandler);
document.removeEventListener(DRAG_LEAVE_EVENT, dropZoneDragHandler);
document.removeEventListener(DROP_EVENT, dropZoneDragHandler);
};
}, [dropZoneDragHandler]);

render() {
return this.props.children;
}
return children;
}

DragAndDrop.propTypes = propTypes;
DragAndDrop.defaultProps = defaultProps;
DragAndDrop.propTypes = DragAndDropPropTypes;
DragAndDrop.displayName = 'DragAndDrop';

export default withNavigationFocus(DragAndDrop);
export default DragAndDrop;
3 changes: 3 additions & 0 deletions src/components/DragAndDrop/index.native.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import DragAndDropPropTypes from './dragAndDropPropTypes';

const DragAndDrop = (props) => props.children;

DragAndDrop.propTypes = DragAndDropPropTypes;
DragAndDrop.displayName = 'DragAndDrop';

export default DragAndDrop;
20 changes: 20 additions & 0 deletions src/hooks/useEffectOnPageLoad/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import {useEffect, useRef} from 'react';

export default function useEffectOnPageLoad(onPageLoad, dependencies = []) {
const onPageLoadRef = useRef(onPageLoad);
onPageLoadRef.current = onPageLoad;

useEffect(() => {
function onPageLoadCallback() {
onPageLoadRef.current();
}

if (document.readyState === 'complete') {
onPageLoadCallback();
} else {
window.addEventListener('load', onPageLoadCallback);
return () => window.removeEventListener('load', onPageLoadCallback);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, dependencies);
roryabraham marked this conversation as resolved.
Show resolved Hide resolved
}
3 changes: 3 additions & 0 deletions src/hooks/useEffectOnPageLoad/index.native.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import {useEffect} from 'react';

export default useEffect;
Loading
Loading