Fix mobile viewer chrome and keep pages visible during autoplay.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user