|
|
|
@@ -0,0 +1,507 @@
|
|
|
|
|
<!--
|
|
|
|
|
Spring-physics card carousel for the home screen (see
|
|
|
|
|
docs/plans/2026-10-16-home-carousel-design.md).
|
|
|
|
|
|
|
|
|
|
Center card large, side cards scaled down; drag / flick settle with a
|
|
|
|
|
damped spring. Internal layout is an isolated `direction: ltr` subtree
|
|
|
|
|
(same rationale as GallerySlider) so translate/scale maths stay coherent
|
|
|
|
|
under the app's RTL chrome. Gestures are still screen-space.
|
|
|
|
|
-->
|
|
|
|
|
<template>
|
|
|
|
|
<section class="carousel" v-if="slides.length">
|
|
|
|
|
<div
|
|
|
|
|
ref="viewport"
|
|
|
|
|
class="carousel__viewport"
|
|
|
|
|
:class="{ 'is-dragging': dragging }"
|
|
|
|
|
:style="{ '--carousel-radius': `${borderRadius}px` }"
|
|
|
|
|
@pointerdown="onPointerDown"
|
|
|
|
|
@pointermove="onPointerMove"
|
|
|
|
|
@pointerup="onPointerUp"
|
|
|
|
|
@pointercancel="onPointerUp"
|
|
|
|
|
>
|
|
|
|
|
<div ref="stage" class="carousel__stage">
|
|
|
|
|
<article
|
|
|
|
|
v-for="(card, i) in cards"
|
|
|
|
|
:key="card.key"
|
|
|
|
|
class="carousel__card"
|
|
|
|
|
:data-index="i"
|
|
|
|
|
:style="cardStyles[i]"
|
|
|
|
|
>
|
|
|
|
|
<img
|
|
|
|
|
v-if="card.image"
|
|
|
|
|
:src="card.image"
|
|
|
|
|
alt=""
|
|
|
|
|
class="carousel__art"
|
|
|
|
|
draggable="false"
|
|
|
|
|
/>
|
|
|
|
|
</article>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="carousel__dots" v-if="slides.length > 1" dir="ltr">
|
|
|
|
|
<button
|
|
|
|
|
v-for="(slide, i) in slides"
|
|
|
|
|
:key="slide.key"
|
|
|
|
|
type="button"
|
|
|
|
|
class="carousel__dot"
|
|
|
|
|
:class="{ 'is-active': i === activeIndex }"
|
|
|
|
|
:aria-label="`اسلاید ${i + 1}`"
|
|
|
|
|
@click="goTo(i)"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
import {
|
|
|
|
|
computed,
|
|
|
|
|
onActivated,
|
|
|
|
|
onBeforeUnmount,
|
|
|
|
|
onDeactivated,
|
|
|
|
|
onMounted,
|
|
|
|
|
ref,
|
|
|
|
|
shallowRef,
|
|
|
|
|
watch,
|
|
|
|
|
} from 'vue'
|
|
|
|
|
import type { ShowcaseSlide } from './ShowcaseSlider.vue'
|
|
|
|
|
import { useAppearanceStore } from '../../stores/appearanceStore'
|
|
|
|
|
|
|
|
|
|
const props = withDefaults(
|
|
|
|
|
defineProps<{
|
|
|
|
|
slides: ShowcaseSlide[]
|
|
|
|
|
sideScale?: number
|
|
|
|
|
gap?: number
|
|
|
|
|
borderRadius?: number
|
|
|
|
|
stiffness?: number
|
|
|
|
|
damping?: number
|
|
|
|
|
autoplay?: boolean
|
|
|
|
|
autoplayInterval?: number
|
|
|
|
|
}>(),
|
|
|
|
|
{
|
|
|
|
|
// Slightly larger neighbours so peeks read as cards, not hairlines.
|
|
|
|
|
sideScale: 0.72,
|
|
|
|
|
gap: 3,
|
|
|
|
|
// Match ShowcaseSlider frame radius so the active card reads the same.
|
|
|
|
|
borderRadius: 22,
|
|
|
|
|
// Premium settle: low stiffness + higher damping = slow, heavy, almost no bounce.
|
|
|
|
|
stiffness: 70,
|
|
|
|
|
damping: 28,
|
|
|
|
|
autoplay: true,
|
|
|
|
|
autoplayInterval: 7500,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
/** Fraction of stage width for each card — wider center, tighter side peeks. */
|
|
|
|
|
const CARD_WIDTH_RATIO = 0.92
|
|
|
|
|
|
|
|
|
|
const appearance = useAppearanceStore()
|
|
|
|
|
|
|
|
|
|
/** Pad copies so wrap never shows empty slots on either side. */
|
|
|
|
|
const cards = computed(() => {
|
|
|
|
|
const base = props.slides.filter((s) => s.image)
|
|
|
|
|
if (!base.length) return [] as { key: string; image: string }[]
|
|
|
|
|
const out: { key: string; image: string }[] = []
|
|
|
|
|
let n = 0
|
|
|
|
|
while (out.length < Math.max(6, base.length * 2)) {
|
|
|
|
|
for (const s of base) {
|
|
|
|
|
out.push({ key: `${s.key}#${n++}`, image: s.image as string })
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return out
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const viewport = ref<HTMLElement | null>(null)
|
|
|
|
|
const stage = ref<HTMLElement | null>(null)
|
|
|
|
|
const dragging = ref(false)
|
|
|
|
|
const activeIndex = ref(0)
|
|
|
|
|
|
|
|
|
|
type CardStyle = {
|
|
|
|
|
opacity: string
|
|
|
|
|
pointerEvents: 'auto' | 'none'
|
|
|
|
|
zIndex: string
|
|
|
|
|
transform: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const cardStyles = shallowRef<CardStyle[]>([])
|
|
|
|
|
|
|
|
|
|
let position = 0
|
|
|
|
|
let target = 0
|
|
|
|
|
let velocity = 0
|
|
|
|
|
let isDragging = false
|
|
|
|
|
let startX = 0
|
|
|
|
|
let startPosition = 0
|
|
|
|
|
let lastX = 0
|
|
|
|
|
let lastTime = 0
|
|
|
|
|
let dragVelocity = 0
|
|
|
|
|
let totalDragDistance = 0
|
|
|
|
|
let pointerId: number | null = null
|
|
|
|
|
let rafId: number | null = null
|
|
|
|
|
let lastFrameTime = 0
|
|
|
|
|
let autoplayTimer: ReturnType<typeof setInterval> | undefined
|
|
|
|
|
let resizeObserver: ResizeObserver | null = null
|
|
|
|
|
|
|
|
|
|
function getWrappedOffset(index: number, pos: number, length: number) {
|
|
|
|
|
let diff = (index - pos) % length
|
|
|
|
|
if (diff < -length / 2) diff += length
|
|
|
|
|
if (diff >= length / 2) diff -= length
|
|
|
|
|
return diff
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getActiveIndex() {
|
|
|
|
|
const len = props.slides.filter((s) => s.image).length || 1
|
|
|
|
|
return ((Math.round(target) % len) + len) % len
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function notifyChange() {
|
|
|
|
|
activeIndex.value = getActiveIndex()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function render() {
|
|
|
|
|
const list = cards.value
|
|
|
|
|
const total = list.length
|
|
|
|
|
if (!total) {
|
|
|
|
|
cardStyles.value = []
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Center card is CARD_WIDTH_RATIO of stage; scaled neighbours peek beside it.
|
|
|
|
|
const stageWidth = stage.value?.clientWidth || 380
|
|
|
|
|
const baseCardWidth = stageWidth * CARD_WIDTH_RATIO
|
|
|
|
|
const sideScale = props.sideScale
|
|
|
|
|
const gap = props.gap
|
|
|
|
|
const stepDistance = baseCardWidth * 0.5 + baseCardWidth * sideScale * 0.5 + gap
|
|
|
|
|
|
|
|
|
|
const next: CardStyle[] = new Array(total)
|
|
|
|
|
for (let i = 0; i < total; i++) {
|
|
|
|
|
const offset = getWrappedOffset(i, position, total)
|
|
|
|
|
const absOffset = Math.abs(offset)
|
|
|
|
|
const clamped = Math.min(absOffset, 1)
|
|
|
|
|
const scale = 1 - clamped * (1 - sideScale)
|
|
|
|
|
const translateX = offset * stepDistance
|
|
|
|
|
const isVisible = absOffset < 2.1
|
|
|
|
|
|
|
|
|
|
next[i] = {
|
|
|
|
|
opacity: isVisible ? '1' : '0',
|
|
|
|
|
pointerEvents: isVisible ? 'auto' : 'none',
|
|
|
|
|
zIndex: String(100 - Math.round(absOffset * 20)),
|
|
|
|
|
transform: `translate3d(calc(-50% + ${translateX.toFixed(2)}px), 0, 0) scale(${scale.toFixed(4)})`,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
cardStyles.value = next
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function startSpringLoop() {
|
|
|
|
|
if (appearance.isReducedMotion) {
|
|
|
|
|
position = target
|
|
|
|
|
velocity = 0
|
|
|
|
|
render()
|
|
|
|
|
rafId = null
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (rafId != null) cancelAnimationFrame(rafId)
|
|
|
|
|
lastFrameTime = performance.now()
|
|
|
|
|
|
|
|
|
|
const tick = (now: number) => {
|
|
|
|
|
const dt = Math.min((now - lastFrameTime) / 1000, 0.064)
|
|
|
|
|
lastFrameTime = now
|
|
|
|
|
|
|
|
|
|
const displacement = position - target
|
|
|
|
|
const springForce = -props.stiffness * displacement
|
|
|
|
|
const dampingForce = -props.damping * velocity
|
|
|
|
|
const acceleration = springForce + dampingForce
|
|
|
|
|
|
|
|
|
|
velocity += acceleration * dt
|
|
|
|
|
position += velocity * dt
|
|
|
|
|
render()
|
|
|
|
|
|
|
|
|
|
if (Math.abs(velocity) < 0.002 && Math.abs(target - position) < 0.001) {
|
|
|
|
|
position = target
|
|
|
|
|
velocity = 0
|
|
|
|
|
render()
|
|
|
|
|
rafId = null
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
rafId = requestAnimationFrame(tick)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
rafId = requestAnimationFrame(tick)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function onPointerDown(e: PointerEvent) {
|
|
|
|
|
if (cards.value.length < 2 || pointerId !== null) return
|
|
|
|
|
if (e.button !== undefined && e.button !== 0) return
|
|
|
|
|
const el = viewport.value
|
|
|
|
|
if (!el) return
|
|
|
|
|
|
|
|
|
|
pointerId = e.pointerId
|
|
|
|
|
isDragging = true
|
|
|
|
|
dragging.value = true
|
|
|
|
|
stopAutoplay()
|
|
|
|
|
|
|
|
|
|
if (rafId != null) {
|
|
|
|
|
cancelAnimationFrame(rafId)
|
|
|
|
|
rafId = null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
startX = e.clientX
|
|
|
|
|
lastX = e.clientX
|
|
|
|
|
startPosition = position
|
|
|
|
|
velocity = 0
|
|
|
|
|
dragVelocity = 0
|
|
|
|
|
totalDragDistance = 0
|
|
|
|
|
lastTime = performance.now()
|
|
|
|
|
el.setPointerCapture?.(e.pointerId)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function onPointerMove(e: PointerEvent) {
|
|
|
|
|
if (!isDragging || e.pointerId !== pointerId) return
|
|
|
|
|
|
|
|
|
|
const now = performance.now()
|
|
|
|
|
const dt = Math.max(1, now - lastTime)
|
|
|
|
|
const dx = e.clientX - startX
|
|
|
|
|
const stepDx = e.clientX - lastX
|
|
|
|
|
|
|
|
|
|
totalDragDistance = Math.abs(dx)
|
|
|
|
|
|
|
|
|
|
const cardWidth = (stage.value?.clientWidth ?? 0) * CARD_WIDTH_RATIO || 320
|
|
|
|
|
position = startPosition - dx / cardWidth
|
|
|
|
|
dragVelocity = -(stepDx / cardWidth) / (dt / 1000)
|
|
|
|
|
|
|
|
|
|
lastX = e.clientX
|
|
|
|
|
lastTime = now
|
|
|
|
|
render()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function onPointerUp(e: PointerEvent) {
|
|
|
|
|
if (!isDragging || e.pointerId !== pointerId) return
|
|
|
|
|
isDragging = false
|
|
|
|
|
dragging.value = false
|
|
|
|
|
pointerId = null
|
|
|
|
|
|
|
|
|
|
const total = cards.value.length
|
|
|
|
|
|
|
|
|
|
// Tap a side card → spring to it
|
|
|
|
|
if (totalDragDistance < 6 && e.target instanceof Element) {
|
|
|
|
|
const clicked = e.target.closest('.carousel__card') as HTMLElement | null
|
|
|
|
|
if (clicked) {
|
|
|
|
|
const cardIdx = Number(clicked.dataset.index)
|
|
|
|
|
if (!Number.isNaN(cardIdx)) {
|
|
|
|
|
const offset = getWrappedOffset(cardIdx, position, total)
|
|
|
|
|
if (Math.abs(offset) > 0.25) {
|
|
|
|
|
target = Math.round(position + offset)
|
|
|
|
|
startSpringLoop()
|
|
|
|
|
notifyChange()
|
|
|
|
|
if (props.autoplay) startAutoplay()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
velocity = dragVelocity
|
|
|
|
|
const projected = position + velocity * 0.16
|
|
|
|
|
target = Math.round(projected)
|
|
|
|
|
startSpringLoop()
|
|
|
|
|
notifyChange()
|
|
|
|
|
if (props.autoplay) startAutoplay()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function goTo(baseIndex: number) {
|
|
|
|
|
const len = props.slides.filter((s) => s.image).length
|
|
|
|
|
if (len < 2) return
|
|
|
|
|
const currentBase = getActiveIndex()
|
|
|
|
|
let diff = baseIndex - currentBase
|
|
|
|
|
if (diff > len / 2) diff -= len
|
|
|
|
|
if (diff < -len / 2) diff += len
|
|
|
|
|
target = Math.round(target) + diff
|
|
|
|
|
startSpringLoop()
|
|
|
|
|
notifyChange()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** False while the home tab is keep-alive-cached off-screen. */
|
|
|
|
|
let pageActive = true
|
|
|
|
|
|
|
|
|
|
function startAutoplay() {
|
|
|
|
|
stopAutoplay()
|
|
|
|
|
if (!pageActive) return
|
|
|
|
|
if (!props.autoplay || appearance.isReducedMotion || props.slides.length < 2) return
|
|
|
|
|
autoplayTimer = setInterval(() => {
|
|
|
|
|
if (!isDragging && pageActive) {
|
|
|
|
|
target = Math.round(target) + 1
|
|
|
|
|
startSpringLoop()
|
|
|
|
|
notifyChange()
|
|
|
|
|
}
|
|
|
|
|
}, props.autoplayInterval)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function stopAutoplay() {
|
|
|
|
|
if (autoplayTimer) {
|
|
|
|
|
clearInterval(autoplayTimer)
|
|
|
|
|
autoplayTimer = undefined
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Pause motion while cached off-screen so return doesn't catch up many slides. */
|
|
|
|
|
function pauseForLeave() {
|
|
|
|
|
pageActive = false
|
|
|
|
|
stopAutoplay()
|
|
|
|
|
if (rafId != null) {
|
|
|
|
|
cancelAnimationFrame(rafId)
|
|
|
|
|
rafId = null
|
|
|
|
|
}
|
|
|
|
|
position = target
|
|
|
|
|
velocity = 0
|
|
|
|
|
isDragging = false
|
|
|
|
|
dragging.value = false
|
|
|
|
|
render()
|
|
|
|
|
notifyChange()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resumeOnEnter() {
|
|
|
|
|
pageActive = true
|
|
|
|
|
render()
|
|
|
|
|
if (props.autoplay) startAutoplay()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resetToStart() {
|
|
|
|
|
position = 0
|
|
|
|
|
target = 0
|
|
|
|
|
velocity = 0
|
|
|
|
|
notifyChange()
|
|
|
|
|
render()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
pageActive = true
|
|
|
|
|
resetToStart()
|
|
|
|
|
if (stage.value && typeof ResizeObserver !== 'undefined') {
|
|
|
|
|
resizeObserver = new ResizeObserver(() => render())
|
|
|
|
|
resizeObserver.observe(stage.value)
|
|
|
|
|
}
|
|
|
|
|
if (props.autoplay) startAutoplay()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// HomeView is keep-alive'd — without these, setInterval keeps advancing
|
|
|
|
|
// target while the user is on another tab, then the spring "catches up"
|
|
|
|
|
// through several slides the moment they return.
|
|
|
|
|
onDeactivated(pauseForLeave)
|
|
|
|
|
onActivated(resumeOnEnter)
|
|
|
|
|
|
|
|
|
|
watch(
|
|
|
|
|
() => props.slides,
|
|
|
|
|
() => {
|
|
|
|
|
resetToStart()
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
watch(
|
|
|
|
|
() => [props.sideScale, props.gap, props.stiffness, props.damping] as const,
|
|
|
|
|
() => render(),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
watch(
|
|
|
|
|
() => props.autoplay,
|
|
|
|
|
(on) => {
|
|
|
|
|
if (on) startAutoplay()
|
|
|
|
|
else stopAutoplay()
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
onBeforeUnmount(() => {
|
|
|
|
|
pauseForLeave()
|
|
|
|
|
resizeObserver?.disconnect()
|
|
|
|
|
if (pointerId !== null && viewport.value) {
|
|
|
|
|
try {
|
|
|
|
|
viewport.value.releasePointerCapture(pointerId)
|
|
|
|
|
} catch {
|
|
|
|
|
/* detached */
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<style scoped lang="less">
|
|
|
|
|
// Break out of Container `px-[16px]` so side peeks sit at the screen edge
|
|
|
|
|
// instead of leaving empty gutters inside the padded column.
|
|
|
|
|
.carousel {
|
|
|
|
|
width: calc(100% + 32px);
|
|
|
|
|
margin-inline: -16px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.carousel__viewport {
|
|
|
|
|
position: relative;
|
|
|
|
|
width: 100%;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
touch-action: pan-y;
|
|
|
|
|
user-select: none;
|
|
|
|
|
-webkit-user-select: none;
|
|
|
|
|
cursor: grab;
|
|
|
|
|
// Top: room for bottom-anchored scale. Bottom: room for card drop-shadow
|
|
|
|
|
// so overflow:hidden does not shear the soft edge.
|
|
|
|
|
padding: 10px 0 28px;
|
|
|
|
|
direction: ltr;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.carousel__viewport.is-dragging {
|
|
|
|
|
cursor: grabbing;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Same aspect as ShowcaseSlider; cards ~92% wide so neighbours hug the center.
|
|
|
|
|
.carousel__stage {
|
|
|
|
|
position: relative;
|
|
|
|
|
width: 100%;
|
|
|
|
|
aspect-ratio: 320 / 208;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.carousel__card {
|
|
|
|
|
position: absolute;
|
|
|
|
|
bottom: 0;
|
|
|
|
|
left: 50%;
|
|
|
|
|
width: 92%;
|
|
|
|
|
height: 100%;
|
|
|
|
|
transform-origin: center bottom;
|
|
|
|
|
will-change: transform;
|
|
|
|
|
border-radius: var(--carousel-radius, 22px);
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
background: @hairlineSoft;
|
|
|
|
|
box-shadow: 0 14px 28px -12px rgba(20, 22, 26, 0.28);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.carousel__art {
|
|
|
|
|
display: block;
|
|
|
|
|
width: 100%;
|
|
|
|
|
height: 100%;
|
|
|
|
|
object-fit: cover;
|
|
|
|
|
pointer-events: none;
|
|
|
|
|
-webkit-user-drag: none;
|
|
|
|
|
user-select: none;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.carousel__dots {
|
|
|
|
|
display: flex;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
gap: 6px;
|
|
|
|
|
// Re-align with the padded column after the bleed; extra air under the stage.
|
|
|
|
|
padding: 4px 16px 8px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.carousel__dot {
|
|
|
|
|
width: 6px;
|
|
|
|
|
height: 6px;
|
|
|
|
|
padding: 0;
|
|
|
|
|
border: 0;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
background: @hairline;
|
|
|
|
|
transition:
|
|
|
|
|
width 0.42s cubic-bezier(0.16, 0.86, 0.3, 1),
|
|
|
|
|
background 0.3s ease;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.carousel__dot.is-active {
|
|
|
|
|
width: 20px;
|
|
|
|
|
background: @wine;
|
|
|
|
|
}
|
|
|
|
|
</style>
|