Click preview: one-pane-at-a-time toggle when it won't fit the screen

When the full layout (card art + info tile) would be taller than the
viewport, typically on phones where the tile wraps below the art, the
modal switches to a compact mode: a toolbar with a Front/Back/Info (or
Card/Info) toggle and the close button, showing one pane at a time with
the art shrunk to fit the remaining height. Decided by measuring the full
layout on mount and after each resize; the art is now sized explicitly
from its known 488x680 aspect so that measurement doesn't depend on the
image having loaded.
This commit is contained in:
2026-10-14 20:29:32 -05:00
parent e4151a501d
commit 7d1184a762
2 changed files with 128 additions and 3 deletions

View File

@@ -5,6 +5,7 @@ import {
useCallback,
useContext,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
@@ -896,6 +897,35 @@ function CardInfoTile({ card }: { card: PreviewCard }) {
);
}
type PreviewPane = "card" | "info";
/** Segmented control for the compact (one-pane-at-a-time) click preview:
* front face / back face (double-faced cards only) / info tile. */
function PreviewPaneToggle({
hasBack,
showBack,
pane,
onSelect,
}: {
hasBack: boolean;
showBack: boolean;
pane: PreviewPane;
onSelect: (pane: PreviewPane, back: boolean) => void;
}) {
const btn = (label: string, active: boolean, onClick: () => void) => (
<button className={active ? "active" : ""} onClick={onClick}>
{label}
</button>
);
return (
<span className="view-toggle">
{btn(hasBack ? "Front" : "Card", pane === "card" && !showBack, () => onSelect("card", false))}
{hasBack && btn("Back", pane === "card" && showBack, () => onSelect("card", true))}
{btn("Info", pane === "info", () => onSelect("info", showBack))}
</span>
);
}
function ClickPreviewModal({
state,
onClose,
@@ -908,15 +938,64 @@ function ClickPreviewModal({
const { card, showBack } = state;
const hasBack = !!card.back_image_uri;
const rotatedBox = useRotatedPreviewBox(hasBack);
const backdropRef = useRef<HTMLDivElement>(null);
const modalRef = useRef<HTMLDivElement>(null);
// Compact mode: when the full layout (art + info tile) would be taller than
// the viewport — i.e. on phones, where the tile wraps below the art — show
// one pane at a time behind a Front/Back/Info toggle instead of making the
// user scroll. Decided by measuring the full layout itself: every resize
// drops back to it, and the layout effect below re-measures and switches
// before paint, so there's no flash and no feedback loop (compact mode is
// never what gets measured). The art's size doesn't depend on the image
// having loaded (see `.preview-modal-img`), so measuring on mount is exact.
const [compact, setCompact] = useState(false);
const [pane, setPane] = useState<PreviewPane>("card");
const [measureKey, setMeasureKey] = useState(0);
useEffect(() => {
const onResize = () => {
setCompact(false);
setMeasureKey((k) => k + 1);
};
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
useLayoutEffect(() => {
const backdrop = backdropRef.current;
const modal = modalRef.current;
if (compact || !backdrop || !modal) return;
const cs = getComputedStyle(backdrop);
const avail = backdrop.clientHeight - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom);
if (modal.getBoundingClientRect().height > avail) setCompact(true);
}, [compact, measureKey, card, showBack]);
const selectPane = (next: PreviewPane, back: boolean) => {
setPane(next);
if (back !== showBack) onFlip();
};
// Both faces are always in the DOM when the card has one; a wide-viewport
// media query shows them side by side (no flip needed), while a narrow one
// shows only the "active" face and the flip button toggles between them.
return (
<div className="preview-modal-backdrop" onClick={onClose}>
<div className="preview-modal-backdrop" ref={backdropRef} onClick={onClose}>
<div
className={`preview-modal ${hasBack ? "preview-modal-dfc" : ""}`}
ref={modalRef}
className={[
"preview-modal",
hasBack ? "preview-modal-dfc" : "",
compact ? "preview-modal-compact" : "",
compact && pane === "info" ? "preview-modal-show-info" : "",
]
.filter(Boolean)
.join(" ")}
onClick={(e) => e.stopPropagation()}
>
{compact && (
<div className="preview-modal-bar">
<PreviewPaneToggle hasBack={hasBack} showBack={showBack} pane={pane} onSelect={selectPane} />
<IconButton icon={Icons.close} label="Close (Esc)" onClick={onClose} overlay />
</div>
)}
<div className="preview-modal-faces">
{/* Only the front face can be horizontal (Battles) — a transformed
Battle's back face is always a normal, correctly-oriented