From 97e5aaf6d41e8f99e23963e9d99d89776f0d948f Mon Sep 17 00:00:00 2001 From: Andrew Chang Date: Tue, 4 Aug 2026 16:30:19 +0800 Subject: [PATCH 1/2] 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. --- .../src/pages/TaskInstance/Logs/Logs.test.tsx | 105 +++++++++++- .../TaskInstance/Logs/TaskLogContent.tsx | 86 +++++++++- .../TaskInstance/Logs/logSelection.test.ts | 157 +++++++++++++++++- .../pages/TaskInstance/Logs/logSelection.ts | 44 +++++ 4 files changed, 385 insertions(+), 7 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 a1992708d9151..793a7b1eb15d9 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,106 @@ 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; + + fireEvent.pointerDown(anchorRow, { button: 0, clientY: 200, pointerType: "mouse" }); + fireEvent.pointerMove(document, { clientY: 600, 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 requestAnimationFrameSpy = vi + .spyOn(globalThis, "requestAnimationFrame") + .mockImplementation(() => 1); + + container.getBoundingClientRect = () => ({ bottom: 500 }) 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..fdb3a1ade6b3a 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,7 @@ import type { ParsedLogEntry } from "src/queries/useLogs"; import { HighlightedText } from "./HighlightedText"; import { ScrollToButton } from "./ScrollToButton"; -import { getSelectionPinnedRows, mergePinnedIndexes } from "./logSelection"; +import { getBottomDragClampTarget, getSelectionPinnedRows, mergePinnedIndexes } from "./logSelection"; import { useLogGroups } from "./useLogGroups"; import { getHighlightColor, isSelectionWithin, scrollToBottom, scrollToTop } from "./utils"; @@ -78,6 +78,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 +117,88 @@ 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 || + !(lastPointerYRef.current >= container.getBoundingClientRect().bottom) + ) { + 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..058bd3136beea 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,161 @@ 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 = () => { + const container = buildLogContainer([ + { index: 10, text: "row ten" }, + { index: 11, text: "row eleven" }, + { index: 12, text: "row twelve" }, + ]); + + container.getBoundingClientRect = () => ({ bottom: 500, top: 100 }) as unknown 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 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..7075709084a61 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,50 @@ export const getSelectionPinnedRows = ( ].filter((index): index is number => index !== undefined); }; +type BottomDragClampOptions = { + container: HTMLElement; + pointerY: number; + selection: Selection; +}; + +/** + * 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 rect = container.getBoundingClientRect(); + + if (pointerY < rect.bottom) { + return undefined; + } + const rows = container.querySelectorAll("[data-index]"); + const lastRow = rows[rows.length - 1]; + + if (lastRow === undefined) { + return undefined; + } + 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 From a225e0df0e735452ed474567f04c04b255206a50 Mon Sep 17 00:00:00 2001 From: Andrew Chang Date: Thu, 6 Aug 2026 21:46:42 +0800 Subject: [PATCH 2/2] Keep task log selection stable while dragging --- .../src/pages/TaskInstance/Logs/Logs.test.tsx | 6 ++- .../TaskInstance/Logs/TaskLogContent.tsx | 18 ++++++--- .../TaskInstance/Logs/logSelection.test.ts | 37 ++++++++++++++++++- .../pages/TaskInstance/Logs/logSelection.ts | 35 ++++++++++++++---- 4 files changed, 79 insertions(+), 17 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 793a7b1eb15d9..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 @@ -568,9 +568,10 @@ describe("Downward drag selection", () => { .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: 600, pointerType: "mouse" }); + fireEvent.pointerMove(document, { clientY: 490, pointerType: "mouse" }); document.dispatchEvent(new Event("selectionchange")); fireEvent.scroll(container); @@ -605,11 +606,14 @@ describe("Downward drag selection", () => { 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" }); 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 fdb3a1ade6b3a..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 { getBottomDragClampTarget, getSelectionPinnedRows, mergePinnedIndexes } from "./logSelection"; +import { + getBottomDragBoundary, + getBottomDragClampTarget, + getSelectionPinnedRows, + mergePinnedIndexes, +} from "./logSelection"; import { useLogGroups } from "./useLogGroups"; import { getHighlightColor, isSelectionWithin, scrollToBottom, scrollToTop } from "./utils"; @@ -143,11 +148,12 @@ export const TaskLogContent = ({ } }; const scheduleBottomClamp = () => { - if ( - !isSelectingRef.current || - dragClampRafRef.current !== 0 || - !(lastPointerYRef.current >= container.getBoundingClientRect().bottom) - ) { + if (!isSelectingRef.current || dragClampRafRef.current !== 0) { + return; + } + const boundary = getBottomDragBoundary(container); + + if (boundary === undefined || lastPointerYRef.current < boundary.y) { return; } dragClampRafRef.current = requestAnimationFrame(clampSelectionToBottom); 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 058bd3136beea..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 @@ -146,14 +146,19 @@ const makeDirectionalSelection = (options: { rangeCount: 1, }) as unknown as Selection; -const buildClampContainer = () => { +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: 500, top: 100 }) as unknown as DOMRect; + container.getBoundingClientRect = () => ({ bottom: containerBottom, top: 100 }) as unknown as DOMRect; + lastRow.getBoundingClientRect = () => ({ bottom: lastRowBottom }) as DOMRect; return container; }; @@ -189,6 +194,34 @@ describe("getBottomDragClampTarget", () => { }); }); + 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"); 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 7075709084a61..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 @@ -59,6 +59,30 @@ type BottomDragClampOptions = { 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 @@ -77,17 +101,12 @@ export const getBottomDragClampTarget = ({ if (anchorRow === undefined) { return undefined; } - const rect = container.getBoundingClientRect(); + const boundary = getBottomDragBoundary(container); - if (pointerY < rect.bottom) { - return undefined; - } - const rows = container.querySelectorAll("[data-index]"); - const lastRow = rows[rows.length - 1]; - - if (lastRow === undefined) { + if (boundary === undefined || pointerY < boundary.y) { return undefined; } + const { lastRow } = boundary; const offset = lastRow.childNodes.length; if (selection.focusNode === lastRow && selection.focusOffset === offset) {