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
|
* horizontal-card preview. Recomputed on resize so it stays live like the
|
||||||
* CSS-only sizing used for normal (portrait) cards.
|
* CSS-only sizing used for normal (portrait) cards.
|
||||||
*
|
*
|
||||||
* `compact` shrinks the budget to roughly match a DFC back face's own
|
* `dfc` (when wide enough for side-by-side faces) shrinks the budget to
|
||||||
* `min(430px, 43vw)` box (see the `.preview-modal-dfc .preview-modal-img`
|
* 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
|
* 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
|
* 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
|
* doesn't render at full single-card size next to a much smaller back face
|
||||||
* and push the info tile off-screen. */
|
* and push the info tile off-screen.
|
||||||
function useRotatedPreviewBox(compact: boolean) {
|
*
|
||||||
|
* `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 compute = () => {
|
||||||
const boundW = compact ? Math.min(430, window.innerWidth * 0.43) : Math.min(720, window.innerWidth * 0.9);
|
// Faces only sit side by side at the `.preview-modal-dfc` ≥860px
|
||||||
const boundH = compact ? window.innerHeight * 0.55 : window.innerHeight * 0.9;
|
// breakpoint; below it they flip one at a time, so use the full budget.
|
||||||
const w = Math.min(boundH, boundW * ROTATED_ASPECT);
|
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 };
|
return { w, h: w / ROTATED_ASPECT };
|
||||||
};
|
};
|
||||||
const [box, setBox] = useState(compute);
|
const [box, setBox] = useState(compute);
|
||||||
@@ -831,7 +837,7 @@ function useRotatedPreviewBox(compact: boolean) {
|
|||||||
const onResize = () => setBox(compute());
|
const onResize = () => setBox(compute());
|
||||||
window.addEventListener("resize", onResize);
|
window.addEventListener("resize", onResize);
|
||||||
return () => window.removeEventListener("resize", onResize);
|
return () => window.removeEventListener("resize", onResize);
|
||||||
}, [compact]);
|
}, [dfc, fitH]);
|
||||||
return box;
|
return box;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -857,9 +863,9 @@ function LegalityPills({ legalities }: { legalities: number }) {
|
|||||||
* type/mana/color, price, ownership context (when the caller had a
|
* type/mana/color, price, ownership context (when the caller had a
|
||||||
* `LibraryCard`/`DeckEntry` to hand — see `PreviewCard`), and per-format
|
* `LibraryCard`/`DeckEntry` to hand — see `PreviewCard`), and per-format
|
||||||
* legality. */
|
* legality. */
|
||||||
function CardInfoTile({ card }: { card: PreviewCard }) {
|
const CardInfoTile = forwardRef<HTMLDivElement, { card: PreviewCard }>(function CardInfoTile({ card }, ref) {
|
||||||
return (
|
return (
|
||||||
<div className="preview-info">
|
<div className="preview-info" ref={ref}>
|
||||||
<div className="preview-info-name">{card.name}</div>
|
<div className="preview-info-name">{card.name}</div>
|
||||||
<div className="preview-info-type">{card.type_line}</div>
|
<div className="preview-info-type">{card.type_line}</div>
|
||||||
<div className="preview-info-row">
|
<div className="preview-info-row">
|
||||||
@@ -895,10 +901,15 @@ function CardInfoTile({ card }: { card: PreviewCard }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
type PreviewPane = "card" | "info";
|
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:
|
/** Segmented control for the compact (one-pane-at-a-time) click preview:
|
||||||
* front face / back face (double-faced cards only) / info tile. */
|
* front face / back face (double-faced cards only) / info tile. */
|
||||||
function PreviewPaneToggle({
|
function PreviewPaneToggle({
|
||||||
@@ -937,23 +948,26 @@ 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 backdropRef = useRef<HTMLDivElement>(null);
|
const backdropRef = useRef<HTMLDivElement>(null);
|
||||||
const modalRef = 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
|
// 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
|
// 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
|
// user scroll. Decided by measuring the full layout itself: every resize
|
||||||
// drops back to it, and the layout effect below re-measures and switches
|
// 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
|
// 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
|
// 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.
|
// 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 [pane, setPane] = useState<PreviewPane>("card");
|
||||||
const [measureKey, setMeasureKey] = useState(0);
|
const [measureKey, setMeasureKey] = useState(0);
|
||||||
|
const artBudget = fit && fit.avail - PREVIEW_BAR_H - PREVIEW_GAP;
|
||||||
|
const rotatedBox = useRotatedPreviewBox(hasBack, artBudget);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onResize = () => {
|
const onResize = () => {
|
||||||
setCompact(false);
|
setFit(null);
|
||||||
setMeasureKey((k) => k + 1);
|
setMeasureKey((k) => k + 1);
|
||||||
};
|
};
|
||||||
window.addEventListener("resize", onResize);
|
window.addEventListener("resize", onResize);
|
||||||
@@ -962,11 +976,35 @@ function ClickPreviewModal({
|
|||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const backdrop = backdropRef.current;
|
const backdrop = backdropRef.current;
|
||||||
const modal = modalRef.current;
|
const modal = modalRef.current;
|
||||||
if (compact || !backdrop || !modal) return;
|
if (fit || !backdrop || !modal) return;
|
||||||
const cs = getComputedStyle(backdrop);
|
const cs = getComputedStyle(backdrop);
|
||||||
const avail = backdrop.clientHeight - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom);
|
const avail = backdrop.clientHeight - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom);
|
||||||
if (modal.getBoundingClientRect().height > avail) setCompact(true);
|
if (modal.getBoundingClientRect().height > avail) {
|
||||||
}, [compact, measureKey, card, showBack]);
|
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) => {
|
const selectPane = (next: PreviewPane, back: boolean) => {
|
||||||
setPane(next);
|
setPane(next);
|
||||||
@@ -980,6 +1018,7 @@ function ClickPreviewModal({
|
|||||||
<div className="preview-modal-backdrop" ref={backdropRef} onClick={onClose}>
|
<div className="preview-modal-backdrop" ref={backdropRef} onClick={onClose}>
|
||||||
<div
|
<div
|
||||||
ref={modalRef}
|
ref={modalRef}
|
||||||
|
style={compactStyle}
|
||||||
className={[
|
className={[
|
||||||
"preview-modal",
|
"preview-modal",
|
||||||
hasBack ? "preview-modal-dfc" : "",
|
hasBack ? "preview-modal-dfc" : "",
|
||||||
@@ -990,12 +1029,6 @@ function ClickPreviewModal({
|
|||||||
.join(" ")}
|
.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
|
||||||
@@ -1035,7 +1068,13 @@ function ClickPreviewModal({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2012,7 +2012,7 @@ tbody tr:hover {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 16px;
|
gap: 16px; /* mirrored by PREVIEW_GAP in components.tsx */
|
||||||
max-width: 96vw;
|
max-width: 96vw;
|
||||||
}
|
}
|
||||||
.preview-modal-faces {
|
.preview-modal-faces {
|
||||||
@@ -2085,30 +2085,41 @@ tbody tr:hover {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
/* Compact mode (see ClickPreviewModal): the full layout would be taller than
|
/* 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 —
|
the viewport, so it shows one pane at a time — a single face, or the info
|
||||||
a single face, or the info tile — with the art shrunk to fit the height
|
tile — above a toolbar (pane toggle + close) that sits at the bottom for
|
||||||
left over after the backdrop padding, toolbar and gap. */
|
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 {
|
.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-direction: column;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
align-items: stretch;
|
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 {
|
.preview-modal-bar {
|
||||||
flex-basis: 100%;
|
flex-shrink: 0;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
.preview-modal-compact .preview-modal-faces {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
.preview-modal-compact .preview-modal-img:not(.active) {
|
.preview-modal-compact .preview-modal-img:not(.active) {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user