Click preview compact mode: toolbar below, fixed size, Battles fit too
- Toolbar (pane toggle + close) moves below the pane for thumb reach. - The compact modal gets a fixed box sized to its largest pane, with each pane anchored just above the toolbar, so switching to the (shorter) info tile no longer moves the buttons. A too-tall info tile scrolls within itself. - Horizontal (Battle) art now uses the same post-toolbar height budget as portrait art, computed once in JS from the measured available height. - Fix useRotatedPreviewBox's width/height bounds being swapped, which let rotated art overflow its width cap (e.g. ~469px wide on a 375px phone). - Only apply the narrow side-by-side DFC budget at the >=860px breakpoint where the faces actually sit side by side; below it a DFC Battle's front was rendered at ~43vw.
This commit is contained in:
@@ -812,17 +812,23 @@ const ROTATED_ASPECT = 680 / 488;
|
||||
* horizontal-card preview. Recomputed on resize so it stays live like the
|
||||
* CSS-only sizing used for normal (portrait) cards.
|
||||
*
|
||||
* `compact` shrinks the budget to roughly match a DFC back face's own
|
||||
* `min(430px, 43vw)` box (see the `.preview-modal-dfc .preview-modal-img`
|
||||
* `dfc` (when wide enough for side-by-side faces) shrinks the budget to
|
||||
* roughly match a DFC back face's own `min(430px, 43vw)` box (see the `.preview-modal-dfc .preview-modal-img`
|
||||
* rule) and leaves headroom below for the info tile — pass it whenever the
|
||||
* horizontal front face has a sibling back face, so a transformed Battle
|
||||
* doesn't render at full single-card size next to a much smaller back face
|
||||
* and push the info tile off-screen. */
|
||||
function useRotatedPreviewBox(compact: boolean) {
|
||||
* and push the info tile off-screen.
|
||||
*
|
||||
* `fitH` (compact mode, see `ClickPreviewModal`) overrides both with the
|
||||
* exact art height budget left over after the pane toolbar. */
|
||||
function useRotatedPreviewBox(dfc: boolean, fitH: number | null) {
|
||||
const compute = () => {
|
||||
const boundW = compact ? Math.min(430, window.innerWidth * 0.43) : Math.min(720, window.innerWidth * 0.9);
|
||||
const boundH = compact ? window.innerHeight * 0.55 : window.innerHeight * 0.9;
|
||||
const w = Math.min(boundH, boundW * ROTATED_ASPECT);
|
||||
// Faces only sit side by side at the `.preview-modal-dfc` ≥860px
|
||||
// breakpoint; below it they flip one at a time, so use the full budget.
|
||||
const sideBySide = dfc && fitH === null && window.innerWidth >= 860;
|
||||
const boundW = sideBySide ? Math.min(430, window.innerWidth * 0.43) : Math.min(720, window.innerWidth * 0.9);
|
||||
const boundH = fitH ?? (sideBySide ? window.innerHeight * 0.55 : window.innerHeight * 0.9);
|
||||
const w = Math.min(boundW, boundH * ROTATED_ASPECT);
|
||||
return { w, h: w / ROTATED_ASPECT };
|
||||
};
|
||||
const [box, setBox] = useState(compute);
|
||||
@@ -831,7 +837,7 @@ function useRotatedPreviewBox(compact: boolean) {
|
||||
const onResize = () => setBox(compute());
|
||||
window.addEventListener("resize", onResize);
|
||||
return () => window.removeEventListener("resize", onResize);
|
||||
}, [compact]);
|
||||
}, [dfc, fitH]);
|
||||
return box;
|
||||
}
|
||||
|
||||
@@ -857,9 +863,9 @@ function LegalityPills({ legalities }: { legalities: number }) {
|
||||
* type/mana/color, price, ownership context (when the caller had a
|
||||
* `LibraryCard`/`DeckEntry` to hand — see `PreviewCard`), and per-format
|
||||
* legality. */
|
||||
function CardInfoTile({ card }: { card: PreviewCard }) {
|
||||
const CardInfoTile = forwardRef<HTMLDivElement, { card: PreviewCard }>(function CardInfoTile({ card }, ref) {
|
||||
return (
|
||||
<div className="preview-info">
|
||||
<div className="preview-info" ref={ref}>
|
||||
<div className="preview-info-name">{card.name}</div>
|
||||
<div className="preview-info-type">{card.type_line}</div>
|
||||
<div className="preview-info-row">
|
||||
@@ -895,10 +901,15 @@ function CardInfoTile({ card }: { card: PreviewCard }) {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
type PreviewPane = "card" | "info";
|
||||
|
||||
// Compact-mode toolbar height and the modal's flex gap — keep in sync with
|
||||
// `.preview-modal-bar` / `.preview-modal` in styles.css.
|
||||
const PREVIEW_BAR_H = 36;
|
||||
const PREVIEW_GAP = 16;
|
||||
|
||||
/** Segmented control for the compact (one-pane-at-a-time) click preview:
|
||||
* front face / back face (double-faced cards only) / info tile. */
|
||||
function PreviewPaneToggle({
|
||||
@@ -937,23 +948,26 @@ 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);
|
||||
const infoRef = 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
|
||||
// one pane at a time above 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);
|
||||
// `fit` holds the measurements compact mode sizes itself from; null = full.
|
||||
const [fit, setFit] = useState<{ avail: number; infoH: number } | null>(null);
|
||||
const [pane, setPane] = useState<PreviewPane>("card");
|
||||
const [measureKey, setMeasureKey] = useState(0);
|
||||
const artBudget = fit && fit.avail - PREVIEW_BAR_H - PREVIEW_GAP;
|
||||
const rotatedBox = useRotatedPreviewBox(hasBack, artBudget);
|
||||
useEffect(() => {
|
||||
const onResize = () => {
|
||||
setCompact(false);
|
||||
setFit(null);
|
||||
setMeasureKey((k) => k + 1);
|
||||
};
|
||||
window.addEventListener("resize", onResize);
|
||||
@@ -962,11 +976,35 @@ function ClickPreviewModal({
|
||||
useLayoutEffect(() => {
|
||||
const backdrop = backdropRef.current;
|
||||
const modal = modalRef.current;
|
||||
if (compact || !backdrop || !modal) return;
|
||||
if (fit || !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]);
|
||||
if (modal.getBoundingClientRect().height > avail) {
|
||||
setFit({ avail, infoH: infoRef.current?.getBoundingClientRect().height ?? 0 });
|
||||
}
|
||||
}, [fit, measureKey, card, showBack]);
|
||||
|
||||
// In compact mode the modal gets a fixed box — as wide and tall as the
|
||||
// largest pane (capped to the screen), toolbar pinned to its bottom — so
|
||||
// switching panes never moves the toolbar. Mirrors the CSS sizing of the
|
||||
// art (`.preview-modal-img`) and info tile (`.preview-info`).
|
||||
let compactStyle: React.CSSProperties | undefined;
|
||||
if (fit && artBudget !== null) {
|
||||
const vw = window.innerWidth;
|
||||
const portraitW = Math.min(488, vw * 0.9, (artBudget * 488) / 680);
|
||||
const faces = [
|
||||
card.horizontal ? rotatedBox : { w: portraitW, h: (portraitW * 680) / 488 },
|
||||
...(hasBack ? [{ w: portraitW, h: (portraitW * 680) / 488 }] : []),
|
||||
];
|
||||
const paneW = Math.max(Math.min(320, vw * 0.9), ...faces.map((f) => f.w));
|
||||
const paneH = Math.min(artBudget, Math.max(fit.infoH, ...faces.map((f) => f.h)));
|
||||
compactStyle = {
|
||||
width: paneW,
|
||||
height: paneH + PREVIEW_GAP + PREVIEW_BAR_H,
|
||||
["--preview-img-h" as string]: `${artBudget}px`,
|
||||
};
|
||||
}
|
||||
const compact = !!fit;
|
||||
|
||||
const selectPane = (next: PreviewPane, back: boolean) => {
|
||||
setPane(next);
|
||||
@@ -980,6 +1018,7 @@ function ClickPreviewModal({
|
||||
<div className="preview-modal-backdrop" ref={backdropRef} onClick={onClose}>
|
||||
<div
|
||||
ref={modalRef}
|
||||
style={compactStyle}
|
||||
className={[
|
||||
"preview-modal",
|
||||
hasBack ? "preview-modal-dfc" : "",
|
||||
@@ -990,12 +1029,6 @@ function ClickPreviewModal({
|
||||
.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
|
||||
@@ -1035,7 +1068,13 @@ function ClickPreviewModal({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<CardInfoTile card={card} />
|
||||
<CardInfoTile ref={infoRef} card={card} />
|
||||
{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>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2012,7 +2012,7 @@ tbody tr:hover {
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
gap: 16px; /* mirrored by PREVIEW_GAP in components.tsx */
|
||||
max-width: 96vw;
|
||||
}
|
||||
.preview-modal-faces {
|
||||
@@ -2085,30 +2085,41 @@ tbody tr:hover {
|
||||
}
|
||||
}
|
||||
/* 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. */
|
||||
the viewport, so it shows one pane at a time — a single face, or the info
|
||||
tile — above a toolbar (pane toggle + close) that sits at the bottom for
|
||||
thumb reach. The modal's width/height and `--preview-img-h` are set inline
|
||||
from JS: a fixed box sized to the largest pane, so the toolbar stays put
|
||||
when switching panes. Each pane is pushed down onto the toolbar
|
||||
(`margin-top: auto`) and centered horizontally; an info tile taller than
|
||||
the art budget scrolls within itself. */
|
||||
.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-compact .preview-modal-faces,
|
||||
.preview-modal-compact .preview-info {
|
||||
margin-top: auto;
|
||||
align-self: center;
|
||||
}
|
||||
.preview-modal-compact .preview-modal-faces {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.preview-modal-compact .preview-info {
|
||||
flex: 0 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
/* Height mirrored by PREVIEW_BAR_H in components.tsx. */
|
||||
.preview-modal-bar {
|
||||
flex-basis: 100%;
|
||||
flex-shrink: 0;
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user