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:
@@ -5,6 +5,7 @@ import {
|
|||||||
useCallback,
|
useCallback,
|
||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} 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({
|
function ClickPreviewModal({
|
||||||
state,
|
state,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -908,15 +938,64 @@ function ClickPreviewModal({
|
|||||||
const { card, showBack } = state;
|
const { card, showBack } = state;
|
||||||
const hasBack = !!card.back_image_uri;
|
const hasBack = !!card.back_image_uri;
|
||||||
const rotatedBox = useRotatedPreviewBox(hasBack);
|
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
|
// 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
|
// 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.
|
// shows only the "active" face and the flip button toggles between them.
|
||||||
return (
|
return (
|
||||||
<div className="preview-modal-backdrop" onClick={onClose}>
|
<div className="preview-modal-backdrop" ref={backdropRef} onClick={onClose}>
|
||||||
<div
|
<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()}
|
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">
|
<div className="preview-modal-faces">
|
||||||
{/* Only the front face can be horizontal (Battles) — a transformed
|
{/* Only the front face can be horizontal (Battles) — a transformed
|
||||||
Battle's back face is always a normal, correctly-oriented
|
Battle's back face is always a normal, correctly-oriented
|
||||||
|
|||||||
@@ -2018,10 +2018,19 @@ tbody tr:hover {
|
|||||||
.preview-modal-faces {
|
.preview-modal-faces {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
/* Sized explicitly (Scryfall "normal" images are 488x680) rather than from
|
||||||
|
the image's intrinsic size, so the box is already its final size before the
|
||||||
|
image loads — ClickPreviewModal measures the layout on mount to decide on
|
||||||
|
compact mode. Same result as the old intrinsic sizing capped at 90vw/90vh;
|
||||||
|
`--preview-img-h` is the height budget, shrunk in compact mode. */
|
||||||
|
.preview-modal {
|
||||||
|
--preview-img-h: 90vh;
|
||||||
|
}
|
||||||
.preview-modal-img {
|
.preview-modal-img {
|
||||||
display: none;
|
display: none;
|
||||||
|
width: min(488px, 90vw, calc(var(--preview-img-h) * 488 / 680));
|
||||||
|
aspect-ratio: 488 / 680;
|
||||||
max-width: min(720px, 90vw);
|
max-width: min(720px, 90vw);
|
||||||
max-height: 90vh;
|
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
box-shadow: var(--shadow-lg);
|
box-shadow: var(--shadow-lg);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
@@ -2075,6 +2084,43 @@ tbody tr:hover {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/* Compact mode (see ClickPreviewModal): the full layout would be taller than
|
||||||
|
the viewport, so a toolbar (pane toggle + close) shows one pane at a time —
|
||||||
|
a single face, or the info tile — with the art shrunk to fit the height
|
||||||
|
left over after the backdrop padding, toolbar and gap. */
|
||||||
|
.preview-modal-compact {
|
||||||
|
/* A column (not the base wrapping row) so the toolbar stretches to exactly
|
||||||
|
the visible pane's width instead of the row's full 96vw. */
|
||||||
|
flex-direction: column;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
align-items: stretch;
|
||||||
|
--preview-img-h: calc(
|
||||||
|
100vh - 48px - env(safe-area-inset-top) - env(safe-area-inset-bottom) - 36px - 16px
|
||||||
|
);
|
||||||
|
}
|
||||||
|
.preview-modal-bar {
|
||||||
|
flex-basis: 100%;
|
||||||
|
height: 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.preview-modal-compact .preview-modal-faces {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.preview-modal-compact .preview-modal-img:not(.active) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.preview-modal-compact .preview-modal-img:not(.preview-modal-img-rotated) {
|
||||||
|
max-width: min(720px, 90vw);
|
||||||
|
}
|
||||||
|
.preview-modal-compact .preview-modal-close,
|
||||||
|
.preview-modal-compact .preview-modal-flip,
|
||||||
|
.preview-modal-compact:not(.preview-modal-show-info) .preview-info,
|
||||||
|
.preview-modal-show-info .preview-modal-faces {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
.preview-modal-close {
|
.preview-modal-close {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -14px;
|
top: -14px;
|
||||||
|
|||||||
Reference in New Issue
Block a user