This is an automated email from the ASF dual-hosted git repository.

bbovenzi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 643a0fa29a6 Keep task log selection stable while dragging (#71155)
643a0fa29a6 is described below

commit 643a0fa29a6c8f2e9997dd19bd17b0c4c4807295
Author: Andrew Chang <[email protected]>
AuthorDate: Mon Aug 10 23:52:40 2026 +0800

    Keep task log selection stable while dragging (#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
---
 .../ui/src/pages/TaskInstance/Logs/Logs.test.tsx   | 109 +++++++++++-
 .../src/pages/TaskInstance/Logs/TaskLogContent.tsx |  87 +++++++++-
 .../pages/TaskInstance/Logs/logSelection.test.ts   | 189 +++++++++++++++++++++
 .../ui/src/pages/TaskInstance/Logs/logSelection.ts |  63 +++++++
 4 files changed, 443 insertions(+), 5 deletions(-)

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 498c631e16d..0033fc2f296 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";
 
@@ -642,3 +642,110 @@ describe("Selection pinning across scrolling", () => {
     });
   });
 });
+
+describe("Downward drag selection", () => {
+  it("coalesces events and extends the selection to the mounted bottom row", 
async () => {
+    render(
+      <AppWrapper 
initialEntries={["/dags/log_grouping/runs/manual__2025-02-18T12:19/tasks/ti_context"]}
 />,
+    );
+    await waitForLogs();
+
+    const container = screen.getByTestId("virtual-scroll-container");
+    const rows = container.querySelectorAll<HTMLElement>("[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<FrameRequestCallback>();
+    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(
+      <AppWrapper 
initialEntries={["/dags/log_grouping/runs/manual__2025-02-18T12:19/tasks/ti_context"]}
 />,
+    );
+    await waitForLogs();
+
+    const container = screen.getByTestId("virtual-scroll-container");
+    const anchorRow = container.querySelector<HTMLElement>("[data-index]") as 
HTMLElement;
+    const rows = container.querySelectorAll<HTMLElement>("[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 d68084e84b8..d71a1e8883c 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
@@ -30,6 +30,8 @@ import type { ParsedLogEntry } from "src/queries/useLogs";
 import { HighlightedText } from "./HighlightedText";
 import { ScrollToButton } from "./ScrollToButton";
 import {
+  getBottomDragBoundary,
+  getBottomDragClampTarget,
   extractSelectedLogText,
   getEntryText,
   getSelectionPinnedRows,
@@ -83,6 +85,10 @@ export const TaskLogContent = ({
   const isAtBottomRef = useRef<boolean>(true);
   const prevVisibleCountRef = useRef<number>(0);
   const pinnedRowsRef = useRef<Array<number>>([]);
+  const isSelectingRef = useRef<boolean>(false);
+  // NaN disables clamping between drags.
+  const lastPointerYRef = useRef<number>(Number.NaN);
+  const dragClampRafRef = useRef<number>(0);
 
   const rangeExtractor = (range: VirtualizerRange) =>
     mergePinnedIndexes(defaultRangeExtractor(range), pinnedRowsRef.current, 
range.count);
@@ -118,16 +124,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);
+    };
   }, []);
 
   useEffect(() => {
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 ceef008c2be..72b10f48217 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
@@ -21,6 +21,7 @@ import { afterEach, describe, expect, it } from "vitest";
 
 import {
   extractSelectedLogText,
+  getBottomDragClampTarget,
   getEntryText,
   getSelectionPinnedRows,
   getSelectionRowRange,
@@ -204,6 +205,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 b51175a7e56..2c27549d90b 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
@@ -83,6 +83,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

Reply via email to