Skip to content

Commit 95e6e11

Browse files
authored
fix: Fixes a bug when pointer d&d transition crashes when pressing Escape (#368)
1 parent 5ba94ef commit 95e6e11

File tree

2 files changed

+74
-25
lines changed

2 files changed

+74
-25
lines changed

src/internal/item-container/index.tsx

Lines changed: 50 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { createPortal } from "react-dom";
1818
import { CSS as CSSUtil } from "@dnd-kit/utilities";
1919
import clsx from "clsx";
2020

21-
import { getLogicalBoundingClientRect } from "@cloudscape-design/component-toolkit/internal";
21+
import { createSingletonHandler, getLogicalBoundingClientRect } from "@cloudscape-design/component-toolkit/internal";
2222
import {
2323
useInternalDragHandleInteractionState,
2424
UseInternalDragHandleInteractionStateProps,
@@ -174,6 +174,18 @@ export const ItemContainer = forwardRef(ItemContainerComponent);
174174
// isn't set in stone.
175175
export const CLICK_DRAG_THRESHOLD = 3;
176176

177+
// We use singleton helper to avoid creating event listeners per each board item.
178+
const usePointerEventListeners = createSingletonHandler<
179+
{ type: "move"; event: PointerEvent } | { type: "up"; event: PointerEvent }
180+
>((setEvent) => {
181+
const handleGlobalPointerMove = (event: PointerEvent) => setEvent({ type: "move", event });
182+
const handleGlobalPointerUp = (event: PointerEvent) => setEvent({ type: "up", event });
183+
const controller = new AbortController();
184+
window.addEventListener("pointermove", handleGlobalPointerMove, { signal: controller.signal });
185+
window.addEventListener("pointerup", handleGlobalPointerUp, { signal: controller.signal });
186+
return () => controller.abort();
187+
});
188+
177189
function ItemContainerComponent(
178190
{ item, placed, acquired, inTransition, transform, getItemSize, onKeyMove, children, isRtl }: ItemContainerProps,
179191
ref: Ref<ItemContainerRef>,
@@ -185,6 +197,8 @@ function ItemContainerComponent(
185197
const [isHidden, setIsHidden] = useState(false);
186198
const muteEventsRef = useRef(false);
187199
const itemRef = useRef<HTMLDivElement>(null);
200+
// Keeps the starting position of active pointer-based d&d transition.
201+
// If undefined - it means the pointer-based d&d is not engaged.
188202
const initialPointerDownPosition = useRef<{ x: number; y: number } | undefined>();
189203
const draggableApi = useDraggable({
190204
draggableItem: item,
@@ -306,12 +320,19 @@ function ItemContainerComponent(
306320
}
307321

308322
function onHandleKeyDown(operation: HandleOperation, event: KeyboardEvent) {
323+
// We do not expect keyboard input when pointer-based d&d is performed.
324+
// Upon receiving any, we discard the current transition immediately.
325+
if (transition && initialPointerDownPosition.current) {
326+
initialPointerDownPosition.current = undefined;
327+
draggableApi.discardTransition();
328+
return;
329+
}
330+
309331
const discard = () => {
310332
if (transition || acquired) {
311333
draggableApi.discardTransition();
312334
}
313335
};
314-
315336
switch (event.key) {
316337
case "ArrowUp":
317338
return handleDirectionalMovement("up", operation);
@@ -334,27 +355,15 @@ function ItemContainerComponent(
334355
// 1. If the last interaction is not "keyboard" (the user clicked on another handle issuing a new transition);
335356
// 2. If the item is acquired by the board (in that case the focus moves to the board item which is expected, palette item is hidden and all events handlers must be muted).
336357
selectedHook.current.processBlur();
337-
initialPointerDownPosition.current = undefined;
358+
338359
if (acquired || (transition && transition.interactionType === "keyboard" && !muteEventsRef.current)) {
360+
initialPointerDownPosition.current = undefined;
339361
draggableApi.submitTransition();
340362
}
341363
}
342364

343-
function handleGlobalPointerMove(event: PointerEvent) {
344-
if (hasPointerMovedBeyondThreshold(event, initialPointerDownPosition.current)) {
345-
selectedHook.current.processPointerMove(event);
346-
}
347-
}
348-
349-
function handleGlobalPointerUp(event: PointerEvent) {
350-
selectedHook.current.processPointerUp(event);
351-
initialPointerDownPosition.current = undefined;
352-
// Clean up global listeners after interaction ends
353-
window.removeEventListener("pointermove", handleGlobalPointerMove);
354-
window.removeEventListener("pointerup", handleGlobalPointerUp);
355-
}
356-
357-
function onDragHandlePointerDown(event: ReactPointerEvent, operation: HandleOperation) {
365+
// Pointer-down handler, added to each drag- and resize handle.
366+
function onHandlePointerDown(event: ReactPointerEvent, operation: HandleOperation) {
358367
// Prevent UI issues when right-clicking: in such a case the OS context menu will be shown and
359368
// the board while the board-item is active. Because of the context menu under the pointer,
360369
// onPointerUp is not called anymore. In such a case the board item would stuck in onPointerUp.
@@ -369,11 +378,25 @@ function ItemContainerComponent(
369378
selectedHook.current = resizeInteractionHook;
370379
}
371380
selectedHook.current.processPointerDown(event.nativeEvent);
372-
373-
// If pointerdown is on our button, start listening for global move and up
374-
window.addEventListener("pointermove", handleGlobalPointerMove);
375-
window.addEventListener("pointerup", handleGlobalPointerUp);
376381
}
382+
// Global pointer-move and pointer-up handlers.
383+
usePointerEventListeners(({ type, event }) => {
384+
switch (type) {
385+
case "move": {
386+
if (hasPointerMovedBeyondThreshold(event, initialPointerDownPosition.current)) {
387+
selectedHook.current.processPointerMove(event);
388+
}
389+
break;
390+
}
391+
case "up": {
392+
if (initialPointerDownPosition.current) {
393+
selectedHook.current.processPointerUp(event);
394+
}
395+
initialPointerDownPosition.current = undefined;
396+
break;
397+
}
398+
}
399+
});
377400

378401
function handlePointerInteractionStart(event: PointerEvent, operation: "drag" | "resize") {
379402
const currentItemElement = itemRef.current;
@@ -470,9 +493,11 @@ function ItemContainerComponent(
470493
onDndEndAction: () => transition && draggableApi.submitTransition(),
471494
onUapActionStartAction: () => handleIncrementalTransition("resize"),
472495
};
496+
497+
// When d&d is active, either drag- or resize hook should be used.
498+
// We determine the correct one in the onHandlePointerDown handler, and store it to the selectedHook ref.
473499
const dragInteractionHook = useInternalDragHandleInteractionState(dragHookProps);
474500
const resizeInteractionHook = useInternalDragHandleInteractionState(resizeHookProps);
475-
// We use a ref to the hook for the handle which is currently active. Distinguishment is managed in the handle button's onPointerDown callback.
476501
const selectedHook = useRef(dragInteractionHook);
477502

478503
const isActive = (!!transition && !isHidden) || !!acquired;
@@ -499,7 +524,7 @@ function ItemContainerComponent(
499524
isHidden,
500525
dragHandle: {
501526
ref: dragHandleRef,
502-
onPointerDown: (e) => onDragHandlePointerDown(e, "drag"),
527+
onPointerDown: (e) => onHandlePointerDown(e, "drag"),
503528
onKeyDown: (event: KeyboardEvent) => onHandleKeyDown("drag", event),
504529
activeState: determineHandleActiveState({
505530
isHandleActive: isActive,
@@ -513,7 +538,7 @@ function ItemContainerComponent(
513538
},
514539
resizeHandle: placed
515540
? {
516-
onPointerDown: (e) => onDragHandlePointerDown(e, "resize"),
541+
onPointerDown: (e) => onHandlePointerDown(e, "resize"),
517542
onKeyDown: (event: KeyboardEvent) => onHandleKeyDown("resize", event),
518543
activeState: determineHandleActiveState({
519544
isHandleActive: isActive,

test/functional/board-layout/regressions.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,27 @@ test(
3434
expect(scroll4.top).toBe(scroll3.top);
3535
}),
3636
);
37+
38+
test.each(["Enter", "Space", "Escape", "ArrowRight", "ArrowDown"])("start d&d operation and cancel it with %s", (key) =>
39+
setupTest("/index.html#/dnd/engine-a2h-test", DndPageObject, async (page) => {
40+
const handle = Math.random() > 0.5 ? "findDragHandle" : "findResizeHandle";
41+
await page.mouseDown(boardWrapper.findItemById("A")[handle]().toSelector());
42+
43+
// This should cancel the operation.
44+
await page.mouseMove(10, 10);
45+
await page.keys([key]);
46+
47+
// These should have no effect.
48+
await page.mouseMove(10, 10);
49+
await page.keys([key]);
50+
await page.mouseMove(10, 10);
51+
await page.keys([key]);
52+
53+
await expect(page.getGrid()).resolves.toEqual([
54+
["A", "B", "C", "D"],
55+
["A", "B", "C", "D"],
56+
["E", "F", "G", "H"],
57+
["E", "F", "G", "H"],
58+
]);
59+
})(),
60+
);

0 commit comments

Comments
 (0)