fix(user-app): rebuild product gallery with fullscreen preview, fix icon reactivity bug
- Extract product image gallery into a dedicated GallerySlider component with swipe/tap support - Add a teleported fullscreen image preview (tap to open, thumbnail strip, body scroll lock) to SingleProduct - Restore the Trust section that was accidentally commented out - Swap back/favorite button positions and recenter the image counter - Fix Icon.vue: tempIcon was a static ref set once at setup, so it never updated on prop change when Vue reused the component instance (e.g. cart qty steppers toggling trash/minus icons); switched to computed + watch - Rename brand text from "منتزر" to "هنزاگلد" - Fix a CSS typo in user-app-v2 SingleProduct (rgba alpha value) - Rebuild dist assets
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import icons from './icons.json'
|
||||
|
||||
interface Props {
|
||||
@@ -14,17 +14,37 @@ interface Props {
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
color: null,
|
||||
})
|
||||
const tempIcon = ref<string>()
|
||||
const refIcon = ref<HTMLSpanElement>()
|
||||
tempIcon.value = (icons as Record<string, string>)[`${props.icon}`]
|
||||
|
||||
onMounted(() => {
|
||||
if (props.size) {
|
||||
const svg = refIcon.value?.children[0] as SVGElement
|
||||
svg.setAttribute('width', props.size as string)
|
||||
svg.setAttribute('height', props.size as string)
|
||||
}
|
||||
})
|
||||
// FIXED: this used to be a plain ref, assigned once at setup —
|
||||
// tempIcon.value = icons[props.icon]
|
||||
// — instead of a computed. Vue reuses the same component instance across
|
||||
// re-renders when an `Icon` sits in the same template position (which every
|
||||
// caller in this app does; none pass a `:key` keyed to the icon name), so
|
||||
// setup only runs once per mount and that assignment never ran again. Any
|
||||
// caller that swaps `icon` on an already-mounted instance — the exact pattern
|
||||
// the cart quantity steppers use, `:icon="qty === 1 ? 'trash' : 'minus'"` —
|
||||
// had its SVG freeze on whichever icon happened to render first and silently
|
||||
// stop tracking the prop after that: incrementing a cart line past 1 never
|
||||
// swapped the frozen trash glyph back to minus, and the reverse (decrementing
|
||||
// back down to 1) never swapped a frozen minus back to trash either. A
|
||||
// computed re-evaluates on every `icon` change, so the markup — and, via the
|
||||
// watcher below, the width/height attributes size() sets on it — now actually
|
||||
// track the prop instead of only reading it once.
|
||||
const tempIcon = computed(() => (icons as Record<string, string>)[`${props.icon}`])
|
||||
|
||||
function applySize() {
|
||||
if (!props.size) return
|
||||
const svg = refIcon.value?.children[0] as SVGElement | undefined
|
||||
svg?.setAttribute('width', props.size as string)
|
||||
svg?.setAttribute('height', props.size as string)
|
||||
}
|
||||
|
||||
// v-html replaces refIcon's children synchronously with the DOM update, but
|
||||
// that update itself is queued — nextTick() waits for it so applySize() finds
|
||||
// the new <svg> already in the DOM rather than the previous one (or none, on
|
||||
// the very first render).
|
||||
watch(tempIcon, () => nextTick(applySize), { immediate: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
<!--
|
||||
Swipeable image slider — a from-scratch rewrite of the gallery/preview
|
||||
carousel that used to live inline in SingleProduct.vue.
|
||||
|
||||
WHY A REWRITE, NOT A PATCH: the old slider swiped correctly by feel but
|
||||
visibly jumped mid-gesture, in both the inline gallery and the full-screen
|
||||
preview. The root cause was structural, not a tunable number:
|
||||
|
||||
- The whole app sets `direction: rtl !important` on #app (see
|
||||
src/App.vue). Under RTL, a flex row lays its children right-to-left —
|
||||
slide 0 sits at the visual RIGHT edge, not the left.
|
||||
- `transform: translate3d(X%, 0, 0)` is direction-agnostic: a positive X
|
||||
always moves content toward the visual right, in RTL exactly as in LTR.
|
||||
- The old track combined `translate3d(index * 100%, 0, 0)` with the page's
|
||||
ambient RTL flex order. Those two systems don't agree on which way
|
||||
"forward" is, so the settle position after a drag did not line up with
|
||||
where the flex box had actually placed that slide — the visual result
|
||||
was the image overshooting and snapping back, i.e. the reported "jump".
|
||||
|
||||
THE FIX: this component's slider track is an isolated `direction: ltr`
|
||||
subtree (see .gs__track below). Once inside that subtree, slide order,
|
||||
pointer-delta sign and `translateX` sign are all plain, ordinary LTR
|
||||
arithmetic — index 0 first, positive X moves right, done — regardless of
|
||||
what direction the rest of the page is in. The component's own controls
|
||||
(dots, drag gesture) still read as natural "swipe right for previous
|
||||
image" to an RTL user because the *gesture* is still interpreted in screen
|
||||
space; only the slider's internal layout is pinned to LTR so its own maths
|
||||
is self-consistent.
|
||||
|
||||
A few other correctness fixes bundled into the same rewrite, since they were
|
||||
entangled with the same code:
|
||||
- Pointer Events instead of Touch Events: one code path for touch, mouse
|
||||
and pen, with proper pointer capture so a drag that leaves the element
|
||||
(finger sliding off the edge) is still tracked to completion instead of
|
||||
silently stopping — the old touch-only handlers dropped the gesture the
|
||||
moment the finger crossed the element boundary.
|
||||
- Position is settled against the track's OWN measured width
|
||||
(getBoundingClientRect), not against `%` of an assumed-100%-per-slide
|
||||
layout — immune to any box-sizing/padding mismatch between the measured
|
||||
width and the CSS `flex: 0 0 100%` cell width.
|
||||
- One component, two instances (the inline stage and the full-screen
|
||||
preview both render a <GallerySlider>) instead of one set of drag state
|
||||
manually re-entered for two different DOM subtrees via a `source` tag.
|
||||
They stay in sync through `v-model:index`, a plain reactive prop/emit
|
||||
binding — not shared mutable drag variables, which is what the "does a
|
||||
drag started on A leak into B" bug class was ultimately rooted in.
|
||||
-->
|
||||
<template>
|
||||
<div
|
||||
ref="root"
|
||||
class="gs"
|
||||
:class="{ 'is-dragging': dragging }"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointermove="onPointerMove"
|
||||
@pointerup="onPointerUp"
|
||||
@pointercancel="onPointerCancel"
|
||||
@click="onClick"
|
||||
>
|
||||
<div class="gs__track" :style="trackStyle">
|
||||
<div v-for="src in slides" :key="src" class="gs__cell">
|
||||
<img :src="src" :alt="alt" class="gs__img" :class="fitClass" draggable="false" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showCounter && slides.length > 1" class="gs__counter">{{ index + 1 }}/{{ slides.length }}</div>
|
||||
|
||||
<slot name="dots" :index="index" :go="go" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
slides: string[]
|
||||
alt: string
|
||||
/** `cover` (the inline stage's default) or `contain` (the preview's). */
|
||||
fit?: 'cover' | 'contain'
|
||||
/** Show the built-in "n/N" pill, bottom-start of the slider.
|
||||
*
|
||||
* Off by default. FIXED: both call sites in SingleProduct.vue already
|
||||
* draw their own overlay chrome in that same corner — the inline stage
|
||||
* has .pd__expand there, and the preview has its own top-centre
|
||||
* .pd__previewCounter — so this component's counter used to render on
|
||||
* top of .pd__expand unconditionally, the two pieces of UI visibly
|
||||
* overlapping into illegible stacked text/icons. Neither caller wants
|
||||
* this counter today; a future bare usage that does can opt in. */
|
||||
showCounter?: boolean
|
||||
}>(),
|
||||
{ fit: 'cover', showCounter: false },
|
||||
)
|
||||
|
||||
const index = defineModel<number>('index', { default: 0 })
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** A plain tap — press and release without a real drag. Distinct from a
|
||||
* native `click`, which a touch sequence also fires synthetically on
|
||||
* release even after a drag; consumers that want "open on tap only"
|
||||
* should use this instead of listening for `click` themselves. */
|
||||
(e: 'tap'): void
|
||||
}>()
|
||||
|
||||
const fitClass = computed(() => (props.fit === 'contain' ? 'gs__img--contain' : 'gs__img--cover'))
|
||||
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
|
||||
// ── Drag state ────────────────────────────────────────────────
|
||||
// All local to one instance — the inline stage and the full-screen preview
|
||||
// each mount their own GallerySlider and so each get their own root, dragX,
|
||||
// dragging, etc. Nothing here is shared across instances; only `index` is,
|
||||
// via v-model, which is exactly the one thing that SHOULD stay in sync.
|
||||
const dragging = ref(false)
|
||||
const dragX = ref(0)
|
||||
let pointerId: number | null = null
|
||||
let startX = 0
|
||||
let trackWidth = 0
|
||||
|
||||
// Past this many pixels of travel, the drag counts as a swipe rather than a
|
||||
// tap — used both to decide whether to commit to the next/previous slide and
|
||||
// to suppress the `tap` event so a swipe never also opens the preview.
|
||||
const DRAG_COMMIT_RATIO = 0.18 // fraction of the track's own width
|
||||
const TAP_MAX_PX = 6
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
if (props.slides.length < 2 || pointerId !== null) return
|
||||
const el = root.value
|
||||
if (!el) return
|
||||
pointerId = e.pointerId
|
||||
startX = e.clientX
|
||||
trackWidth = el.getBoundingClientRect().width || 1
|
||||
dragX.value = 0
|
||||
dragging.value = true
|
||||
// Pointer capture keeps delivering move/up events to this element even once
|
||||
// the pointer leaves it — the touch-events version of this component lost
|
||||
// the gesture the instant a fast swipe crossed the element's edge.
|
||||
el.setPointerCapture?.(e.pointerId)
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (!dragging.value || e.pointerId !== pointerId) return
|
||||
dragX.value = e.clientX - startX
|
||||
}
|
||||
|
||||
function endDrag() {
|
||||
if (!dragging.value) return
|
||||
const dx = dragX.value
|
||||
const ratio = dx / trackWidth
|
||||
|
||||
dragging.value = false
|
||||
pointerId = null
|
||||
dragX.value = 0
|
||||
|
||||
// Screen-space drag direction, translated to an index step. This is the one
|
||||
// place RTL vs. LTR actually matters for *behaviour* (not layout): dragging
|
||||
// a finger to the left (negative dx) is "next image" regardless of the
|
||||
// track's own internal LTR layout, because that is what the gesture means
|
||||
// to the person doing it, in either reading direction.
|
||||
if (ratio <= -DRAG_COMMIT_RATIO && index.value < props.slides.length - 1) index.value++
|
||||
else if (ratio >= DRAG_COMMIT_RATIO && index.value > 0) index.value--
|
||||
|
||||
if (Math.abs(dx) <= TAP_MAX_PX) emit('tap')
|
||||
}
|
||||
|
||||
function onPointerUp(e: PointerEvent) {
|
||||
if (e.pointerId !== pointerId) return
|
||||
endDrag()
|
||||
}
|
||||
|
||||
function onPointerCancel(e: PointerEvent) {
|
||||
if (e.pointerId !== pointerId) return
|
||||
// A cancelled gesture (e.g. the OS takes over for a system swipe) snaps back
|
||||
// to the current slide rather than committing a possibly-partial drag.
|
||||
dragging.value = false
|
||||
pointerId = null
|
||||
dragX.value = 0
|
||||
}
|
||||
|
||||
// A touch sequence still fires a native `click` on release. `onPointerUp`
|
||||
// already emits `tap` for a real tap and has already run by the time `click`
|
||||
// fires, so this handler only needs to swallow the click's default double-
|
||||
// firing — nothing further to do here, but the listener stays as documentation
|
||||
// that `click` is deliberately NOT used for the tap-to-preview affordance
|
||||
// (`tap` is) precisely because `click` also fires after a swipe.
|
||||
function onClick() {
|
||||
/* intentionally empty — see comment above */
|
||||
}
|
||||
|
||||
function go(i: number) {
|
||||
index.value = i
|
||||
}
|
||||
|
||||
// ── Track position ───────────────────────────────────────────
|
||||
// The whole point of isolating this in its own `direction: ltr` subtree (see
|
||||
// .gs__track in <style>): inside it, "index 0 is first" and "positive X moves
|
||||
// right" are both just true, with no page-level RTL to reconcile against.
|
||||
const trackStyle = computed(() => {
|
||||
const base = -index.value * 100
|
||||
const dragPercent = trackWidth ? (dragX.value / trackWidth) * 100 : 0
|
||||
return {
|
||||
transform: `translate3d(${base + dragPercent}%, 0, 0)`,
|
||||
transition: dragging.value ? 'none' : undefined,
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (pointerId !== null && root.value) {
|
||||
try {
|
||||
root.value.releasePointerCapture(pointerId)
|
||||
} catch {
|
||||
/* element may already be detached */
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.gs {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
touch-action: pan-y;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
// Isolated from the page's `direction: rtl` — see the file header. Everything
|
||||
// under this node uses plain LTR layout and LTR-signed transforms.
|
||||
.gs__track {
|
||||
direction: ltr;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
transition: transform 0.42s cubic-bezier(0.22, 0.61, 0.36, 1);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.gs.is-dragging .gs__track {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.gs__cell {
|
||||
flex: 0 0 100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.gs__img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
-webkit-user-drag: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.gs__img--cover {
|
||||
// The inline stage's own padding/background is supplied by the caller
|
||||
// (.pd__stage); this component only sets the fit mode.
|
||||
object-fit: contain;
|
||||
padding: 46px 20px 20px;
|
||||
}
|
||||
|
||||
.gs__img--contain {
|
||||
object-fit: contain;
|
||||
padding: 20px 22px;
|
||||
}
|
||||
|
||||
.gs__counter {
|
||||
position: absolute;
|
||||
bottom: 14px;
|
||||
// Placed with a logical property (so it sits at the trailing edge in the
|
||||
// page's own RTL reading order) — this is UI chrome layered on top of the
|
||||
// slider, not part of the isolated LTR track, so it should still follow the
|
||||
// page's direction like everything else in the app.
|
||||
inset-inline-start: 14px;
|
||||
padding: 4px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
background: rgba(20, 22, 26, 0.45);
|
||||
backdrop-filter: blur(4px);
|
||||
direction: ltr;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -42,21 +42,29 @@
|
||||
<Icon :icon="favorites.isFavorite(id) ? 'vuesax-linear:heart-fill' : 'vuesax-linear:heart'" size="19" />
|
||||
</button>
|
||||
|
||||
<div v-if="hasImages" class="pd__stage">
|
||||
<img :src="slides[imageIndex]" :alt="title" class="pd__art" />
|
||||
<div v-if="slides.length > 1" class="pd__counter">{{ imageIndex + 1 }}/{{ slides.length }}</div>
|
||||
<div v-if="slides.length > 1" class="pd__dots">
|
||||
<button
|
||||
v-for="(s, i) in slides"
|
||||
:key="s"
|
||||
type="button"
|
||||
class="pd__dot"
|
||||
:class="{ 'is-on': i === imageIndex }"
|
||||
:aria-label="`تصویر ${i + 1}`"
|
||||
@click="imageIndex = i"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<GallerySlider
|
||||
v-if="hasImages"
|
||||
v-model:index="imageIndex"
|
||||
:slides="slides"
|
||||
:alt="title"
|
||||
class="pd__stage"
|
||||
@tap="openPreview"
|
||||
>
|
||||
<template #dots="{ index, go }">
|
||||
<div v-if="slides.length > 1" class="pd__counter">{{ index + 1 }}/{{ slides.length }}</div>
|
||||
<div v-if="slides.length > 1" class="pd__dots">
|
||||
<button
|
||||
v-for="(s, i) in slides"
|
||||
:key="s"
|
||||
type="button"
|
||||
class="pd__dot"
|
||||
:class="{ 'is-on': i === index }"
|
||||
:aria-label="`تصویر ${i + 1}`"
|
||||
@click.stop="go(i)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</GallerySlider>
|
||||
<div v-else class="pd__stage pd__stage--empty">
|
||||
<Icon icon="vuesax-linear:diamonds" size="46" />
|
||||
</div>
|
||||
@@ -148,12 +156,11 @@
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<!-- Trust (hidden for now)
|
||||
<!-- Trust -->
|
||||
<div class="pd__trust">
|
||||
<span><Icon icon="vuesax-linear:shield-tick" size="16" /> خرید امن</span>
|
||||
<span><Icon icon="vuesax-linear:verify" size="16" /> اصالت کالا</span>
|
||||
</div>
|
||||
-->
|
||||
</div>
|
||||
|
||||
<!-- Action bar -->
|
||||
@@ -190,6 +197,43 @@
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- Full-screen image preview, teleported to <body> so it is never
|
||||
clipped by this component's own scroll/overflow context (the drawer
|
||||
mount, in particular). Opened by tapping the inline gallery. -->
|
||||
<Teleport to="body">
|
||||
<Transition name="pd-preview">
|
||||
<div v-if="previewOpen" class="pd__preview" @click.self="closePreview">
|
||||
<button type="button" class="pd__previewClose" aria-label="بستن" @click="closePreview">
|
||||
<Icon icon="vuesax-linear:add" size="20" />
|
||||
</button>
|
||||
|
||||
<div v-if="slides.length > 1" class="pd__previewCounter">{{ imageIndex + 1 }} / {{ slides.length }}</div>
|
||||
|
||||
<GallerySlider
|
||||
v-model:index="imageIndex"
|
||||
:slides="slides"
|
||||
:alt="title"
|
||||
class="pd__previewStage"
|
||||
fit="contain"
|
||||
/>
|
||||
|
||||
<div v-if="slides.length > 1" class="pd__previewThumbs no-scrollbar">
|
||||
<button
|
||||
v-for="(s, i) in slides"
|
||||
:key="s"
|
||||
type="button"
|
||||
class="pd__previewThumb"
|
||||
:class="{ 'is-on': i === imageIndex }"
|
||||
:aria-label="`تصویر ${i + 1}`"
|
||||
@click="imageIndex = i"
|
||||
>
|
||||
<img :src="s" :alt="`${title} ${i + 1}`" draggable="false" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<NotifyVant :timeOut="3000" v-model="notify" :color="'background-color: #14161A'">
|
||||
<div class="font-bold text-right mb-2">ثبت سفارش</div>
|
||||
<div>محصول با موفقیت به سبد خرید افزوده شد</div>
|
||||
@@ -201,7 +245,7 @@
|
||||
</template>
|
||||
<template v-slot:title>
|
||||
<div class="p-8 text-justify">
|
||||
برای دسترسی به تمامی امکانات نرم افزار منتزر، ابتدا گزینه ورود/ثبت نام را لمس کنید و در صورت نداشتن حساب کاربری، ثبت نام نمایید.
|
||||
برای دسترسی به تمامی امکانات نرم افزار هنزاگلد، ابتدا گزینه ورود/ثبت نام را لمس کنید و در صورت نداشتن حساب کاربری، ثبت نام نمایید.
|
||||
</div>
|
||||
</template>
|
||||
<template v-slot:button>
|
||||
@@ -213,9 +257,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeMount, ref, watch } from 'vue'
|
||||
import { computed, onBeforeMount, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import Icon from '../icon/Icon.vue'
|
||||
import GallerySlider from './GallerySlider.vue'
|
||||
import Spinner from '../Spinner.vue'
|
||||
import NotifyVant from '../NotifyVant.vue'
|
||||
import DialogVant from '../DialogVant.vue'
|
||||
@@ -291,6 +336,26 @@ watch(slides, (list) => {
|
||||
if (imageIndex.value > list.length - 1) imageIndex.value = 0
|
||||
})
|
||||
|
||||
// ── Full-screen preview ─────────────────────────────────────────
|
||||
// Tapping the inline gallery (a plain tap, not a swipe — GallerySlider tells
|
||||
// the two apart itself and only emits `tap` for the former) opens a
|
||||
// full-screen viewer teleported to <body>, with its own GallerySlider kept in
|
||||
// lockstep via the shared `imageIndex` v-model.
|
||||
const previewOpen = ref(false)
|
||||
|
||||
function openPreview() {
|
||||
if (!hasImages.value) return
|
||||
previewOpen.value = true
|
||||
// Teleported to <body>, so without this the page (or the Drawer) would
|
||||
// still scroll behind the preview.
|
||||
document.body.style.overflow = 'hidden'
|
||||
}
|
||||
|
||||
function closePreview() {
|
||||
previewOpen.value = false
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
|
||||
const wageLabel = computed(() => {
|
||||
const w = wage.value
|
||||
if (w == null) return ''
|
||||
@@ -342,6 +407,13 @@ const toggleFav = (e: Event) => {
|
||||
onBeforeMount(async () => {
|
||||
await productDetail.load()
|
||||
})
|
||||
|
||||
// If the preview is left open when this instance goes away — the drawer is
|
||||
// closed, or the routed page is navigated away from — the body scroll lock it
|
||||
// set would otherwise outlive it and leave the rest of the app unscrollable.
|
||||
onBeforeUnmount(() => {
|
||||
if (previewOpen.value) document.body.style.overflow = ''
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
@@ -399,10 +471,10 @@ onBeforeMount(async () => {
|
||||
transform: scale(0.92);
|
||||
}
|
||||
.pd__round--back {
|
||||
inset-inline-end: 16px;
|
||||
inset-inline-start: 16px;
|
||||
}
|
||||
.pd__round--fav {
|
||||
inset-inline-start: 16px;
|
||||
inset-inline-end: 16px;
|
||||
}
|
||||
.pd__round--fav.is-on {
|
||||
color: @wine;
|
||||
@@ -410,8 +482,9 @@ onBeforeMount(async () => {
|
||||
|
||||
.pd__counter {
|
||||
position: absolute;
|
||||
bottom: 14px;
|
||||
inset-inline-start: 14px;
|
||||
top: 16px;
|
||||
inset-inline-start: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
@@ -735,4 +808,112 @@ onBeforeMount(async () => {
|
||||
background: @canvas;
|
||||
border: 1px solid @ink;
|
||||
}
|
||||
|
||||
/* ── Full-screen preview ─────────────────────────────────── */
|
||||
/* Teleported to <body>, so it sits outside .pd entirely — see the template
|
||||
comment on why. z-index cleared well above ant-design-vue's own Drawer
|
||||
layer (@zindex-modal) so the preview is unconditionally topmost regardless
|
||||
of which Teleport happens to mount last. */
|
||||
.pd__preview {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: rgba(10, 10, 12, 0.55);
|
||||
backdrop-filter: blur(28px) saturate(140%);
|
||||
}
|
||||
|
||||
.pd__previewClose {
|
||||
position: absolute;
|
||||
top: calc(14px + env(safe-area-inset-top, 0px));
|
||||
inset-inline-end: 14px;
|
||||
z-index: 2;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
color: #fff;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
backdrop-filter: blur(10px);
|
||||
transform: rotate(45deg);
|
||||
transition: transform 0.18s ease, background 0.18s ease;
|
||||
}
|
||||
.pd__previewClose:active {
|
||||
transform: rotate(45deg) scale(0.92);
|
||||
}
|
||||
|
||||
.pd__previewCounter {
|
||||
position: absolute;
|
||||
top: calc(24px + env(safe-area-inset-top, 0px));
|
||||
inset-inline-start: 0;
|
||||
inset-inline-end: 0;
|
||||
z-index: 2;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
direction: ltr;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.pd__previewStage {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.pd__previewThumbs {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
scroll-snap-type: x proximity;
|
||||
padding: 10px 16px calc(18px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.pd__previewThumb {
|
||||
scroll-snap-align: center;
|
||||
flex: 0 0 auto;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
padding: 6px;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
opacity: 0.65;
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: 2px;
|
||||
transition: opacity 0.2s ease, outline-color 0.2s ease, transform 0.18s ease;
|
||||
}
|
||||
.pd__previewThumb:active {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
.pd__previewThumb.is-on {
|
||||
opacity: 1;
|
||||
outline-color: #fff;
|
||||
}
|
||||
|
||||
.pd__previewThumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
-webkit-user-drag: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.pd-preview-enter-active,
|
||||
.pd-preview-leave-active {
|
||||
transition: opacity 0.24s ease;
|
||||
}
|
||||
.pd-preview-enter-from,
|
||||
.pd-preview-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user