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.
183 lines
6.8 KiB
TypeScript
183 lines
6.8 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
|
|
/**
|
|
* Modal preview for image and PDF attachments.
|
|
*
|
|
* Uses the native <dialog> element for focus trap, Esc-to-close, and
|
|
* inert-background semantics — saves ~100 lines of bespoke a11y wiring
|
|
* that we'd otherwise have to maintain.
|
|
*
|
|
* For an `image/*` mime, renders an <img>. For `application/pdf`, an
|
|
* <iframe>. Anything else should not reach this component — FileLink is
|
|
* responsible for branching office/other types to plain download links.
|
|
*/
|
|
export function AttachmentLightbox({
|
|
open,
|
|
onClose,
|
|
previewSrc,
|
|
downloadHref,
|
|
filename,
|
|
mime,
|
|
}: {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
/** URL the <img>/<iframe> loads from. Should serve with Content-Disposition: inline. */
|
|
previewSrc: string;
|
|
/** Anchor target for the Download button. Serves with Content-Disposition: attachment. */
|
|
downloadHref: string;
|
|
filename: string;
|
|
/** Resolved mime; used to pick between <img> and <iframe>. */
|
|
mime: string;
|
|
}) {
|
|
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;
|
|
if (!dlg) return;
|
|
if (open && !dlg.open) {
|
|
dlg.showModal();
|
|
} else if (!open && dlg.open) {
|
|
dlg.close();
|
|
}
|
|
}, [open]);
|
|
|
|
// Native <dialog> fires a 'close' event on Esc and on form-method=dialog
|
|
// submit. Mirror that back into React state so the parent stays in sync.
|
|
useEffect(() => {
|
|
const dlg = ref.current;
|
|
if (!dlg) return;
|
|
const handle = () => onClose();
|
|
dlg.addEventListener("close", handle);
|
|
return () => dlg.removeEventListener("close", handle);
|
|
}, [onClose]);
|
|
|
|
// Close when the user clicks the backdrop (everything outside the inner
|
|
// panel). The dialog itself receives the click event when the backdrop
|
|
// is hit because the panel uses pointer-events the same way.
|
|
const onDialogClick = (e: React.MouseEvent<HTMLDialogElement>) => {
|
|
if (e.target === ref.current) onClose();
|
|
};
|
|
|
|
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 (
|
|
<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
|
|
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}
|
|
</p>
|
|
<div className="flex flex-shrink-0 items-center gap-3">
|
|
<a
|
|
href={downloadHref}
|
|
className="text-xs font-medium text-leaf-700 underline decoration-rule underline-offset-4 hover:decoration-ink hover:text-leaf-800"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
>
|
|
Download
|
|
</a>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="rounded px-2 py-1 text-xs font-medium text-ink-soft hover:bg-rule-soft/40 focus:outline-none focus-visible:ring-2 focus-visible:ring-leaf-700"
|
|
aria-label="Close preview"
|
|
>
|
|
Close
|
|
</button>
|
|
</div>
|
|
</header>
|
|
<div className="flex flex-1 items-center justify-center overflow-hidden bg-ink/90 p-3">
|
|
{isImage ? (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img
|
|
src={previewSrc}
|
|
alt={filename}
|
|
className="max-h-full max-w-full object-contain"
|
|
/>
|
|
) : isPdf ? (
|
|
<iframe
|
|
src={previewSrc}
|
|
title={filename}
|
|
className="h-full w-full border-0 bg-paper"
|
|
/>
|
|
) : (
|
|
// Defensive: FileLink shouldn't open the lightbox for non-previewable
|
|
// types, but if it does, surface a clear message instead of an empty box.
|
|
<p className="px-6 text-paper">
|
|
Preview not available. Use Download above to open the file.
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</dialog>
|
|
);
|
|
}
|