diff --git a/frontend/apps/artcraft/app/src/pages/PageScene/PageEditor.tsx b/frontend/apps/artcraft/app/src/pages/PageScene/PageEditor.tsx index 0227f8e9db..802b6236ce 100644 --- a/frontend/apps/artcraft/app/src/pages/PageScene/PageEditor.tsx +++ b/frontend/apps/artcraft/app/src/pages/PageScene/PageEditor.tsx @@ -123,6 +123,76 @@ 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 184050bf2d..780ad8f671 100644 --- a/frontend/libs/components/modal/src/lib/modal.tsx +++ b/frontend/libs/components/modal/src/lib/modal.tsx @@ -229,19 +229,20 @@ export const Modal = ({ // Track last non-expanded position const lastNonExpandedPosition = useRef<{ x: number; y: number } | null>(null); - // 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. + // Animation transitions const transitions = useTransition(isOpen, { - from: { opacity: 0 }, - enter: { opacity: 1 }, - leave: { opacity: 0 }, + 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)", + }, config: { tension: 300, friction: 30, @@ -985,7 +986,9 @@ export const Modal = ({ style={{ ...getModalStyle(), opacity: styles.opacity, - willChange: "opacity", + transform: styles.transform, + transformOrigin: "center center", + willChange: "transform, opacity", // Optimize for animations outline: "none", }} >