|
|
|
@@ -0,0 +1,541 @@
|
|
|
|
|
<!--
|
|
|
|
|
The product detail overlay, opened as a container transform out of the card
|
|
|
|
|
that was tapped — the replacement for the full-height bottom Drawer, which
|
|
|
|
|
slid a blank sheet up from off-screen and then jumped when the detail fetch
|
|
|
|
|
landed and the page finally rendered.
|
|
|
|
|
|
|
|
|
|
Three layers move, and only one of them is ever repainted:
|
|
|
|
|
- the SHAPE: a plain fill in the card's colour, revealed by animating
|
|
|
|
|
`clip-path: inset(... round r)` from the card's exact rect and corner
|
|
|
|
|
radius out to the whole screen. It holds no content, so repainting it
|
|
|
|
|
per frame costs next to nothing.
|
|
|
|
|
- the PAGE (SingleProduct, mounted fully formed — it is seeded with the
|
|
|
|
|
card's own ProductSummary, so there is nothing to wait for): it grows out
|
|
|
|
|
of the card's centre and fades in, by transform and opacity only, so the
|
|
|
|
|
compositor moves it without re-rasterising it.
|
|
|
|
|
- the GHOST: one fixed <img> of the card's photo, flown by transform from
|
|
|
|
|
where the card drew it to where the gallery draws it.
|
|
|
|
|
An earlier version clipped the page itself; that forced a full-screen repaint
|
|
|
|
|
of the whole page (photos, blurred buttons and all) on every frame and lagged
|
|
|
|
|
on phones. Backdrop blurs inside the page are also switched off while
|
|
|
|
|
anything is moving, for the same reason, and the scroll lock and the page's
|
|
|
|
|
background refresh both wait until the motion is over.
|
|
|
|
|
|
|
|
|
|
Closing plays the morph back into the card if it is still on screen, and
|
|
|
|
|
falls back to a short drop-and-fade if it is not (the list was scrolled or
|
|
|
|
|
re-rendered). `prefers-reduced-motion` gets a plain crossfade.
|
|
|
|
|
|
|
|
|
|
Mounted once, in App.vue. Opened through composables/useProductMorph.ts.
|
|
|
|
|
-->
|
|
|
|
|
<template>
|
|
|
|
|
<Teleport to="body">
|
|
|
|
|
<div v-if="current" class="pm" :class="{ 'is-moving': moving, 'is-closing': closing }" dir="rtl">
|
|
|
|
|
<div ref="veil" class="pm__veil" aria-hidden="true"></div>
|
|
|
|
|
<div ref="shape" class="pm__shape" aria-hidden="true"></div>
|
|
|
|
|
|
|
|
|
|
<div ref="sheet" class="pm__sheet no-scrollbar">
|
|
|
|
|
<SingleProduct
|
|
|
|
|
:id="current.product.id"
|
|
|
|
|
:key="current.product.id"
|
|
|
|
|
:initial="current.product"
|
|
|
|
|
@closeDrawer="close"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<img v-if="ghostSrc" ref="ghost" class="pm__ghost" :src="ghostSrc" alt="" aria-hidden="true" decoding="sync" />
|
|
|
|
|
</div>
|
|
|
|
|
</Teleport>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
import { nextTick, onBeforeUnmount, ref, shallowRef, watch } from 'vue'
|
|
|
|
|
import { useRoute } from 'vue-router'
|
|
|
|
|
import SingleProduct from './SingleProduct.vue'
|
|
|
|
|
import { productMorph, type ProductMorphRequest } from '../../composables/useProductMorph'
|
|
|
|
|
|
|
|
|
|
const route = useRoute()
|
|
|
|
|
|
|
|
|
|
const current = shallowRef<ProductMorphRequest | null>(null)
|
|
|
|
|
const ghostSrc = ref<string | null>(null)
|
|
|
|
|
const moving = ref(false)
|
|
|
|
|
const closing = ref(false)
|
|
|
|
|
|
|
|
|
|
const veil = ref<HTMLElement | null>(null)
|
|
|
|
|
const shape = ref<HTMLElement | null>(null)
|
|
|
|
|
const sheet = ref<HTMLElement | null>(null)
|
|
|
|
|
const ghost = ref<HTMLImageElement | null>(null)
|
|
|
|
|
|
|
|
|
|
// ── Timing ────────────────────────────────────────────────────
|
|
|
|
|
// Opening rides a lightly-damped spring (≈1% overshoot): quick off the mark —
|
|
|
|
|
// most of the travel is done in the first ~40% — with a short, soft settle.
|
|
|
|
|
// Expressed as a CSS `linear()` easing so it still runs on the compositor like
|
|
|
|
|
// any other easing; browsers without `linear()` get an ease-out-quint, which
|
|
|
|
|
// reads almost the same minus the settle. Closing is shorter and never
|
|
|
|
|
// overshoots — going back should feel like letting go, not a second entrance.
|
|
|
|
|
const OPEN_MS = 420
|
|
|
|
|
const CLOSE_MS = 300
|
|
|
|
|
const FALLBACK_OPEN_MS = 300
|
|
|
|
|
const FALLBACK_CLOSE_MS = 220
|
|
|
|
|
|
|
|
|
|
function springEasing(zeta = 0.82, span = 7, samples = 32): string {
|
|
|
|
|
const wd = Math.sqrt(1 - zeta * zeta)
|
|
|
|
|
const points: string[] = []
|
|
|
|
|
for (let i = 0; i <= samples; i++) {
|
|
|
|
|
const t = (i / samples) * span
|
|
|
|
|
const x = i === samples ? 1 : 1 - Math.exp(-zeta * t) * (Math.cos(wd * t) + (zeta / wd) * Math.sin(wd * t))
|
|
|
|
|
points.push(String(+x.toFixed(4)))
|
|
|
|
|
}
|
|
|
|
|
return `linear(${points.join(', ')})`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const supportsLinear = typeof CSS !== 'undefined' && CSS.supports('animation-timing-function', 'linear(0, 1)')
|
|
|
|
|
const EASE_OPEN = supportsLinear ? springEasing() : 'cubic-bezier(0.22, 1, 0.36, 1)'
|
|
|
|
|
const EASE_CLOSE = 'cubic-bezier(0.32, 0.72, 0, 1)'
|
|
|
|
|
const EASE_OUT = 'cubic-bezier(0.22, 1, 0.36, 1)'
|
|
|
|
|
|
|
|
|
|
// How far the page starts shrunk into the card it grows out of.
|
|
|
|
|
const PAGE_FROM_SCALE = 0.9
|
|
|
|
|
|
|
|
|
|
// The in-app "fast & light" switch (html.motion-reduced, see
|
|
|
|
|
// stores/appearanceStore.ts) or the OS accessibility setting.
|
|
|
|
|
const reducedMotion = () =>
|
|
|
|
|
document.documentElement.classList.contains('motion-reduced') ||
|
|
|
|
|
(typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches)
|
|
|
|
|
|
|
|
|
|
// ── Geometry ──────────────────────────────────────────────────
|
|
|
|
|
interface Box {
|
|
|
|
|
x: number
|
|
|
|
|
y: number
|
|
|
|
|
w: number
|
|
|
|
|
h: number
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Where an <img> actually draws its picture: its box minus padding, then the
|
|
|
|
|
// object-fit: contain letterboxing every product photo in this app uses. The
|
|
|
|
|
// ghost has to start and end on the drawn picture, not on the element's box,
|
|
|
|
|
// or it would visibly snap at both ends.
|
|
|
|
|
function drawnBox(img: HTMLImageElement, nw: number, nh: number): Box {
|
|
|
|
|
const r = img.getBoundingClientRect()
|
|
|
|
|
const cs = getComputedStyle(img)
|
|
|
|
|
const pl = parseFloat(cs.paddingLeft) || 0
|
|
|
|
|
const pr = parseFloat(cs.paddingRight) || 0
|
|
|
|
|
const pt = parseFloat(cs.paddingTop) || 0
|
|
|
|
|
const pb = parseFloat(cs.paddingBottom) || 0
|
|
|
|
|
const x = r.left + pl
|
|
|
|
|
const y = r.top + pt
|
|
|
|
|
const w = r.width - pl - pr
|
|
|
|
|
const h = r.height - pt - pb
|
|
|
|
|
if (cs.objectFit !== 'contain' && cs.objectFit !== 'scale-down') return { x, y, w, h }
|
|
|
|
|
let s = Math.min(w / nw, h / nh)
|
|
|
|
|
if (cs.objectFit === 'scale-down') s = Math.min(s, 1)
|
|
|
|
|
return { x: x + (w - nw * s) / 2, y: y + (h - nh * s) / 2, w: nw * s, h: nh * s }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function insetOf(r: DOMRect, radius: string): string {
|
|
|
|
|
const vw = document.documentElement.clientWidth
|
|
|
|
|
const vh = window.innerHeight
|
|
|
|
|
return `inset(${r.top}px ${vw - r.right}px ${vh - r.bottom}px ${r.left}px round ${radius})`
|
|
|
|
|
}
|
|
|
|
|
const FULL = 'inset(0px 0px 0px 0px round 0px)'
|
|
|
|
|
|
|
|
|
|
function onScreen(r: DOMRect): boolean {
|
|
|
|
|
return r.width > 0 && r.height > 0 && r.bottom > 0 && r.top < window.innerHeight
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function loaded(img: HTMLImageElement | null | undefined): img is HTMLImageElement {
|
|
|
|
|
return !!img && img.complete && img.naturalWidth > 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function sameSrc(a: HTMLImageElement, b: HTMLImageElement): boolean {
|
|
|
|
|
return (a.currentSrc || a.src) === (b.currentSrc || b.src)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The gallery image currently showing — the slider keeps every slide in its
|
|
|
|
|
// track, so pick the one whose centre sits inside the stage.
|
|
|
|
|
function galleryImage(): HTMLImageElement | null {
|
|
|
|
|
const stage = sheet.value?.querySelector<HTMLElement>('.pd__stage')
|
|
|
|
|
if (!stage) return null
|
|
|
|
|
const s = stage.getBoundingClientRect()
|
|
|
|
|
const imgs = stage.querySelectorAll<HTMLImageElement>('img')
|
|
|
|
|
for (const img of imgs) {
|
|
|
|
|
const r = img.getBoundingClientRect()
|
|
|
|
|
const cx = r.left + r.width / 2
|
|
|
|
|
if (cx > s.left && cx < s.right) return img
|
|
|
|
|
}
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function originImage(origin: HTMLElement | null): HTMLImageElement | null {
|
|
|
|
|
return origin?.querySelector<HTMLImageElement>('img') ?? null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Colours and corner radius the shape morphs between: the card's own fill
|
|
|
|
|
// (falling back to the page's when the card is transparent) and the page's.
|
|
|
|
|
function palette(origin: HTMLElement) {
|
|
|
|
|
const pd = sheet.value?.querySelector<HTMLElement>('.pd')
|
|
|
|
|
const pageBg = (pd && getComputedStyle(pd).backgroundColor) || '#fff'
|
|
|
|
|
const cs = getComputedStyle(origin)
|
|
|
|
|
const cardBg = cs.backgroundColor === 'rgba(0, 0, 0, 0)' ? pageBg : cs.backgroundColor
|
|
|
|
|
return { pageBg, cardBg, radius: cs.borderRadius || '0px' }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function centreOf(r: DOMRect): string {
|
|
|
|
|
return `${r.left + r.width / 2}px ${r.top + r.height / 2}px`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ghostFrames(from: Box, to: Box): Keyframe[] {
|
|
|
|
|
const t = (b: Box) => `translate(${b.x - to.x}px, ${b.y - to.y}px) scale(${b.w / to.w}, ${b.h / to.h})`
|
|
|
|
|
return [{ transform: t(from) }, { transform: t(to) }]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function placeGhost(el: HTMLImageElement, at: Box) {
|
|
|
|
|
el.style.left = `${at.x}px`
|
|
|
|
|
el.style.top = `${at.y}px`
|
|
|
|
|
el.style.width = `${at.w}px`
|
|
|
|
|
el.style.height = `${at.h}px`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Run bookkeeping ───────────────────────────────────────────
|
|
|
|
|
// Each open/close takes a new generation; a run that has been superseded by a
|
|
|
|
|
// later one skips its own teardown.
|
|
|
|
|
let gen = 0
|
|
|
|
|
let running: Animation[] = []
|
|
|
|
|
let phase: 'idle' | 'opening' | 'open' | 'closing' = 'idle'
|
|
|
|
|
let hiddenImgs: HTMLElement[] = []
|
|
|
|
|
|
|
|
|
|
function hide(el: HTMLElement | null) {
|
|
|
|
|
if (!el) return
|
|
|
|
|
el.style.opacity = '0'
|
|
|
|
|
hiddenImgs.push(el)
|
|
|
|
|
}
|
|
|
|
|
function unhideAll() {
|
|
|
|
|
hiddenImgs.forEach((el) => (el.style.opacity = ''))
|
|
|
|
|
hiddenImgs = []
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function stopRunning() {
|
|
|
|
|
running.forEach((a) => a.cancel())
|
|
|
|
|
running = []
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function settled(anims: Animation[]) {
|
|
|
|
|
running = anims
|
|
|
|
|
await Promise.all(anims.map((a) => a.finished.catch(() => undefined)))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The page behind must not scroll while the overlay is up. Set on <html>, not
|
|
|
|
|
// <body>: SingleProduct's own image preview locks and unlocks <body>, and must
|
|
|
|
|
// not be able to release this one. Only applied once the overlay is fully
|
|
|
|
|
// open — toggling overflow on the root re-lays-out the page underneath, which
|
|
|
|
|
// is a cost the first frame of the morph should not pay.
|
|
|
|
|
function lockScroll(on: boolean) {
|
|
|
|
|
document.documentElement.style.overflow = on ? 'hidden' : ''
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Open ──────────────────────────────────────────────────────
|
|
|
|
|
async function open(req: ProductMorphRequest) {
|
|
|
|
|
const my = ++gen
|
|
|
|
|
stopRunning()
|
|
|
|
|
unhideAll()
|
|
|
|
|
closing.value = false
|
|
|
|
|
moving.value = true
|
|
|
|
|
phase = 'opening'
|
|
|
|
|
|
|
|
|
|
const originImg = originImage(req.origin)
|
|
|
|
|
const originRect = req.origin?.getBoundingClientRect()
|
|
|
|
|
const canMorph = !reducedMotion() && !!req.origin && !!originRect && onScreen(originRect)
|
|
|
|
|
ghostSrc.value = canMorph && loaded(originImg) ? originImg.currentSrc || originImg.src : null
|
|
|
|
|
|
|
|
|
|
current.value = req
|
|
|
|
|
// Mounted, laid out and measured inside the same task as the tap — the first
|
|
|
|
|
// painted frame is already the first frame of the morph.
|
|
|
|
|
await nextTick()
|
|
|
|
|
if (my !== gen || !sheet.value || !veil.value) return
|
|
|
|
|
|
|
|
|
|
let anims: Animation[]
|
|
|
|
|
if (canMorph && originRect && req.origin) {
|
|
|
|
|
anims = morphOpen(req.origin, originRect, originImg)
|
|
|
|
|
} else if (reducedMotion()) {
|
|
|
|
|
anims = [
|
|
|
|
|
sheet.value.animate({ opacity: [0, 1] }, { duration: 160, easing: 'ease-out', fill: 'both' }),
|
|
|
|
|
veil.value.animate({ opacity: [0, 1] }, { duration: 160, easing: 'ease-out', fill: 'both' }),
|
|
|
|
|
]
|
|
|
|
|
} else {
|
|
|
|
|
anims = [
|
|
|
|
|
sheet.value.animate(
|
|
|
|
|
{ transform: ['translateY(6%)', 'none'], opacity: [0, 1] },
|
|
|
|
|
{ duration: FALLBACK_OPEN_MS, easing: EASE_OUT, fill: 'both' },
|
|
|
|
|
),
|
|
|
|
|
veil.value.animate({ opacity: [0, 1] }, { duration: FALLBACK_OPEN_MS, easing: 'ease-out', fill: 'both' }),
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await settled(anims)
|
|
|
|
|
if (my !== gen) return
|
|
|
|
|
// The end state of every animation equals the resting CSS, so dropping them
|
|
|
|
|
// changes nothing on screen — it just leaves no clip-path or composited
|
|
|
|
|
// layers behind while the page is being read and scrolled.
|
|
|
|
|
stopRunning()
|
|
|
|
|
unhideAll()
|
|
|
|
|
ghostSrc.value = null
|
|
|
|
|
moving.value = false
|
|
|
|
|
phase = 'open'
|
|
|
|
|
lockScroll(true)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function morphOpen(origin: HTMLElement, originRect: DOMRect, originImg: HTMLImageElement | null): Animation[] {
|
|
|
|
|
const sheetEl = sheet.value!
|
|
|
|
|
const shapeEl = shape.value!
|
|
|
|
|
const veilEl = veil.value!
|
|
|
|
|
const { pageBg, cardBg, radius } = palette(origin)
|
|
|
|
|
|
|
|
|
|
// Measure everything BEFORE any animation starts: once the page is scaled,
|
|
|
|
|
// the gallery's rect would come back scaled too.
|
|
|
|
|
const targetImg = galleryImage()
|
|
|
|
|
const ghostEl = ghost.value
|
|
|
|
|
let flight: { from: Box; to: Box } | null = null
|
|
|
|
|
if (ghostEl && loaded(originImg) && targetImg && sameSrc(originImg, targetImg)) {
|
|
|
|
|
const nw = originImg.naturalWidth
|
|
|
|
|
const nh = originImg.naturalHeight
|
|
|
|
|
const to = drawnBox(targetImg, nw, nh)
|
|
|
|
|
if (to.w > 0 && to.h > 0) flight = { from: drawnBox(originImg, nw, nh), to }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
sheetEl.style.transformOrigin = centreOf(originRect)
|
|
|
|
|
|
|
|
|
|
const anims: Animation[] = [
|
|
|
|
|
shapeEl.animate(
|
|
|
|
|
{ clipPath: [insetOf(originRect, radius), FULL], backgroundColor: [cardBg, pageBg] },
|
|
|
|
|
{ duration: OPEN_MS, easing: EASE_OPEN, fill: 'both' },
|
|
|
|
|
),
|
|
|
|
|
veilEl.animate({ opacity: [0, 1] }, { duration: OPEN_MS * 0.6, easing: 'ease-out', fill: 'both' }),
|
|
|
|
|
// The page grows out of the card's centre alongside the shape, and fades
|
|
|
|
|
// in a beat behind it — the first frames read as the card lifting, then
|
|
|
|
|
// the page arriving inside it.
|
|
|
|
|
sheetEl.animate(
|
|
|
|
|
{ transform: [`scale(${PAGE_FROM_SCALE})`, 'none'] },
|
|
|
|
|
{ duration: OPEN_MS, easing: EASE_OPEN, fill: 'both' },
|
|
|
|
|
),
|
|
|
|
|
sheetEl.animate(
|
|
|
|
|
{ opacity: [0, 1] },
|
|
|
|
|
{ duration: OPEN_MS * 0.55, delay: OPEN_MS * 0.1, easing: 'cubic-bezier(0.3, 0, 0.2, 1)', fill: 'both' },
|
|
|
|
|
),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
if (ghostEl && flight && targetImg) {
|
|
|
|
|
placeGhost(ghostEl, flight.to)
|
|
|
|
|
hide(targetImg)
|
|
|
|
|
anims.push(ghostEl.animate(ghostFrames(flight.from, flight.to), { duration: OPEN_MS, easing: EASE_OPEN, fill: 'both' }))
|
|
|
|
|
} else {
|
|
|
|
|
ghostSrc.value = null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return anims
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Close ─────────────────────────────────────────────────────
|
|
|
|
|
async function close() {
|
|
|
|
|
if (!current.value || phase === 'closing') return
|
|
|
|
|
const my = ++gen
|
|
|
|
|
const wasOpening = phase === 'opening'
|
|
|
|
|
phase = 'closing'
|
|
|
|
|
closing.value = true
|
|
|
|
|
moving.value = true
|
|
|
|
|
|
|
|
|
|
let anims: Animation[]
|
|
|
|
|
if (wasOpening && running.length) {
|
|
|
|
|
// Closed before it finished opening: play the same motion back from
|
|
|
|
|
// wherever it got to, rather than jumping to open and morphing from there.
|
|
|
|
|
running.forEach((a) => a.reverse())
|
|
|
|
|
anims = running
|
|
|
|
|
} else {
|
|
|
|
|
stopRunning()
|
|
|
|
|
unhideAll()
|
|
|
|
|
anims = await closeAnimations()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await settled(anims)
|
|
|
|
|
if (my !== gen) return
|
|
|
|
|
teardown()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function closeAnimations(): Promise<Animation[]> {
|
|
|
|
|
const sheetEl = sheet.value
|
|
|
|
|
const shapeEl = shape.value
|
|
|
|
|
const veilEl = veil.value
|
|
|
|
|
if (!sheetEl || !shapeEl || !veilEl) return []
|
|
|
|
|
|
|
|
|
|
if (reducedMotion()) {
|
|
|
|
|
return [
|
|
|
|
|
sheetEl.animate({ opacity: [1, 0] }, { duration: 140, easing: 'ease-in', fill: 'forwards' }),
|
|
|
|
|
veilEl.animate({ opacity: [1, 0] }, { duration: 140, easing: 'ease-in', fill: 'forwards' }),
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const origin = current.value?.origin ?? null
|
|
|
|
|
const originRect = origin?.isConnected ? origin.getBoundingClientRect() : null
|
|
|
|
|
|
|
|
|
|
if (!origin || !originRect || !onScreen(originRect)) {
|
|
|
|
|
return [
|
|
|
|
|
sheetEl.animate(
|
|
|
|
|
{ transform: ['none', 'translateY(6%)'], opacity: [1, 0] },
|
|
|
|
|
{ duration: FALLBACK_CLOSE_MS, easing: 'cubic-bezier(0.4, 0, 1, 1)', fill: 'forwards' },
|
|
|
|
|
),
|
|
|
|
|
veilEl.animate({ opacity: [1, 0] }, { duration: FALLBACK_CLOSE_MS, easing: 'ease-in', fill: 'forwards' }),
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const { pageBg, cardBg, radius } = palette(origin)
|
|
|
|
|
|
|
|
|
|
// Measured before anything moves, for the same reason as on the way in.
|
|
|
|
|
const originImg = originImage(origin)
|
|
|
|
|
const fromImg = galleryImage()
|
|
|
|
|
let flight: { from: Box; to: Box } | null = null
|
|
|
|
|
if (loaded(originImg) && fromImg && sameSrc(originImg, fromImg) && onScreen(fromImg.getBoundingClientRect())) {
|
|
|
|
|
const nw = originImg.naturalWidth
|
|
|
|
|
const nh = originImg.naturalHeight
|
|
|
|
|
const from = drawnBox(fromImg, nw, nh)
|
|
|
|
|
if (from.w > 0 && from.h > 0) flight = { from, to: drawnBox(originImg, nw, nh) }
|
|
|
|
|
}
|
|
|
|
|
if (flight && originImg) {
|
|
|
|
|
ghostSrc.value = originImg.currentSrc || originImg.src
|
|
|
|
|
await nextTick()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
sheetEl.style.transformOrigin = centreOf(originRect)
|
|
|
|
|
|
|
|
|
|
const anims: Animation[] = [
|
|
|
|
|
// The shape fades over its last stretch so the real card — title and all —
|
|
|
|
|
// crossfades in under it instead of popping in after it lands.
|
|
|
|
|
shapeEl.animate(
|
|
|
|
|
[
|
|
|
|
|
{ clipPath: FULL, backgroundColor: pageBg, opacity: 1 },
|
|
|
|
|
{ opacity: 1, offset: 0.7 },
|
|
|
|
|
{ clipPath: insetOf(originRect, radius), backgroundColor: cardBg, opacity: 0 },
|
|
|
|
|
],
|
|
|
|
|
{ duration: CLOSE_MS, easing: EASE_CLOSE, fill: 'forwards' },
|
|
|
|
|
),
|
|
|
|
|
sheetEl.animate(
|
|
|
|
|
{ transform: ['none', `scale(${PAGE_FROM_SCALE})`] },
|
|
|
|
|
{ duration: CLOSE_MS, easing: EASE_CLOSE, fill: 'forwards' },
|
|
|
|
|
),
|
|
|
|
|
sheetEl.animate({ opacity: [1, 0] }, { duration: CLOSE_MS * 0.45, easing: 'ease-in', fill: 'forwards' }),
|
|
|
|
|
veilEl.animate({ opacity: [1, 0] }, { duration: CLOSE_MS, easing: 'ease-out', fill: 'forwards' }),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
// The photo flies home only if the gallery is still on screen and showing
|
|
|
|
|
// the same picture the card does; otherwise the shape alone carries it back.
|
|
|
|
|
const ghostEl = ghost.value
|
|
|
|
|
if (ghostEl && flight && fromImg) {
|
|
|
|
|
// Based on the gallery box this time, so the frames run identity → card.
|
|
|
|
|
placeGhost(ghostEl, flight.from)
|
|
|
|
|
hide(fromImg)
|
|
|
|
|
anims.push(
|
|
|
|
|
ghostEl.animate(ghostFrames(flight.to, flight.from).reverse(), {
|
|
|
|
|
duration: CLOSE_MS,
|
|
|
|
|
easing: EASE_CLOSE,
|
|
|
|
|
fill: 'forwards',
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return anims
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function teardown() {
|
|
|
|
|
stopRunning()
|
|
|
|
|
unhideAll()
|
|
|
|
|
ghostSrc.value = null
|
|
|
|
|
moving.value = false
|
|
|
|
|
closing.value = false
|
|
|
|
|
phase = 'idle'
|
|
|
|
|
current.value = null
|
|
|
|
|
lockScroll(false)
|
|
|
|
|
if (productMorph.value) productMorph.value = null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Wiring ────────────────────────────────────────────────────
|
|
|
|
|
watch(productMorph, (req) => {
|
|
|
|
|
if (req) open(req)
|
|
|
|
|
else if (current.value) close()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Anything inside the page that navigates (the cart button, the category pill)
|
|
|
|
|
// leaves the overlay behind; it goes with the screen it was opened over.
|
|
|
|
|
watch(
|
|
|
|
|
() => route.fullPath,
|
|
|
|
|
() => {
|
|
|
|
|
if (!current.value) return
|
|
|
|
|
++gen
|
|
|
|
|
teardown()
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
onBeforeUnmount(() => {
|
|
|
|
|
stopRunning()
|
|
|
|
|
unhideAll()
|
|
|
|
|
lockScroll(false)
|
|
|
|
|
})
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<style scoped lang="less">
|
|
|
|
|
.pm {
|
|
|
|
|
position: fixed;
|
|
|
|
|
inset: 0;
|
|
|
|
|
// Same layer the Drawer it replaces sat on; SingleProduct's own full-screen
|
|
|
|
|
// image preview is 2000 and must stay above this.
|
|
|
|
|
z-index: 1000;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.pm.is-closing {
|
|
|
|
|
pointer-events: none;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.pm__veil {
|
|
|
|
|
position: absolute;
|
|
|
|
|
inset: 0;
|
|
|
|
|
background: rgba(14, 15, 18, 0.32);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Only drawn while something is moving; at rest the page covers it anyway.
|
|
|
|
|
.pm__shape {
|
|
|
|
|
position: absolute;
|
|
|
|
|
inset: 0;
|
|
|
|
|
display: none;
|
|
|
|
|
}
|
|
|
|
|
.pm.is-moving .pm__shape {
|
|
|
|
|
display: block;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The scroller for pages that do not scroll themselves (v1's SingleProduct
|
|
|
|
|
// grows with its content; v2's is its own 100vh scroller and never overflows
|
|
|
|
|
// this). The page's transform is applied HERE rather than on anything inside:
|
|
|
|
|
// this box is already pinned to the viewport, so a `position: fixed` action bar
|
|
|
|
|
// inside the page stays exactly where it is while the transform is running.
|
|
|
|
|
.pm__sheet {
|
|
|
|
|
position: absolute;
|
|
|
|
|
inset: 0;
|
|
|
|
|
overflow-y: auto;
|
|
|
|
|
overflow-x: hidden;
|
|
|
|
|
overscroll-behavior: contain;
|
|
|
|
|
-webkit-overflow-scrolling: touch;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Backdrop blurs are re-computed on every frame their layer moves, and the page
|
|
|
|
|
// has around ten of them (header buttons, action bar…). Their surfaces are
|
|
|
|
|
// already ~90% opaque, so dropping the blur for the length of the motion is
|
|
|
|
|
// invisible — and it is the single biggest cost the moving page had.
|
|
|
|
|
.pm.is-moving :deep(*) {
|
|
|
|
|
backdrop-filter: none !important;
|
|
|
|
|
-webkit-backdrop-filter: none !important;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.pm__ghost {
|
|
|
|
|
position: fixed;
|
|
|
|
|
z-index: 2;
|
|
|
|
|
transform-origin: 0 0;
|
|
|
|
|
object-fit: fill;
|
|
|
|
|
pointer-events: none;
|
|
|
|
|
will-change: transform;
|
|
|
|
|
}
|
|
|
|
|
</style>
|