Skip to content

fix(detail): portal the fullscreen zoom viewer to body (escape the modal dialog transform)#530

Merged
Zheaoli merged 1 commit into
mainfrom
fix/zoom-viewer-portal
Jun 11, 2026
Merged

fix(detail): portal the fullscreen zoom viewer to body (escape the modal dialog transform)#530
Zheaoli merged 1 commit into
mainfrom
fix/zoom-viewer-portal

Conversation

@Zheaoli

@Zheaoli Zheaoli commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator

Symptom

Grid → modal → open photo zoom = blank/white, but the direct /preview/{id} (full-page) URL zooms fine. Environment-independent (full-page renders on the same GPU). Distinct from the LRU mount-gate fix.

Root cause

In the @modal route the detail view renders inside Radix DialogContent, whose base styles are position:fixed; top:50%; left:50%; translate-x-[-50%] translate-y-[-50%] (+ zoom-in/out-95 animations). Per CSS, a transformed ancestor becomes the containing block for position:fixed descendants — so the WebGL fullscreen viewer (fixed inset-0 z-[100], rendered inside DialogContent) had its inset-0 resolved against the dialog's translated/clipped box instead of the viewport → off-screen/clipped → blank. The full-page route has no DialogContent ancestor, so it worked there.

Fix

Render the viewer via createPortal(viewer, document.body) so it escapes the transformed ancestor and covers the real viewport.

  • React tree unchanged — only the DOM parent moves to body. The fix(viewer): keep zoom viewer mounted + CSS-toggle (restore context reuse/state, follow-up to #509) #510 lifecycle (mount on hasOpenedFullScreen, visibility toggle, destroy on unmount) and the GL-context LRU are untouched.
  • SSR guard: typeof document !== 'undefined' (viewer is client-only + only after hasOpenedFullScreen).
  • Dismiss interaction: modal.tsx already sets onInteractOutside={(e)=>e.preventDefault()} + modal={false}, so interacting with the portaled viewer (now DOM-outside DialogContent) does not dismiss the dialog.
  • z-index: viewer z-[100] > dialog z-50; both at body level → viewer on top.

tsc + eslint clean. Headless can't exercise the modal WebGL viewer (no GPU + soft-nav intercept), so post-deploy verification: devtools confirms the viewer's boundingClientRect is the full viewport (0,0,vw,vh) rather than the dialog box, and Manjusaka confirms zoom renders in the modal on his device.

…the dialog transform

In the @modal route the detail view lives inside Radix DialogContent, which is
`position:fixed` with `translate(-50%,-50%)` (plus zoom-in/out animations). A
transformed ancestor becomes the containing block for `position:fixed`
descendants, so the WebGL fullscreen viewer's `fixed inset-0` mapped to the
dialog's (shifted/clipped) box instead of the viewport — the zoom came up blank.
The full-page /preview route has no DialogContent ancestor, which is why zoom
worked there.

Render the viewer via `createPortal(..., document.body)` so it escapes the
transform and covers the real viewport. Guarded with `typeof document` for SSR.
The React tree (and the #510 mount/visibility/destroy lifecycle) is unchanged —
only the DOM parent moves to body. The modal already sets
`onInteractOutside=preventDefault` + `modal={false}`, so viewer interaction
outside DialogContent doesn't dismiss the dialog; z-[100] > dialog z-50 keeps it
on top.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
picimpact Ready Ready Preview, Comment Jun 11, 2026 9:28am

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant