From 4167bf22ce0f62c807b15bd0809c2b1762b8d987 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Fri, 1 May 2026 18:27:27 -0700 Subject: [PATCH] Modal: drop transform from open animation so clicks don't shift targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the upload-modal file-picker regression: the modal's React Spring enter spring transitions `transform: scale(0.95) translateY(-10px)` → `scale(1) translateY(0)`. With PageScene's heavier render loop, the spring takes long enough that a click on a small deep child (the file uploader's "Upload a file" `` link, ~10px tall) lands its mousedown on the `` and its mouseup on a different element after the modal shifted. WebView2 strictly requires same-target mousedown/mouseup to synthesize `click`, so no `click` fires, the library's native `addEventListener("click", u)` on the label never runs, and `inputRef.current.click()` is never called. Animate opacity only on enter/leave. Same fade visual, no element movement under the cursor. Also strip the now-unused TEMP click tracer from PageEditor.tsx. --- .../app/src/pages/PageScene/PageEditor.tsx | 70 ------------------- .../libs/components/modal/src/lib/modal.tsx | 29 ++++---- 2 files changed, 13 insertions(+), 86 deletions(-) diff --git a/frontend/apps/artcraft/app/src/pages/PageScene/PageEditor.tsx b/frontend/apps/artcraft/app/src/pages/PageScene/PageEditor.tsx index 802b6236ce..0227f8e9db 100644 --- a/frontend/apps/artcraft/app/src/pages/PageScene/PageEditor.tsx +++ b/frontend/apps/artcraft/app/src/pages/PageScene/PageEditor.tsx @@ -123,76 +123,6 @@ export const PageEditor = () => { } }; - // TEMP debug: trace click / mousedown / pointerdown events at every - // listenable phase so we can locate whatever is silencing the - // upload-modal file-picker click. Remove once the bug is fixed. - useEffect(() => { - const describe = (t: EventTarget | null): string => { - if (!(t instanceof HTMLElement)) return String(t); - const id = t.id ? `#${t.id}` : ""; - const cls = - typeof t.className === "string" && t.className - ? `.${t.className.split(/\s+/).slice(0, 2).join(".")}` - : ""; - const label = - t.closest("label")?.getAttribute("for") ?? - (t.closest("label") ? "(unnamed)" : null); - return `${t.tagName.toLowerCase()}${id}${cls}${label ? ` [in label for=${label}]` : ""}`; - }; - - const trace = (phase: string, type: string) => (e: Event) => { - // eslint-disable-next-line no-console - console.debug( - `[click-trace] ${type} ${phase}`, - { - target: describe(e.target), - currentTarget: describe(e.currentTarget), - defaultPrevented: e.defaultPrevented, - bubbles: e.bubbles, - composedPath: (e as any).composedPath?.()?.slice(0, 6).map(describe), - }, - ); - }; - - const types: Array< - "click" | "mousedown" | "mouseup" | "pointerdown" | "pointerup" - > = ["click", "mousedown", "mouseup", "pointerdown", "pointerup"]; - const handlers = types.flatMap((type) => { - const cap = trace("CAPTURE", type); - const bub = trace("BUBBLE", type); - document.addEventListener(type, cap, true); - document.addEventListener(type, bub, false); - return [ - () => document.removeEventListener(type, cap, true), - () => document.removeEventListener(type, bub, false), - ]; - }); - - // Monkey-patch setPointerCapture to see who's grabbing the pointer - // (a mid-click capture explains the missing click — mouseup fires - // on the captured element instead of the original mousedown target). - const proto = Element.prototype as any; - const originalSet = proto.setPointerCapture; - proto.setPointerCapture = function (id: number) { - // eslint-disable-next-line no-console - console.debug("[click-trace] setPointerCapture", { - on: describe(this), - pointerId: id, - stack: new Error().stack?.split("\n").slice(1, 6).join("\n"), - }); - return originalSet.call(this, id); - }; - - // eslint-disable-next-line no-console - console.debug("[click-trace] installed"); - return () => { - handlers.forEach((off) => off()); - proto.setPointerCapture = originalSet; - // eslint-disable-next-line no-console - console.debug("[click-trace] removed"); - }; - }, []); - useEffect(() => { window.onbeforeunload = () => { return "You may have unsaved changes."; diff --git a/frontend/libs/components/modal/src/lib/modal.tsx b/frontend/libs/components/modal/src/lib/modal.tsx index 780ad8f671..184050bf2d 100644 --- a/frontend/libs/components/modal/src/lib/modal.tsx +++ b/frontend/libs/components/modal/src/lib/modal.tsx @@ -229,20 +229,19 @@ export const Modal = ({ // Track last non-expanded position const lastNonExpandedPosition = useRef<{ x: number; y: number } | null>(null); - // Animation transitions + // Animation transitions. Transform (translate / scale) is + // intentionally omitted — when the React tree under the modal is + // busy (e.g. the 3D scene is rendering), the spring takes long + // enough that a quick click on a deep child (like the file + // uploader's "Upload a file" link) lands its mousedown on one + // element and its mouseup on another after the modal shifts under + // the cursor, breaking WebView2's strict same-target rule for + // synthesizing `click`. The file picker then never opens. Opacity + // alone gives the fade without moving any clickable surface. const transitions = useTransition(isOpen, { - from: { - opacity: 0, - transform: "scale(0.95) translateY(-10px)", - }, - enter: { - opacity: 1, - transform: "scale(1) translateY(0px)", - }, - leave: { - opacity: 0, - transform: "scale(0.95) translateY(10px)", - }, + from: { opacity: 0 }, + enter: { opacity: 1 }, + leave: { opacity: 0 }, config: { tension: 300, friction: 30, @@ -986,9 +985,7 @@ export const Modal = ({ style={{ ...getModalStyle(), opacity: styles.opacity, - transform: styles.transform, - transformOrigin: "center center", - willChange: "transform, opacity", // Optimize for animations + willChange: "opacity", outline: "none", }} >