Skip to content

Commit aebba8b

Browse files
Fix correctly scrolling the screen when dragging events, instructions, objects (#9082)
1 parent 56c79e2 commit aebba8b

12 files changed

Lines changed: 589 additions & 227 deletions

File tree

newIDE/app/src/EventsSheet/EventsTree/DropContainer.js

Lines changed: 0 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,6 @@ const sharedStyles = {
2121
border: '2px solid black',
2222
outline: '1px solid white',
2323
},
24-
autoScroll: {
25-
width: '100%',
26-
position: 'absolute',
27-
height: '10%',
28-
zIndex: 2,
29-
},
3024
};
3125

3226
type DropTargetContainerStyle = {|
@@ -331,68 +325,3 @@ export function DropContainer({
331325
</div>
332326
);
333327
}
334-
335-
export function AutoScroll({
336-
direction,
337-
DnDComponent,
338-
activateTargets,
339-
onHover,
340-
}: {|
341-
direction: 'top' | 'bottom',
342-
DnDComponent: DropTargetComponent<SortableTreeNode>,
343-
activateTargets: boolean,
344-
onHover: () => void,
345-
|}): React.MixedElement {
346-
const delayActivationTimer = React.useRef<?TimeoutID>(null);
347-
const [show, setShow] = React.useState(false);
348-
349-
// This drop target overlaps with sibling drag source and cancels drag immediately.
350-
// See: https://github.com/react-dnd/react-dnd/issues/766#issuecomment-388943403
351-
// Delaying the render of the drop target seems to solve the issue.
352-
React.useEffect(
353-
() => {
354-
if (activateTargets) {
355-
delayActivationTimer.current = setTimeout(() => {
356-
setShow(true);
357-
}, 100);
358-
} else {
359-
setShow(false);
360-
clearTimeout(delayActivationTimer.current);
361-
delayActivationTimer.current = null;
362-
}
363-
return () => {
364-
delayActivationTimer.current &&
365-
clearTimeout(delayActivationTimer.current);
366-
};
367-
},
368-
[activateTargets]
369-
);
370-
371-
return (
372-
<DnDComponent
373-
canDrop={() => true}
374-
drop={() => {
375-
return;
376-
}}
377-
>
378-
{({ isOverLazy, connectDropTarget }) => {
379-
if (isOverLazy) {
380-
onHover();
381-
}
382-
const dropTarget = (
383-
<div
384-
style={{
385-
...sharedStyles.autoScroll,
386-
...(direction === 'top' ? { top: 0 } : { bottom: 0 }),
387-
388-
// Uncomment for debugging purposes.
389-
// backgroundColor: 'black',
390-
// opacity: isOverLazy ? 1 : 0,
391-
}}
392-
/>
393-
);
394-
return show ? connectDropTarget(dropTarget) : null;
395-
}}
396-
</DnDComponent>
397-
);
398-
}

newIDE/app/src/EventsSheet/EventsTree/SortableEventsTree.js

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// @flow
22
import * as React from 'react';
3-
import { VariableSizeList } from 'react-window';
3+
import { VariableSizeList, areEqual } from 'react-window';
4+
import { useIsDragging } from '../../UI/DragAndDrop/UseIsDragging';
45
import classNames from 'classnames';
56
import { AutoSizer } from 'react-virtualized';
67
import type { ProjectScopedContainersAccessor } from '../../InstructionOrExpression/EventsScope';
@@ -354,6 +355,13 @@ const TreeRow = ({
354355
);
355356
};
356357

358+
// Rows only re-render when their data changes, not on every scroll.
359+
const MemoizedTreeRow = React.memo<{
360+
index: number,
361+
style: any,
362+
data: RowItemData,
363+
}>(TreeRow, areEqual);
364+
357365
// -- Main component --
358366

359367
const SortableEventsTree = ({
@@ -459,6 +467,31 @@ const SortableEventsTree = ({
459467
]
460468
);
461469

470+
const isDragging = useIsDragging();
471+
const isDraggingRef = React.useRef(false);
472+
isDraggingRef.current = isDragging;
473+
// react-window disables pointer events while scrolling: the drop targets of
474+
// the rows must stay active while the sheet auto scrolls during a drag.
475+
// Stable component (rows would be unmounted otherwise) reading a ref.
476+
const listInnerElementType = React.useMemo(
477+
() =>
478+
React.forwardRef<{ style: Object }, HTMLDivElement>(
479+
({ style, ...otherProps }, ref) => (
480+
<div
481+
ref={ref}
482+
style={{
483+
...style,
484+
pointerEvents: isDraggingRef.current
485+
? undefined
486+
: style.pointerEvents,
487+
}}
488+
{...otherProps}
489+
/>
490+
)
491+
),
492+
[]
493+
);
494+
462495
const itemKey = React.useCallback((index: number, data: RowItemData) => {
463496
const entry = data.flatData[index];
464497
return entry ? String(entry.node.key) : String(index);
@@ -480,8 +513,9 @@ const SortableEventsTree = ({
480513
itemKey={itemKey}
481514
onScroll={handleScroll}
482515
overscanCount={10}
516+
innerElementType={listInnerElementType}
483517
>
484-
{TreeRow}
518+
{MemoizedTreeRow}
485519
</VariableSizeList>
486520
)}
487521
</AutoSizer>

newIDE/app/src/EventsSheet/EventsTree/index.js

Lines changed: 9 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@ import TutorialMessage from '../../Hints/TutorialMessage';
4545
import getTutorial from '../../Hints/getTutorial';
4646
import { makeDragSourceAndDropTarget } from '../../UI/DragAndDrop/DragSourceAndDropTarget';
4747
import { makeDropTarget } from '../../UI/DragAndDrop/DropTarget';
48-
import { AutoScroll, DropContainer } from './DropContainer';
48+
import { DropContainer } from './DropContainer';
49+
import { useAutoScrollDuringAnyDrag } from '../../UI/DragAndDrop/UseAutoScrollDuringDrag';
4950
import {
5051
isDescendant,
5152
isElseEventValid,
@@ -511,9 +512,13 @@ const EventsTree: React.ComponentType<{
511512
const _hoverTimerId = React.useRef<?TimeoutID>(null);
512513

513514
const [draggedNode, setDraggedNode] = React.useState(null);
514-
const [isScrolledTop, setIsScrolledTop] = React.useState(true);
515-
const [isScrolledBottom, setIsScrolledBottom] = React.useState(false);
516515
const lastKnownScrollPosition = React.useRef(0);
516+
// Scroll when events, but also instructions or variable declarations,
517+
// are dragged close to the edges.
518+
useAutoScrollDuringAnyDrag(
519+
() => (_list.current ? _list.current.container : null),
520+
{ maxSpeed: 900 }
521+
);
517522

518523
// This is the data that will be displayed by the tree - reconstructed at each render
519524
// (because events could have changed, some could have been deleted, so we can't keep
@@ -1227,14 +1232,6 @@ const EventsTree: React.ComponentType<{
12271232
[]
12281233
);
12291234

1230-
const _scrollUp = React.useCallback(() => {
1231-
_list.current && _list.current.container.scrollBy({ top: -5 });
1232-
}, []);
1233-
1234-
const _scrollDown = React.useCallback(() => {
1235-
_list.current && _list.current.container.scrollBy({ top: 5 });
1236-
}, []);
1237-
12381235
const zoomLevel = props.fontSize || 14;
12391236

12401237
// Update treeDataRoot with the events tree. Done at each render as events
@@ -1249,9 +1246,7 @@ const EventsTree: React.ComponentType<{
12491246
React.useLayoutEffect(() => {
12501247
// Recompute row heights on every render. This is needed for any change
12511248
// that affects which event is at which row (deletion, fold/unfold, move)
1252-
// — not just changes to individual event heights. Scroll-triggered
1253-
// re-renders (isScrolledTop / isScrolledBottom state flips) are infrequent
1254-
// and recalculate the same heights, so the extra work is negligible.
1249+
// — not just changes to individual event heights.
12551250
if (_list.current) {
12561251
_list.current.recomputeRowHeights();
12571252
}
@@ -1282,26 +1277,6 @@ const EventsTree: React.ComponentType<{
12821277
)}px`,
12831278
}}
12841279
>
1285-
{/* Disable for touchscreen because the dragged DOM node gets deleted, the */}
1286-
{/* touch events are lost and the dnd does not drop anymore (hypothesis). */}
1287-
{props.screenType !== 'touch' && (
1288-
<>
1289-
<AutoScroll
1290-
DnDComponent={EventDropTarget}
1291-
direction="top"
1292-
// $FlowFixMe[constant-condition]
1293-
activateTargets={!!draggedNode && !isScrolledTop}
1294-
onHover={_scrollUp}
1295-
/>
1296-
<AutoScroll
1297-
DnDComponent={EventDropTarget}
1298-
direction="bottom"
1299-
// $FlowFixMe[constant-condition]
1300-
activateTargets={!!draggedNode && !isScrolledBottom}
1301-
onHover={_scrollDown}
1302-
/>
1303-
</>
1304-
)}
13051280
<SortableTree
13061281
treeData={
13071282
// Pass a new array each time, otherwise the tree will not re-render.
@@ -1332,10 +1307,6 @@ const EventsTree: React.ComponentType<{
13321307
}
13331308
lastKnownScrollPosition.current = event.scrollTop;
13341309
props.onScroll && props.onScroll();
1335-
setIsScrolledTop(event.scrollTop === 0);
1336-
setIsScrolledBottom(
1337-
event.clientHeight + event.scrollTop >= event.scrollHeight
1338-
);
13391310
},
13401311
// 'smart': no-op if the row is already visible; centers it only when
13411312
// it is more than one viewport away. This prevents undo of an in-view

newIDE/app/src/EventsSheet/EventsTree/style.css

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@
1010
* Draggable handle on the left of an event
1111
*/
1212
.gd-events-sheet .move-handle {
13+
/* Also prevents the browser from running its own selection auto-scroll
14+
while an event is dragged. */
15+
user-select: none;
1316
width: 10px;
1417
margin: 1px 0.5px;
1518
flex-grow: 0;

newIDE/app/src/UI/DragAndDrop/DragAndDropContextProvider.js

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,82 @@ const EndDragOnTouchCancel = ({
5858
return null;
5959
};
6060

61+
/**
62+
* Touch events are always dispatched to the element the finger first touched.
63+
* If this element is removed from the document during a drag (a virtualized
64+
* list unmounts the dragged row once auto scrolling moved it out of view),
65+
* the events don't reach the drag backend anymore: the drag can't be updated
66+
* or ended, and auto scrolling never stops. Keep this element in the document
67+
* until the drag ends (the backend only handles the removal of the drag
68+
* source itself, not of one of its ancestors).
69+
*/
70+
const KeepTouchedElementConnectedDuringDrag = ({
71+
documentToWatch,
72+
}: {|
73+
documentToWatch: Document,
74+
|}) => {
75+
const dragDropManager = useDragDropManager();
76+
React.useEffect(
77+
() => {
78+
let touchedElement: ?Element = null;
79+
let observer: ?MutationObserver = null;
80+
let hiddenContainer: ?HTMLElement = null;
81+
82+
const onTouchStart = (event: TouchEvent) => {
83+
touchedElement = event.target instanceof Element ? event.target : null;
84+
};
85+
const onTouchEnd = () => {
86+
touchedElement = null;
87+
};
88+
const stopWatching = () => {
89+
if (observer) observer.disconnect();
90+
observer = null;
91+
if (hiddenContainer) hiddenContainer.remove();
92+
hiddenContainer = null;
93+
};
94+
const reconnectTouchedElement = () => {
95+
const element = touchedElement;
96+
if (!element || element.isConnected) return;
97+
let detachedRoot: Node = element;
98+
while (detachedRoot.parentNode) detachedRoot = detachedRoot.parentNode;
99+
if (!hiddenContainer) {
100+
const container = documentToWatch.createElement('div');
101+
container.style.display = 'none';
102+
documentToWatch.body && documentToWatch.body.appendChild(container);
103+
hiddenContainer = container;
104+
}
105+
hiddenContainer.appendChild(detachedRoot);
106+
};
107+
108+
const monitor = dragDropManager.getMonitor();
109+
const onStateChange = () => {
110+
if (!monitor.isDragging()) {
111+
stopWatching();
112+
return;
113+
}
114+
// Not a touch drag, or already watching.
115+
if (observer || !touchedElement || !touchedElement.isConnected) return;
116+
observer = new MutationObserver(reconnectTouchedElement);
117+
observer.observe(documentToWatch, { childList: true, subtree: true });
118+
};
119+
120+
documentToWatch.addEventListener('touchstart', onTouchStart, true);
121+
documentToWatch.addEventListener('touchend', onTouchEnd, true);
122+
documentToWatch.addEventListener('touchcancel', onTouchEnd, true);
123+
const unsubscribe = monitor.subscribeToStateChange(onStateChange);
124+
return () => {
125+
documentToWatch.removeEventListener('touchstart', onTouchStart, true);
126+
documentToWatch.removeEventListener('touchend', onTouchEnd, true);
127+
documentToWatch.removeEventListener('touchcancel', onTouchEnd, true);
128+
unsubscribe();
129+
stopWatching();
130+
};
131+
},
132+
[dragDropManager, documentToWatch]
133+
);
134+
return null;
135+
};
136+
61137
type Props = {|
62138
children: React.Node,
63139

@@ -108,6 +184,9 @@ const DragAndDropContextProvider = ({
108184
<EndDragOnTouchCancel
109185
documentToWatch={window ? window.document : document}
110186
/>
187+
<KeepTouchedElementConnectedDuringDrag
188+
documentToWatch={window ? window.document : document}
189+
/>
111190
{children}
112191
</DndProvider>
113192
);

0 commit comments

Comments
 (0)