Skip to content

Commit 79dac3c

Browse files
Andrushikadabla
authored andcommitted
Keep task log selection stable while dragging (apache#71155)
* Keep downward task log selection stable while dragging Chrome can resolve the drag focus to the start of the absolutely positioned virtualized log block when the pointer moves below the viewport, reversing the selection. * Keep task log selection stable while dragging * Fix duplicate imports left over from merge conflict resolution
1 parent 143da11 commit 79dac3c

4 files changed

Lines changed: 443 additions & 5 deletions

File tree

airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.test.tsx

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
*/
1919
import "@testing-library/jest-dom";
2020
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
21-
import { describe, it, expect, beforeAll, vi } from "vitest";
21+
import { beforeAll, describe, expect, it, vi } from "vitest";
2222

2323
import { AppWrapper } from "src/utils/AppWrapper";
2424

@@ -642,3 +642,110 @@ describe("Selection pinning across scrolling", () => {
642642
});
643643
});
644644
});
645+
646+
describe("Downward drag selection", () => {
647+
it("coalesces events and extends the selection to the mounted bottom row", async () => {
648+
render(
649+
<AppWrapper initialEntries={["/dags/log_grouping/runs/manual__2025-02-18T12:19/tasks/ti_context"]} />,
650+
);
651+
await waitForLogs();
652+
653+
const container = screen.getByTestId("virtual-scroll-container");
654+
const rows = container.querySelectorAll<HTMLElement>("[data-index]");
655+
const anchorRow = rows[0] as HTMLElement;
656+
const lastRow = rows[rows.length - 1] as HTMLElement;
657+
const extend = vi.fn();
658+
const range = document.createRange();
659+
660+
range.selectNodeContents(anchorRow);
661+
662+
const selection = {
663+
anchorNode: anchorRow,
664+
extend,
665+
focusNode: anchorRow,
666+
focusOffset: 0,
667+
getRangeAt: () => range,
668+
rangeCount: 1,
669+
} as unknown as Selection;
670+
const animationFrames = new Array<FrameRequestCallback>();
671+
const getSelectionSpy = vi.spyOn(document, "getSelection").mockReturnValue(selection);
672+
const requestAnimationFrameSpy = vi
673+
.spyOn(globalThis, "requestAnimationFrame")
674+
.mockImplementation((callback) => {
675+
animationFrames.push(callback);
676+
677+
return animationFrames.length;
678+
});
679+
const cancelAnimationFrameSpy = vi
680+
.spyOn(globalThis, "cancelAnimationFrame")
681+
.mockImplementation(() => undefined);
682+
683+
container.getBoundingClientRect = () => ({ bottom: 500 }) as DOMRect;
684+
lastRow.getBoundingClientRect = () => ({ bottom: 480 }) as DOMRect;
685+
686+
fireEvent.pointerDown(anchorRow, { button: 0, clientY: 200, pointerType: "mouse" });
687+
fireEvent.pointerMove(document, { clientY: 490, pointerType: "mouse" });
688+
document.dispatchEvent(new Event("selectionchange"));
689+
fireEvent.scroll(container);
690+
691+
expect(animationFrames).toHaveLength(1);
692+
animationFrames.shift()?.(0);
693+
expect(extend).toHaveBeenCalledWith(lastRow, lastRow.childNodes.length);
694+
695+
fireEvent.scroll(container);
696+
expect(animationFrames).toHaveLength(1);
697+
animationFrames.shift()?.(1);
698+
expect(extend).toHaveBeenCalledTimes(2);
699+
700+
fireEvent.scroll(container);
701+
const pendingAnimationFrame = animationFrames.shift();
702+
703+
fireEvent.pointerUp(document, { pointerType: "mouse" });
704+
expect(cancelAnimationFrameSpy).toHaveBeenCalledWith(1);
705+
706+
pendingAnimationFrame?.(2);
707+
expect(extend).toHaveBeenCalledTimes(2);
708+
709+
getSelectionSpy.mockRestore();
710+
requestAnimationFrameSpy.mockRestore();
711+
cancelAnimationFrameSpy.mockRestore();
712+
});
713+
714+
it("only activates for downward primary-mouse drags starting in a log row", async () => {
715+
render(
716+
<AppWrapper initialEntries={["/dags/log_grouping/runs/manual__2025-02-18T12:19/tasks/ti_context"]} />,
717+
);
718+
await waitForLogs();
719+
720+
const container = screen.getByTestId("virtual-scroll-container");
721+
const anchorRow = container.querySelector<HTMLElement>("[data-index]") as HTMLElement;
722+
const rows = container.querySelectorAll<HTMLElement>("[data-index]");
723+
const lastRow = rows[rows.length - 1] as HTMLElement;
724+
const requestAnimationFrameSpy = vi
725+
.spyOn(globalThis, "requestAnimationFrame")
726+
.mockImplementation(() => 1);
727+
728+
container.getBoundingClientRect = () => ({ bottom: 500 }) as DOMRect;
729+
lastRow.getBoundingClientRect = () => ({ bottom: 700 }) as DOMRect;
730+
731+
fireEvent.pointerDown(container, { button: 0, clientY: 200, pointerType: "mouse" });
732+
fireEvent.pointerMove(document, { clientY: 600, pointerType: "mouse" });
733+
expect(requestAnimationFrameSpy).not.toHaveBeenCalled();
734+
735+
fireEvent.pointerDown(anchorRow, { button: 2, clientY: 200, pointerType: "mouse" });
736+
fireEvent.pointerMove(document, { clientY: 600, pointerType: "mouse" });
737+
expect(requestAnimationFrameSpy).not.toHaveBeenCalled();
738+
739+
fireEvent.pointerDown(anchorRow, { button: 0, clientY: 200, pointerType: "touch" });
740+
fireEvent.pointerMove(document, { clientY: 600, pointerType: "touch" });
741+
expect(requestAnimationFrameSpy).not.toHaveBeenCalled();
742+
743+
fireEvent.pointerDown(anchorRow, { button: 0, clientY: 200, pointerType: "mouse" });
744+
fireEvent.pointerMove(document, { clientY: 50, pointerType: "mouse" });
745+
fireEvent.scroll(container);
746+
expect(requestAnimationFrameSpy).not.toHaveBeenCalled();
747+
748+
fireEvent.pointerUp(document, { pointerType: "mouse" });
749+
requestAnimationFrameSpy.mockRestore();
750+
});
751+
});

airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/TaskLogContent.tsx

Lines changed: 83 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ import type { ParsedLogEntry } from "src/queries/useLogs";
3030
import { HighlightedText } from "./HighlightedText";
3131
import { ScrollToButton } from "./ScrollToButton";
3232
import {
33+
getBottomDragBoundary,
34+
getBottomDragClampTarget,
3335
extractSelectedLogText,
3436
getEntryText,
3537
getSelectionPinnedRows,
@@ -83,6 +85,10 @@ export const TaskLogContent = ({
8385
const isAtBottomRef = useRef<boolean>(true);
8486
const prevVisibleCountRef = useRef<number>(0);
8587
const pinnedRowsRef = useRef<Array<number>>([]);
88+
const isSelectingRef = useRef<boolean>(false);
89+
// NaN disables clamping between drags.
90+
const lastPointerYRef = useRef<number>(Number.NaN);
91+
const dragClampRafRef = useRef<number>(0);
8692

8793
const rangeExtractor = (range: VirtualizerRange) =>
8894
mergePinnedIndexes(defaultRangeExtractor(range), pinnedRowsRef.current, range.count);
@@ -118,16 +124,89 @@ export const TaskLogContent = ({
118124

119125
useEffect(() => {
120126
const container = parentRef.current;
127+
128+
if (!container) {
129+
return undefined;
130+
}
131+
const clampSelectionToBottom = () => {
132+
dragClampRafRef.current = 0;
133+
134+
if (!isSelectingRef.current) {
135+
return;
136+
}
137+
const selection = document.getSelection();
138+
139+
if (!selection) {
140+
return;
141+
}
142+
const clampTarget = getBottomDragClampTarget({
143+
container,
144+
pointerY: lastPointerYRef.current,
145+
selection,
146+
});
147+
148+
if (clampTarget) {
149+
selection.extend(clampTarget.node, clampTarget.offset);
150+
}
151+
};
152+
const scheduleBottomClamp = () => {
153+
if (!isSelectingRef.current || dragClampRafRef.current !== 0) {
154+
return;
155+
}
156+
const boundary = getBottomDragBoundary(container);
157+
158+
if (boundary === undefined || lastPointerYRef.current < boundary.y) {
159+
return;
160+
}
161+
dragClampRafRef.current = requestAnimationFrame(clampSelectionToBottom);
162+
};
121163
const handleSelectionChange = () => {
122-
if (!container) {
164+
const selection = document.getSelection();
165+
166+
pinnedRowsRef.current = getSelectionPinnedRows(selection, container);
167+
scheduleBottomClamp();
168+
};
169+
const handlePointerDown = (event: PointerEvent) => {
170+
const target = event.target instanceof Element ? event.target.closest("[data-index]") : null;
171+
172+
if (event.button !== 0 || event.pointerType !== "mouse" || !target || !container.contains(target)) {
123173
return;
124174
}
125-
pinnedRowsRef.current = getSelectionPinnedRows(document.getSelection(), container);
175+
isSelectingRef.current = true;
176+
lastPointerYRef.current = event.clientY;
177+
};
178+
const stopSelecting = () => {
179+
isSelectingRef.current = false;
180+
lastPointerYRef.current = Number.NaN;
181+
cancelAnimationFrame(dragClampRafRef.current);
182+
dragClampRafRef.current = 0;
183+
};
184+
const handlePointerMove = (event: PointerEvent) => {
185+
if (!isSelectingRef.current) {
186+
return;
187+
}
188+
lastPointerYRef.current = event.clientY;
189+
scheduleBottomClamp();
126190
};
127191

192+
container.addEventListener("pointerdown", handlePointerDown);
193+
container.addEventListener("scroll", scheduleBottomClamp, { passive: true });
128194
document.addEventListener("selectionchange", handleSelectionChange);
129-
130-
return () => document.removeEventListener("selectionchange", handleSelectionChange);
195+
document.addEventListener("pointermove", handlePointerMove, { passive: true });
196+
document.addEventListener("pointerup", stopSelecting);
197+
document.addEventListener("pointercancel", stopSelecting);
198+
globalThis.addEventListener("blur", stopSelecting);
199+
200+
return () => {
201+
container.removeEventListener("pointerdown", handlePointerDown);
202+
container.removeEventListener("scroll", scheduleBottomClamp);
203+
document.removeEventListener("selectionchange", handleSelectionChange);
204+
document.removeEventListener("pointermove", handlePointerMove);
205+
document.removeEventListener("pointerup", stopSelecting);
206+
document.removeEventListener("pointercancel", stopSelecting);
207+
globalThis.removeEventListener("blur", stopSelecting);
208+
cancelAnimationFrame(dragClampRafRef.current);
209+
};
131210
}, []);
132211

133212
useEffect(() => {

0 commit comments

Comments
 (0)