From f1b4121fbf87320a6dae1e1923b9cf1a74901274 Mon Sep 17 00:00:00 2001 From: Manjusaka Date: Thu, 11 Jun 2026 17:26:59 +0800 Subject: [PATCH] fix(detail): portal the fullscreen zoom viewer to body so it escapes the dialog transform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- components/album/progressive-image.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/components/album/progressive-image.tsx b/components/album/progressive-image.tsx index 1d54b818..1f555f05 100644 --- a/components/album/progressive-image.tsx +++ b/components/album/progressive-image.tsx @@ -2,6 +2,7 @@ import type { ProgressiveImageProps } from '~/types/props.ts' import { useEffect, useState, useRef, Activity } from 'react' +import { createPortal } from 'react-dom' import { useTranslations } from 'next-intl' import { MotionImage } from '~/components/album/motion-image' import { useBlurImageDataUrl } from '~/hooks/use-blurhash' @@ -237,7 +238,17 @@ export default function ProgressiveImage( view, and pointer-events are disabled while hidden so the invisible overlay never blocks the page. Mounted lazily on first open so the engine isn't built until the user actually zooms. */} - {hasOpenedFullScreen && props.keepViewerMounted !== false && ( + {/* Portal the fullscreen viewer to . In the @modal route the + detail lives inside Radix DialogContent, which is `position:fixed` + with `translate(-50%,-50%)` — a transformed ancestor becomes the + containing block for `position:fixed` descendants, so the viewer's + `inset-0` would map to the dialog box (shifted/clipped → blank) + instead of the viewport. Portaling to body escapes that transform so + the viewer covers the real viewport. (The full-page route has no such + ancestor, which is why it worked there.) The modal already sets + `onInteractOutside=preventDefault` + `modal={false}`, so viewer + interaction outside DialogContent doesn't dismiss the dialog. */} + {hasOpenedFullScreen && props.keepViewerMounted !== false && typeof document !== 'undefined' && createPortal(( webGLAvailable ?
- )} + ), document.body)} ) : null}