diff --git a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.test.tsx b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.test.tsx
index a1992708d9151..f4c5d66af1d7b 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.test.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.test.tsx
@@ -18,7 +18,7 @@
*/
import "@testing-library/jest-dom";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
-import { describe, it, expect, beforeAll, vi } from "vitest";
+import { beforeAll, describe, expect, it, vi } from "vitest";
import { AppWrapper } from "src/utils/AppWrapper";
@@ -529,3 +529,110 @@ describe("Selection pinning across scrolling", () => {
});
});
});
+
+describe("Downward drag selection", () => {
+ it("coalesces events and extends the selection to the mounted bottom row", async () => {
+ render(
+ ,
+ );
+ await waitForLogs();
+
+ const container = screen.getByTestId("virtual-scroll-container");
+ const rows = container.querySelectorAll("[data-index]");
+ const anchorRow = rows[0] as HTMLElement;
+ const lastRow = rows[rows.length - 1] as HTMLElement;
+ const extend = vi.fn();
+ const range = document.createRange();
+
+ range.selectNodeContents(anchorRow);
+
+ const selection = {
+ anchorNode: anchorRow,
+ extend,
+ focusNode: anchorRow,
+ focusOffset: 0,
+ getRangeAt: () => range,
+ rangeCount: 1,
+ } as unknown as Selection;
+ const animationFrames = new Array();
+ const getSelectionSpy = vi.spyOn(document, "getSelection").mockReturnValue(selection);
+ const requestAnimationFrameSpy = vi
+ .spyOn(globalThis, "requestAnimationFrame")
+ .mockImplementation((callback) => {
+ animationFrames.push(callback);
+
+ return animationFrames.length;
+ });
+ const cancelAnimationFrameSpy = vi
+ .spyOn(globalThis, "cancelAnimationFrame")
+ .mockImplementation(() => undefined);
+
+ container.getBoundingClientRect = () => ({ bottom: 500 }) as DOMRect;
+ lastRow.getBoundingClientRect = () => ({ bottom: 480 }) as DOMRect;
+
+ fireEvent.pointerDown(anchorRow, { button: 0, clientY: 200, pointerType: "mouse" });
+ fireEvent.pointerMove(document, { clientY: 490, pointerType: "mouse" });
+ document.dispatchEvent(new Event("selectionchange"));
+ fireEvent.scroll(container);
+
+ expect(animationFrames).toHaveLength(1);
+ animationFrames.shift()?.(0);
+ expect(extend).toHaveBeenCalledWith(lastRow, lastRow.childNodes.length);
+
+ fireEvent.scroll(container);
+ expect(animationFrames).toHaveLength(1);
+ animationFrames.shift()?.(1);
+ expect(extend).toHaveBeenCalledTimes(2);
+
+ fireEvent.scroll(container);
+ const pendingAnimationFrame = animationFrames.shift();
+
+ fireEvent.pointerUp(document, { pointerType: "mouse" });
+ expect(cancelAnimationFrameSpy).toHaveBeenCalledWith(1);
+
+ pendingAnimationFrame?.(2);
+ expect(extend).toHaveBeenCalledTimes(2);
+
+ getSelectionSpy.mockRestore();
+ requestAnimationFrameSpy.mockRestore();
+ cancelAnimationFrameSpy.mockRestore();
+ });
+
+ it("only activates for downward primary-mouse drags starting in a log row", async () => {
+ render(
+ ,
+ );
+ await waitForLogs();
+
+ const container = screen.getByTestId("virtual-scroll-container");
+ const anchorRow = container.querySelector("[data-index]") as HTMLElement;
+ const rows = container.querySelectorAll("[data-index]");
+ const lastRow = rows[rows.length - 1] as HTMLElement;
+ const requestAnimationFrameSpy = vi
+ .spyOn(globalThis, "requestAnimationFrame")
+ .mockImplementation(() => 1);
+
+ container.getBoundingClientRect = () => ({ bottom: 500 }) as DOMRect;
+ lastRow.getBoundingClientRect = () => ({ bottom: 700 }) as DOMRect;
+
+ fireEvent.pointerDown(container, { button: 0, clientY: 200, pointerType: "mouse" });
+ fireEvent.pointerMove(document, { clientY: 600, pointerType: "mouse" });
+ expect(requestAnimationFrameSpy).not.toHaveBeenCalled();
+
+ fireEvent.pointerDown(anchorRow, { button: 2, clientY: 200, pointerType: "mouse" });
+ fireEvent.pointerMove(document, { clientY: 600, pointerType: "mouse" });
+ expect(requestAnimationFrameSpy).not.toHaveBeenCalled();
+
+ fireEvent.pointerDown(anchorRow, { button: 0, clientY: 200, pointerType: "touch" });
+ fireEvent.pointerMove(document, { clientY: 600, pointerType: "touch" });
+ expect(requestAnimationFrameSpy).not.toHaveBeenCalled();
+
+ fireEvent.pointerDown(anchorRow, { button: 0, clientY: 200, pointerType: "mouse" });
+ fireEvent.pointerMove(document, { clientY: 50, pointerType: "mouse" });
+ fireEvent.scroll(container);
+ expect(requestAnimationFrameSpy).not.toHaveBeenCalled();
+
+ fireEvent.pointerUp(document, { pointerType: "mouse" });
+ requestAnimationFrameSpy.mockRestore();
+ });
+});
diff --git a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/TaskLogContent.tsx b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/TaskLogContent.tsx
index b720c081ed5ee..7490f59434f23 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/TaskLogContent.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/TaskLogContent.tsx
@@ -29,7 +29,12 @@ import type { ParsedLogEntry } from "src/queries/useLogs";
import { HighlightedText } from "./HighlightedText";
import { ScrollToButton } from "./ScrollToButton";
-import { getSelectionPinnedRows, mergePinnedIndexes } from "./logSelection";
+import {
+ getBottomDragBoundary,
+ getBottomDragClampTarget,
+ getSelectionPinnedRows,
+ mergePinnedIndexes,
+} from "./logSelection";
import { useLogGroups } from "./useLogGroups";
import { getHighlightColor, isSelectionWithin, scrollToBottom, scrollToTop } from "./utils";
@@ -78,6 +83,10 @@ export const TaskLogContent = ({
const isAtBottomRef = useRef(true);
const prevVisibleCountRef = useRef(0);
const pinnedRowsRef = useRef>([]);
+ const isSelectingRef = useRef(false);
+ // NaN disables clamping between drags.
+ const lastPointerYRef = useRef(Number.NaN);
+ const dragClampRafRef = useRef(0);
const rangeExtractor = (range: VirtualizerRange) =>
mergePinnedIndexes(defaultRangeExtractor(range), pinnedRowsRef.current, range.count);
@@ -113,16 +122,89 @@ export const TaskLogContent = ({
useEffect(() => {
const container = parentRef.current;
+
+ if (!container) {
+ return undefined;
+ }
+ const clampSelectionToBottom = () => {
+ dragClampRafRef.current = 0;
+
+ if (!isSelectingRef.current) {
+ return;
+ }
+ const selection = document.getSelection();
+
+ if (!selection) {
+ return;
+ }
+ const clampTarget = getBottomDragClampTarget({
+ container,
+ pointerY: lastPointerYRef.current,
+ selection,
+ });
+
+ if (clampTarget) {
+ selection.extend(clampTarget.node, clampTarget.offset);
+ }
+ };
+ const scheduleBottomClamp = () => {
+ if (!isSelectingRef.current || dragClampRafRef.current !== 0) {
+ return;
+ }
+ const boundary = getBottomDragBoundary(container);
+
+ if (boundary === undefined || lastPointerYRef.current < boundary.y) {
+ return;
+ }
+ dragClampRafRef.current = requestAnimationFrame(clampSelectionToBottom);
+ };
const handleSelectionChange = () => {
- if (!container) {
+ const selection = document.getSelection();
+
+ pinnedRowsRef.current = getSelectionPinnedRows(selection, container);
+ scheduleBottomClamp();
+ };
+ const handlePointerDown = (event: PointerEvent) => {
+ const target = event.target instanceof Element ? event.target.closest("[data-index]") : null;
+
+ if (event.button !== 0 || event.pointerType !== "mouse" || !target || !container.contains(target)) {
return;
}
- pinnedRowsRef.current = getSelectionPinnedRows(document.getSelection(), container);
+ isSelectingRef.current = true;
+ lastPointerYRef.current = event.clientY;
+ };
+ const stopSelecting = () => {
+ isSelectingRef.current = false;
+ lastPointerYRef.current = Number.NaN;
+ cancelAnimationFrame(dragClampRafRef.current);
+ dragClampRafRef.current = 0;
+ };
+ const handlePointerMove = (event: PointerEvent) => {
+ if (!isSelectingRef.current) {
+ return;
+ }
+ lastPointerYRef.current = event.clientY;
+ scheduleBottomClamp();
};
+ container.addEventListener("pointerdown", handlePointerDown);
+ container.addEventListener("scroll", scheduleBottomClamp, { passive: true });
document.addEventListener("selectionchange", handleSelectionChange);
-
- return () => document.removeEventListener("selectionchange", handleSelectionChange);
+ document.addEventListener("pointermove", handlePointerMove, { passive: true });
+ document.addEventListener("pointerup", stopSelecting);
+ document.addEventListener("pointercancel", stopSelecting);
+ globalThis.addEventListener("blur", stopSelecting);
+
+ return () => {
+ container.removeEventListener("pointerdown", handlePointerDown);
+ container.removeEventListener("scroll", scheduleBottomClamp);
+ document.removeEventListener("selectionchange", handleSelectionChange);
+ document.removeEventListener("pointermove", handlePointerMove);
+ document.removeEventListener("pointerup", stopSelecting);
+ document.removeEventListener("pointercancel", stopSelecting);
+ globalThis.removeEventListener("blur", stopSelecting);
+ cancelAnimationFrame(dragClampRafRef.current);
+ };
}, []);
useLayoutEffect(() => {
diff --git a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/logSelection.test.ts b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/logSelection.test.ts
index b359f1d1bd205..7992ec450f5ed 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/logSelection.test.ts
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/logSelection.test.ts
@@ -18,7 +18,7 @@
*/
import { afterEach, describe, expect, it } from "vitest";
-import { getSelectionPinnedRows, mergePinnedIndexes } from "./logSelection";
+import { getBottomDragClampTarget, getSelectionPinnedRows, mergePinnedIndexes } from "./logSelection";
const buildLogContainer = (rows: Array<{ index: number; text: string }>): HTMLElement => {
const container = document.createElement("div");
@@ -131,6 +131,194 @@ describe("getSelectionPinnedRows", () => {
});
});
+const makeDirectionalSelection = (options: {
+ anchor: Node;
+ anchorOffset: number;
+ focus: Node;
+ focusOffset: number;
+}): Selection =>
+ ({
+ anchorNode: options.anchor,
+ anchorOffset: options.anchorOffset,
+ focusNode: options.focus,
+ focusOffset: options.focusOffset,
+ isCollapsed: false,
+ rangeCount: 1,
+ }) as unknown as Selection;
+
+const buildClampContainer = ({
+ containerBottom = 500,
+ lastRowBottom = 700,
+}: { containerBottom?: number; lastRowBottom?: number } = {}) => {
+ const container = buildLogContainer([
+ { index: 10, text: "row ten" },
+ { index: 11, text: "row eleven" },
+ { index: 12, text: "row twelve" },
+ ]);
+ const lastRow = container.querySelector('[data-index="12"]') as Element;
+
+ container.getBoundingClientRect = () => ({ bottom: containerBottom, top: 100 }) as unknown as DOMRect;
+ lastRow.getBoundingClientRect = () => ({ bottom: lastRowBottom }) as DOMRect;
+
+ return container;
+};
+
+describe("getBottomDragClampTarget", () => {
+ it("returns undefined when there is no selection range", () => {
+ const container = buildClampContainer();
+ const selection = makeDirectionalSelection({
+ anchor: getRowTextNode(container, 11),
+ anchorOffset: 2,
+ focus: getRowTextNode(container, 10),
+ focusOffset: 0,
+ });
+
+ Object.defineProperty(selection, "rangeCount", { value: 0 });
+
+ expect(getBottomDragClampTarget({ container, pointerY: 600, selection })).toBeUndefined();
+ });
+
+ it("clamps to the last row when the pointer is below and the focus flipped above the anchor", () => {
+ const container = buildClampContainer();
+ const selection = makeDirectionalSelection({
+ anchor: getRowTextNode(container, 11),
+ anchorOffset: 2,
+ focus: getRowTextNode(container, 10),
+ focusOffset: 0,
+ });
+ const lastRow = container.querySelector('[data-index="12"]') as Element;
+
+ expect(getBottomDragClampTarget({ container, pointerY: 600, selection })).toEqual({
+ node: lastRow,
+ offset: lastRow.childNodes.length,
+ });
+ });
+
+ it("clamps inside the viewer after the pointer passes the last mounted row", () => {
+ const container = buildClampContainer({ lastRowBottom: 480 });
+ const selection = makeDirectionalSelection({
+ anchor: getRowTextNode(container, 11),
+ anchorOffset: 2,
+ focus: getRowTextNode(container, 10),
+ focusOffset: 0,
+ });
+ const lastRow = container.querySelector('[data-index="12"]') as Element;
+
+ expect(getBottomDragClampTarget({ container, pointerY: 490, selection })).toEqual({
+ node: lastRow,
+ offset: lastRow.childNodes.length,
+ });
+ });
+
+ it("does not clamp inside the viewer while the last mounted row continues below it", () => {
+ const container = buildClampContainer();
+ const selection = makeDirectionalSelection({
+ anchor: getRowTextNode(container, 11),
+ anchorOffset: 2,
+ focus: getRowTextNode(container, 10),
+ focusOffset: 0,
+ });
+
+ expect(getBottomDragClampTarget({ container, pointerY: 490, selection })).toBeUndefined();
+ });
+
+ it("clamps to the last row when the pointer is below and the focus left the rows", () => {
+ const container = buildClampContainer();
+ const outside = document.createElement("div");
+
+ outside.textContent = "outside";
+ document.body.append(outside);
+
+ const selection = makeDirectionalSelection({
+ anchor: getRowTextNode(container, 11),
+ anchorOffset: 2,
+ focus: outside.firstChild as Node,
+ focusOffset: 0,
+ });
+ const lastRow = container.querySelector('[data-index="12"]') as Element;
+
+ expect(getBottomDragClampTarget({ container, pointerY: 600, selection })).toEqual({
+ node: lastRow,
+ offset: lastRow.childNodes.length,
+ });
+ });
+
+ it("does not clamp an upward selection when the pointer is above the container", () => {
+ const container = buildClampContainer();
+ const selection = makeDirectionalSelection({
+ anchor: getRowTextNode(container, 11),
+ anchorOffset: 2,
+ focus: getRowTextNode(container, 10),
+ focusOffset: 0,
+ });
+
+ expect(getBottomDragClampTarget({ container, pointerY: 50, selection })).toBeUndefined();
+ });
+
+ it("clamps a same-row focus inversion when the pointer is below the container", () => {
+ const container = buildClampContainer();
+ const node = getRowTextNode(container, 10);
+ const selection = makeDirectionalSelection({
+ anchor: node,
+ anchorOffset: 4,
+ focus: node,
+ focusOffset: 0,
+ });
+ const lastRow = container.querySelector('[data-index="12"]') as Element;
+
+ expect(getBottomDragClampTarget({ container, pointerY: 600, selection })).toEqual({
+ node: lastRow,
+ offset: lastRow.childNodes.length,
+ });
+ });
+
+ it("follows the mounted edge for a forward selection while the pointer is below the container", () => {
+ const container = buildClampContainer();
+ const selection = makeDirectionalSelection({
+ anchor: getRowTextNode(container, 10),
+ anchorOffset: 2,
+ focus: getRowTextNode(container, 11),
+ focusOffset: 3,
+ });
+ const lastRow = container.querySelector('[data-index="12"]') as Element;
+
+ expect(getBottomDragClampTarget({ container, pointerY: 600, selection })).toEqual({
+ node: lastRow,
+ offset: lastRow.childNodes.length,
+ });
+ });
+
+ it("returns undefined when the focus already sits at the clamp target", () => {
+ const container = buildClampContainer();
+ const lastRow = container.querySelector('[data-index="12"]') as Element;
+ const selection = makeDirectionalSelection({
+ anchor: getRowTextNode(container, 12),
+ anchorOffset: 2,
+ focus: lastRow,
+ focusOffset: lastRow.childNodes.length,
+ });
+
+ expect(getBottomDragClampTarget({ container, pointerY: 600, selection })).toBeUndefined();
+ });
+
+ it("returns undefined when the anchor is not inside a row", () => {
+ const container = buildClampContainer();
+ const outside = document.createElement("div");
+
+ outside.textContent = "outside";
+ document.body.append(outside);
+
+ const selection = makeDirectionalSelection({
+ anchor: outside.firstChild as Node,
+ anchorOffset: 0,
+ focus: getRowTextNode(container, 10),
+ focusOffset: 0,
+ });
+
+ expect(getBottomDragClampTarget({ container, pointerY: 600, selection })).toBeUndefined();
+ });
+});
+
describe("mergePinnedIndexes", () => {
it("returns the default range untouched when there is nothing to pin", () => {
expect(mergePinnedIndexes([5, 6, 7], [], 10)).toEqual([5, 6, 7]);
diff --git a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/logSelection.ts b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/logSelection.ts
index 6b0c6afc689cb..53bfeea74a271 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/logSelection.ts
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/logSelection.ts
@@ -53,6 +53,69 @@ export const getSelectionPinnedRows = (
].filter((index): index is number => index !== undefined);
};
+type BottomDragClampOptions = {
+ container: HTMLElement;
+ pointerY: number;
+ selection: Selection;
+};
+
+type BottomDragBoundary = {
+ lastRow: Element;
+ y: number;
+};
+
+/**
+ * The first Y coordinate where a downward drag leaves selectable log text.
+ * Mid-scroll this is the scrollport edge; at the end it is the last mounted
+ * row edge, before any trailing space or container padding.
+ */
+export const getBottomDragBoundary = (container: HTMLElement): BottomDragBoundary | undefined => {
+ const rows = container.querySelectorAll("[data-index]");
+ const lastRow = rows[rows.length - 1];
+
+ if (lastRow === undefined) {
+ return undefined;
+ }
+
+ return {
+ lastRow,
+ y: Math.min(container.getBoundingClientRect().bottom, lastRow.getBoundingClientRect().bottom),
+ };
+};
+
+/**
+ * Re-extend a downward drag selection to the last mounted row. When all log
+ * rows are absolutely positioned, Chrome can resolve a hit-test below the
+ * scrollport's text to the block start, reversing a downward selection.
+ */
+export const getBottomDragClampTarget = ({
+ container,
+ pointerY,
+ selection,
+}: BottomDragClampOptions): { node: Node; offset: number } | undefined => {
+ if (selection.rangeCount === 0) {
+ return undefined;
+ }
+ const anchorRow = getRowIndexForNode(selection.anchorNode, container);
+
+ if (anchorRow === undefined) {
+ return undefined;
+ }
+ const boundary = getBottomDragBoundary(container);
+
+ if (boundary === undefined || pointerY < boundary.y) {
+ return undefined;
+ }
+ const { lastRow } = boundary;
+ const offset = lastRow.childNodes.length;
+
+ if (selection.focusNode === lastRow && selection.focusOffset === offset) {
+ return undefined;
+ }
+
+ return { node: lastRow, offset };
+};
+
/**
* Merge selection-pinned row indexes into the virtualizer's default render
* range. Rows holding selection boundaries must stay mounted while the user