- SingleProduct.vue: rebuild gallery/header/action bar as a self-scrolling layout with a sticky photo, solid-on-scroll header matching PageBar, and a full-screen image preview (blurred overlay, centered thumbnail strip) - Add GallerySlider.vue: pointer-based swipe carousel isolated in an LTR subtree, fixing RTL/transform sign mismatch that caused slide jumps - Fix Icon.vue: icon was read into a plain ref once at setup and never updated on prop change; switch to computed so icon swaps (e.g. cart qty stepper trash/minus toggle) actually re-render - Fix quantity stepper DOM order in SingleProduct.vue/CartItem.vue so plus/minus render correctly under RTL flex - prisma/assign-downloaded-images.js: seed real product photos (FEATURED/ FEATURED_MOBILE/GALLERY/GALLERY_MOBILE) onto the 5 placeholder products
288 lines
11 KiB
Vue
288 lines
11 KiB
Vue
<!--
|
|
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>
|