Fix mobile viewer chrome and keep pages visible during autoplay.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-19 18:07:45 +03:30
co-authored by Cursor
parent a8374e7d6c
commit d25841aa16
6 changed files with 281 additions and 78 deletions
+87 -22
View File
@@ -15,6 +15,10 @@ const props = defineProps<{
flipMs?: number;
}>();
const emit = defineEmits<{
immersive: [value: boolean];
}>();
const flipMs = computed(() => props.flipMs ?? DEFAULT_FLIP_MS);
const autoIntervalMs = computed(() => flipMs.value + AUTO_PAUSE_AFTER_FLIP_MS);
@@ -24,6 +28,7 @@ const fullscreen = ref(false);
const fallbackSlide = ref(false);
const playing = ref(true);
const waiting = ref(true);
const hideChrome = ref(false);
const stage = ref<HTMLElement | null>(null);
const magazine = ref<{ next: () => void; prev: () => void } | null>(null);
@@ -35,6 +40,12 @@ const visualEffect = computed<EffectId>(() => {
return props.effect;
});
const immersive = computed(
() => singlePage.value && (hideChrome.value || (playing.value && !waiting.value)),
);
watch(immersive, (value) => emit("immersive", value), { immediate: true });
let delayTimer = 0;
let loopTimer = 0;
@@ -96,6 +107,11 @@ function togglePlay() {
}
}
function onFab() {
hideChrome.value = false;
if (playing.value) togglePlay();
}
function onKey(ev: KeyboardEvent) {
if (ev.key === "ArrowLeft") onUserNav(next);
if (ev.key === "ArrowRight") onUserNav(prev);
@@ -103,21 +119,49 @@ function onKey(ev: KeyboardEvent) {
ev.preventDefault();
togglePlay();
}
if (ev.key === "Escape") zoom.value = false;
}
async function toggleFullscreen() {
if (!stage.value) return;
if (!document.fullscreenElement) {
await stage.value.requestFullscreen();
fullscreen.value = true;
} else {
await document.exitFullscreen();
fullscreen.value = false;
if (ev.key === "Escape") {
zoom.value = false;
hideChrome.value = false;
}
}
function fullscreenElement() {
const doc = document as Document & { webkitFullscreenElement?: Element | null };
return document.fullscreenElement ?? doc.webkitFullscreenElement ?? null;
}
async function toggleFullscreen() {
if (singlePage.value) {
hideChrome.value = !hideChrome.value;
return;
}
const el = stage.value as
| (HTMLElement & { webkitRequestFullscreen?: () => Promise<void> | void })
| null;
if (!el) return;
try {
if (!fullscreenElement()) {
const req = el.requestFullscreen ?? el.webkitRequestFullscreen;
if (!req) return;
await req.call(el);
} else {
const exit =
document.exitFullscreen ??
(document as Document & { webkitExitFullscreen?: () => Promise<void> | void })
.webkitExitFullscreen;
if (exit) await exit.call(document);
}
} catch {
/* mobile / iframe / permission — ignore */
}
}
function onFullscreenChange() {
fullscreen.value = Boolean(fullscreenElement());
}
function onResize() {
if (fullscreenElement()) return;
windowInner.value = window.innerWidth;
}
@@ -136,12 +180,16 @@ function armAutoplay() {
onMounted(() => {
window.addEventListener("keydown", onKey);
window.addEventListener("resize", onResize);
document.addEventListener("fullscreenchange", onFullscreenChange);
document.addEventListener("webkitfullscreenchange", onFullscreenChange);
armAutoplay();
});
onUnmounted(() => {
window.removeEventListener("keydown", onKey);
window.removeEventListener("resize", onResize);
document.removeEventListener("fullscreenchange", onFullscreenChange);
document.removeEventListener("webkitfullscreenchange", onFullscreenChange);
window.clearTimeout(delayTimer);
stopLoop();
});
@@ -152,25 +200,42 @@ watch(
index.value = 0;
fallbackSlide.value = false;
playing.value = true;
hideChrome.value = false;
armAutoplay();
},
);
</script>
<template>
<div ref="stage" class="viewer-wrap cinematic">
<div class="stage-bar">
<button class="btn" type="button" @click="onUserNav(prev)">قبلی</button>
<button class="btn" type="button" @click="onUserNav(next)">بعدی</button>
<span>{{ images.length ? index + 1 : 0 }} / {{ images.length }}</span>
<button class="btn play" type="button" @click="togglePlay">
{{ playing ? "توقف پخش" : "پخش خودکار" }}
</button>
<span style="flex: 1"></span>
<button class="btn" type="button" @click="zoom = !zoom">زوم</button>
<button class="btn" type="button" @click="toggleFullscreen">تمام‌صفحه</button>
<div ref="stage" class="viewer-wrap cinematic" :class="{ immersive }">
<div class="stage-bar page-chrome">
<div class="chrome-nav">
<button class="btn chrome-btn" type="button" @click="onUserNav(prev)">قبلی</button>
<span class="page-pos">{{ images.length ? index + 1 : 0 }} / {{ images.length }}</span>
<button class="btn chrome-btn" type="button" @click="onUserNav(next)">بعدی</button>
</div>
<div class="chrome-tools">
<button class="btn play chrome-btn" type="button" @click="togglePlay">
<span class="lbl-full">{{ playing ? "توقف پخش" : "پخش خودکار" }}</span>
<span class="lbl-short">{{ playing ? "توقف" : "پخش" }}</span>
</button>
<button class="btn chrome-btn" type="button" @click="zoom = !zoom">زوم</button>
<button class="btn chrome-btn" type="button" @click="toggleFullscreen">
<span class="lbl-full">تمام‌صفحه</span>
<span class="lbl-short">تمام</span>
</button>
</div>
</div>
<button
v-if="immersive"
class="fab-pause"
type="button"
@click="onFab"
>
{{ playing ? "توقف" : "منو" }}
</button>
<div v-if="waiting && playing" class="auto-hint">
<span>تا لحظه‌ای دیگر خودبه‌خود ورق می‌خورد</span>
<i class="hint-bar"></i>
@@ -20,6 +20,7 @@ const mountKey = ref(0);
let book: PageFlip | null = null;
let ignore = false;
let prepared: string[] = [];
let makeGen = 0;
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
@@ -87,25 +88,36 @@ function pageBox(first: HTMLImageElement, maxW: number, maxH: number) {
}
async function makeBook() {
const gen = ++makeGen;
const previous = prepared;
prepared = [];
try {
book?.destroy();
} catch {
/* ignore */
}
book = null;
revokePrepared();
mountKey.value += 1;
await nextTick();
if (!host.value || !outer.value || !props.images.length) return;
if (gen !== makeGen) {
for (const url of previous) URL.revokeObjectURL(url);
return;
}
if (!host.value || !outer.value || !props.images.length) {
for (const url of previous) URL.revokeObjectURL(url);
return;
}
try {
const first = await loadImage(props.images[0]);
if (gen !== makeGen) return;
const maxW = Math.max(280, outer.value.clientWidth - 16);
const maxH = Math.max(320, outer.value.clientHeight - 16);
const { pageW, pageH } = pageBox(first, maxW, maxH);
const fitted: string[] = [];
for (const src of props.images) {
if (gen !== makeGen) return;
try {
const img = src === props.images[0] ? first : await loadImage(src);
fitted.push(await containDraw(img, pageW, pageH));
@@ -115,7 +127,7 @@ async function makeBook() {
}
await nextTick();
if (!host.value) return;
if (gen !== makeGen || !host.value) return;
book = new PageFlip(host.value, {
width: pageW,
@@ -140,6 +152,7 @@ async function makeBook() {
ignore = false;
});
});
for (const url of previous) URL.revokeObjectURL(url);
} catch {
emit("fallback");
}
+5 -3
View File
@@ -10,6 +10,7 @@ const id = computed(() => String(route.params.id));
const catalog = ref<CatalogPublic | null>(null);
const error = ref("");
const loading = ref(true);
const immersive = ref(false);
const images = computed(() => {
if (!catalog.value) return [];
@@ -30,10 +31,10 @@ onMounted(async () => {
</script>
<template>
<div class="stage-page">
<header class="stage-bar">
<div class="stage-page" :class="{ immersive }">
<header class="stage-bar page-chrome">
<h1>{{ catalog?.title || "کاتالوگ دیجیتال" }}</h1>
<router-link class="btn" to="/">ساخت جدید</router-link>
<router-link class="btn chrome-btn" to="/">ساخت جدید</router-link>
</header>
<p v-if="loading" class="viewer-wrap">در حال بارگذاری…</p>
<p v-else-if="error" class="viewer-wrap error">{{ error }}</p>
@@ -42,6 +43,7 @@ onMounted(async () => {
:effect="catalog.effect"
:images="images"
:flip-ms="catalog.flipMs"
@immersive="immersive = $event"
/>
</div>
</template>
+112
View File
@@ -312,6 +312,9 @@ a {
.stage-page {
min-height: 100vh;
min-height: 100dvh;
display: flex;
flex-direction: column;
background:
radial-gradient(900px 400px at 50% -10%, rgba(180, 83, 9, 0.25), transparent 50%),
var(--stage);
@@ -326,11 +329,46 @@ a {
border-bottom: 1px solid #2a241f;
}
.stage-page > .stage-bar {
flex-shrink: 0;
}
.stage-bar h1 {
flex: 1;
margin: 0;
font-size: 1rem;
font-weight: 600;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chrome-btn {
flex-shrink: 0;
white-space: nowrap;
}
.chrome-nav,
.chrome-tools {
display: flex;
align-items: center;
gap: 8px;
}
.chrome-tools {
margin-inline-start: auto;
}
.page-pos {
min-width: 4.5rem;
text-align: center;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.lbl-short {
display: none;
}
.stage-bar a,
@@ -343,6 +381,7 @@ a {
.viewer-wrap {
padding: 16px;
position: relative;
flex: 1;
}
.viewer-wrap.cinematic {
@@ -352,6 +391,79 @@ a {
border-radius: 16px;
}
.fab-pause {
position: fixed;
z-index: 20;
bottom: max(16px, env(safe-area-inset-bottom));
left: 50%;
transform: translateX(-50%);
border: 0;
border-radius: 999px;
padding: 10px 22px;
background: #b45309;
color: #fff;
font: inherit;
font-weight: 600;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
}
.stage-page.immersive > .page-chrome,
.viewer-wrap.immersive > .page-chrome,
.viewer-wrap.immersive .thumbs,
.viewer-wrap.immersive .auto-hint {
display: none;
}
.stage-page.immersive .viewer-wrap {
padding: 0;
border-radius: 0;
}
@media (max-width: 900px) {
.stage-bar {
gap: 8px;
padding: 8px 10px;
}
.stage-bar h1 {
font-size: 0.88rem;
}
.chrome-btn {
padding: 6px 9px;
font-size: 0.78rem;
}
.lbl-full {
display: none;
}
.lbl-short {
display: inline;
}
.viewer-wrap.cinematic > .stage-bar {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
row-gap: 8px;
}
.chrome-nav,
.chrome-tools {
width: 100%;
justify-content: space-between;
}
.chrome-tools {
margin-inline-start: 0;
}
.viewer-wrap {
padding: 10px 8px 16px;
}
}
.auto-hint {
position: relative;
max-width: 420px;