Lightbox: pin and size to visible viewport when framed
The staff-report iframe is expanded to full content height (no nested scrollbar), so a modal <dialog> centered in its viewport landed in the middle of the whole document and sat off-screen unless the parent page was scrolled there. Cross-origin means the iframe can't read the parent's scroll position itself. Tab.tpl now broadcasts the iframe's visible slice (webform-mw-viewport, rAF-throttled on scroll/resize/load and after each height change), and AttachmentLightbox pins to that slice and fills its height. Falls back to a fixed box when no viewport message arrives (older extension), and standalone mode keeps native centering at a taller 85vh. Extension bumped to 0.3.1 (template change -> re-copy + cv flush on prod). PRODUCTION_CUTOVER.md change log updated.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
/**
|
||||
* Modal preview for image and PDF attachments.
|
||||
@@ -33,6 +33,44 @@ export function AttachmentLightbox({
|
||||
}) {
|
||||
const ref = useRef<HTMLDialogElement | null>(null);
|
||||
|
||||
// When embedded in the CiviCRM tab, the iframe is auto-expanded to its full
|
||||
// content height (no nested scrollbar), so a modal <dialog> — which centers
|
||||
// in *its* viewport — lands in the vertical middle of the whole document and
|
||||
// is off-screen unless the parent page happens to be scrolled there. The
|
||||
// iframe can't read the parent's scroll position (cross-origin), so the
|
||||
// extension's tab template broadcasts the iframe's currently-visible slice
|
||||
// as `webform-mw-viewport` messages. We pin the dialog to that slice and
|
||||
// size it to fill the visible height. Standalone (non-embedded) keeps the
|
||||
// native viewport centering.
|
||||
const [framed, setFramed] = useState(false);
|
||||
const vpRef = useRef<{ top: number; height: number } | null>(null);
|
||||
const [vp, setVp] = useState<{ top: number; height: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setFramed(window.parent !== window);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onMsg = (e: MessageEvent) => {
|
||||
if (e.source !== window.parent) return;
|
||||
const d = e.data as { type?: string; top?: number; height?: number } | null;
|
||||
if (!d || d.type !== "webform-mw-viewport") return;
|
||||
const next = { top: Number(d.top) || 0, height: Number(d.height) || 0 };
|
||||
vpRef.current = next;
|
||||
// Only reflect into render state while open — many FileLinks mount a
|
||||
// (closed) lightbox each, and we don't want every one re-rendering on
|
||||
// each scroll frame.
|
||||
if (open) setVp(next);
|
||||
};
|
||||
window.addEventListener("message", onMsg);
|
||||
return () => window.removeEventListener("message", onMsg);
|
||||
}, [open]);
|
||||
|
||||
// Seed from the latest known viewport the moment we open.
|
||||
useEffect(() => {
|
||||
if (open) setVp(vpRef.current);
|
||||
}, [open]);
|
||||
|
||||
// Drive the native <dialog>'s open state from our prop.
|
||||
useEffect(() => {
|
||||
const dlg = ref.current;
|
||||
@@ -64,19 +102,35 @@ export function AttachmentLightbox({
|
||||
const isImage = mime.startsWith("image/");
|
||||
const isPdf = mime === "application/pdf";
|
||||
|
||||
// Vertical placement + height.
|
||||
// - framed + known viewport: pin to the visible slice and fill it.
|
||||
// - framed but no viewport yet (e.g. an older extension that doesn't
|
||||
// broadcast): fall back to a safe fixed box so we never balloon to the
|
||||
// full multi-thousand-pixel iframe height.
|
||||
// - standalone: native viewport centering, tall enough for documents.
|
||||
const margin = 16;
|
||||
const dialogStyle: React.CSSProperties | undefined =
|
||||
framed && vp
|
||||
? { top: Math.max(8, vp.top + margin), bottom: "auto", marginTop: 0, marginBottom: 0 }
|
||||
: undefined;
|
||||
const panelHeight = framed
|
||||
? vp
|
||||
? Math.max(360, vp.height - margin * 2)
|
||||
: 640
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
// Sizing note: this dialog opens inside the staff-report iframe, which
|
||||
// auto-grows to fit content (often 2000–4000 px tall). "h-full"/"vh"
|
||||
// values inside that iframe resolve to the full iframe document, so the
|
||||
// dialog would balloon. Cap to a fixed pixel box that fits comfortably
|
||||
// on a typical laptop and still gives PDFs/images enough room.
|
||||
<dialog
|
||||
ref={ref}
|
||||
onClick={onDialogClick}
|
||||
aria-label={`Preview: ${filename}`}
|
||||
style={dialogStyle}
|
||||
className="m-auto w-[min(92vw,900px)] rounded-md bg-transparent p-0 shadow-2xl backdrop:bg-ink/70"
|
||||
>
|
||||
<div className="flex h-[640px] max-h-[85vh] flex-col overflow-hidden rounded-md">
|
||||
<div
|
||||
style={panelHeight !== undefined ? { height: panelHeight } : undefined}
|
||||
className={`flex flex-col overflow-hidden rounded-md ${framed ? "" : "h-[85vh] max-h-[860px]"}`}
|
||||
>
|
||||
<header className="flex items-center justify-between gap-4 bg-paper px-4 py-2.5 sm:px-5">
|
||||
<p className="min-w-0 truncate font-display text-sm text-ink">
|
||||
{filename}
|
||||
|
||||
Reference in New Issue
Block a user