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