From 191aaba05d588c38ec42bdd99aaceefa75afacb6 Mon Sep 17 00:00:00 2001 From: olayinkaadelakun Date: Wed, 13 May 2026 14:34:25 -0700 Subject: [PATCH] fix: prevent canvas text-selection highlight on shift+drag in Tauri (#12995) * fix: prevent canvas text-selection highlight on shift+drag in Tauri WebView * ensure macos fuctionality is identical to chromium * [autofix.ci] apply automated fixes --------- Co-authored-by: Olayinka Adelakun Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../__tests__/useShiftDragSelectFix.test.ts | 109 ++++++++++++++++++ .../hooks/useShiftDragSelectFix.ts | 33 ++++++ .../components/PageComponent/index.tsx | 3 + 3 files changed, 145 insertions(+) create mode 100644 src/frontend/src/pages/FlowPage/components/PageComponent/hooks/__tests__/useShiftDragSelectFix.test.ts create mode 100644 src/frontend/src/pages/FlowPage/components/PageComponent/hooks/useShiftDragSelectFix.ts diff --git a/src/frontend/src/pages/FlowPage/components/PageComponent/hooks/__tests__/useShiftDragSelectFix.test.ts b/src/frontend/src/pages/FlowPage/components/PageComponent/hooks/__tests__/useShiftDragSelectFix.test.ts new file mode 100644 index 0000000000..ee98798c3c --- /dev/null +++ b/src/frontend/src/pages/FlowPage/components/PageComponent/hooks/__tests__/useShiftDragSelectFix.test.ts @@ -0,0 +1,109 @@ +import { act, renderHook } from "@testing-library/react"; +import { createRef } from "react"; +import { useShiftDragSelectFix } from "../useShiftDragSelectFix"; + +// Note: jsdom does not implement vendor-prefixed CSS properties such as +// -webkit-user-select, so only the unprefixed user-select property is +// asserted here. The -webkit- prefix is exercised in real WKWebView browsers. + +function makeRef(el: HTMLElement | null) { + const ref = createRef(); + Object.defineProperty(ref, "current", { value: el, writable: true }); + return ref; +} + +function fireMouseDown(target: HTMLElement, shiftKey: boolean) { + target.dispatchEvent( + new MouseEvent("mousedown", { bubbles: true, shiftKey }), + ); +} + +function fireMouseUp() { + document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); +} + +function getUserSelect() { + return document.documentElement.style.getPropertyValue("user-select"); +} + +describe("useShiftDragSelectFix", () => { + let el: HTMLDivElement; + + beforeEach(() => { + el = document.createElement("div"); + document.body.appendChild(el); + document.documentElement.style.removeProperty("user-select"); + }); + + afterEach(() => { + document.body.removeChild(el); + document.documentElement.style.removeProperty("user-select"); + }); + + it("does nothing when ref.current is null", () => { + renderHook(() => useShiftDragSelectFix(makeRef(null))); + expect(getUserSelect()).toBe(""); + }); + + it("does not suppress selection on mousedown without shiftKey", () => { + renderHook(() => useShiftDragSelectFix(makeRef(el))); + + act(() => fireMouseDown(el, false)); + + expect(getUserSelect()).toBe(""); + }); + + it("sets user-select:none on shift+mousedown", () => { + renderHook(() => useShiftDragSelectFix(makeRef(el))); + + act(() => fireMouseDown(el, true)); + + expect(getUserSelect()).toBe("none"); + }); + + it("restores user-select on mouseup after shift+drag", () => { + renderHook(() => useShiftDragSelectFix(makeRef(el))); + + act(() => fireMouseDown(el, true)); + act(() => fireMouseUp()); + + expect(getUserSelect()).toBe(""); + }); + + it("only restores once — a second mouseup does not clear a subsequently set value", () => { + renderHook(() => useShiftDragSelectFix(makeRef(el))); + + act(() => { + fireMouseDown(el, true); + fireMouseUp(); + document.documentElement.style.setProperty("user-select", "text"); + fireMouseUp(); + }); + + // The restore listener was already removed after the first mouseup, + // so the second mouseup cannot clear the value we just set. + expect(getUserSelect()).toBe("text"); + }); + + it("removes the mousedown listener on unmount", () => { + const { unmount } = renderHook(() => useShiftDragSelectFix(makeRef(el))); + + unmount(); + + act(() => fireMouseDown(el, true)); + + expect(getUserSelect()).toBe(""); + }); + + it("handles multiple shift+drag cycles correctly", () => { + renderHook(() => useShiftDragSelectFix(makeRef(el))); + + for (let i = 0; i < 3; i++) { + act(() => fireMouseDown(el, true)); + expect(getUserSelect()).toBe("none"); + + act(() => fireMouseUp()); + expect(getUserSelect()).toBe(""); + } + }); +}); diff --git a/src/frontend/src/pages/FlowPage/components/PageComponent/hooks/useShiftDragSelectFix.ts b/src/frontend/src/pages/FlowPage/components/PageComponent/hooks/useShiftDragSelectFix.ts new file mode 100644 index 0000000000..76398955cc --- /dev/null +++ b/src/frontend/src/pages/FlowPage/components/PageComponent/hooks/useShiftDragSelectFix.ts @@ -0,0 +1,33 @@ +import { useEffect } from "react"; + +/** + * Suppresses text selection during shift-drag in WKWebView. + * + * WKWebView does not suppress selection on pointer-drag the way Chromium does, + * so ace editors, labels, and inputs highlight as the pointer moves over them. + * We set user-select:none on the root element for the lifetime of the drag and + * restore it on mouseup — so normal text editing is completely unaffected. + */ +export function useShiftDragSelectFix( + ref: React.RefObject, +) { + useEffect(() => { + const el = ref.current; + if (!el) return; + + const onMouseDown = (e: MouseEvent) => { + if (!e.shiftKey) return; + document.documentElement.style.setProperty("-webkit-user-select", "none"); + document.documentElement.style.setProperty("user-select", "none"); + const restore = () => { + document.documentElement.style.removeProperty("-webkit-user-select"); + document.documentElement.style.removeProperty("user-select"); + document.removeEventListener("mouseup", restore); + }; + document.addEventListener("mouseup", restore); + }; + + el.addEventListener("mousedown", onMouseDown); + return () => el.removeEventListener("mousedown", onMouseDown); + }, [ref]); +} diff --git a/src/frontend/src/pages/FlowPage/components/PageComponent/index.tsx b/src/frontend/src/pages/FlowPage/components/PageComponent/index.tsx index d1b28cbf92..7a40e19ff3 100644 --- a/src/frontend/src/pages/FlowPage/components/PageComponent/index.tsx +++ b/src/frontend/src/pages/FlowPage/components/PageComponent/index.tsx @@ -82,6 +82,7 @@ import { MemoizedCanvasControls, MemoizedSidebarTrigger, } from "./MemoizedComponents"; +import { useShiftDragSelectFix } from "./hooks/useShiftDragSelectFix"; import getRandomName from "./utils/get-random-name"; import isWrappedWithClass from "./utils/is-wrapped-with-class"; @@ -729,6 +730,8 @@ export default function Page({ setSelectionEnded(false); }, []); + useShiftDragSelectFix(reactFlowWrapper); + // Workaround to show the menu only after the selection has ended. useEffect(() => { if (selectionEnded && lastSelection && lastSelection.nodes.length > 1) {