Feat: page Portfolio avec galerie animée, navigation par scroll/touch/clavier
All checks were successful
Deploy / Deploy to Production (push) Successful in 18s
All checks were successful
Deploy / Deploy to Production (push) Successful in 18s
- Composable useScrollNav partagé entre Expertise et Portfolio (wheel/touch/clavier) - GalleryAnimation : 3 colonnes CSS défilantes infinies avec décalage et delay - Portfolio : golden grid, mockup centré, infos projet, sidebar vignettes navigables - API portfolio.json.php alignée sur blueprint project.yml (catchphrase, images_gallery, mockup, keywords, external_links) - Variable --ease-standard partagée dans variables.css - Alias @composables ajouté dans vite.config.js - Refactor Expertise pour utiliser le composable (comportement identique) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
feb300f76e
commit
0b563b4697
9 changed files with 505 additions and 125 deletions
|
|
@ -1,20 +1,16 @@
|
|||
<script>
|
||||
import { onMount } from 'svelte'
|
||||
import { slides } from '@state/slides.svelte'
|
||||
import { createScrollNav } from '@composables/useScrollNav.svelte.js'
|
||||
|
||||
let { data } = $props()
|
||||
|
||||
// --- Constants ---
|
||||
const SCROLL_LOCK_MS = 650 // ms before next scroll is accepted
|
||||
const WHEEL_DEBOUNCE_MS = 100 // ms window to accumulate wheel delta
|
||||
const WHEEL_THRESHOLD = 25 // accumulated px to trigger navigation
|
||||
const TOUCH_THRESHOLD = 50 // px swipe distance to trigger navigation
|
||||
const PLAY_DELAY_MS = 300 // ms delay before starting playback on slide activation
|
||||
const REV_TARGET_MIN = 0.1 // min revTarget to avoid pausing exactly at videoDuration
|
||||
const PLAY_DELAY_MS = 300 // ms delay before starting playback on slide activation
|
||||
const REV_TARGET_MIN = 0.1 // min revTarget to avoid pausing exactly at videoDuration
|
||||
|
||||
// --- State ---
|
||||
let currentItem = $state(0)
|
||||
let canScroll = $state(true)
|
||||
let isReverse = $state(false)
|
||||
let offsetY = $state(0)
|
||||
let videoDuration = $state(0)
|
||||
|
|
@ -27,11 +23,8 @@
|
|||
let sectionEl = $state(null)
|
||||
|
||||
// --- Plain variables (not reactive, used in event listeners) ---
|
||||
let fwdTarget = null // forward video pause target (seconds)
|
||||
let revTarget = null // reverse video pause target (seconds)
|
||||
// Position in the forward video timeline.
|
||||
// Updated from videoFwd.currentTime when going forward,
|
||||
// and mirrored from videoRev.currentTime when going in reverse.
|
||||
let fwdTarget = null
|
||||
let revTarget = null
|
||||
let currentFwdTime = 0
|
||||
|
||||
// --- Derived ---
|
||||
|
|
@ -39,16 +32,27 @@
|
|||
const items = $derived(data?.items ?? [])
|
||||
const itemCount = $derived(items.length)
|
||||
|
||||
// segmentEnds[i] = timestamp where forward video pauses after reaching item i
|
||||
const segmentEnds = $derived(
|
||||
itemCount > 0 && videoDuration > 0
|
||||
? Array.from({ length: itemCount }, (_, i) => videoDuration * (i + 1) / itemCount)
|
||||
: []
|
||||
)
|
||||
|
||||
// --- Scroll nav composable ---
|
||||
const nav = createScrollNav({
|
||||
isActive: () => isActive,
|
||||
onNavigate: (dir) => {
|
||||
if (segmentEnds.length === 0) return false
|
||||
const prevItem = currentItem
|
||||
const newItem = dir === 'down'
|
||||
? Math.min(prevItem + 1, itemCount - 1)
|
||||
: Math.max(prevItem - 1, 0)
|
||||
if (newItem === prevItem) return false
|
||||
navigate(dir, newItem)
|
||||
},
|
||||
})
|
||||
|
||||
// --- Center active item vertically in viewport ---
|
||||
// Uses offsetTop which is NOT affected by CSS transforms
|
||||
// Requires position:relative on .expertise-text (see CSS below)
|
||||
function computeOffset() {
|
||||
if (!textContainer || !itemEls[currentItem]) return
|
||||
const wh = window.innerHeight
|
||||
|
|
@ -58,8 +62,6 @@
|
|||
}
|
||||
|
||||
// --- Video control helpers ---
|
||||
|
||||
// Capture current position from whichever video is playing, then stop both.
|
||||
function stopActiveVideo() {
|
||||
if (videoFwd && !videoFwd.paused) {
|
||||
currentFwdTime = videoFwd.currentTime
|
||||
|
|
@ -73,8 +75,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
// Seek videoFwd to currentFwdTime and play until targetTime.
|
||||
// If currently showing the reverse video, wait for the seek before switching.
|
||||
function playForward(targetTime) {
|
||||
fwdTarget = targetTime
|
||||
if (videoFwd) videoFwd.currentTime = currentFwdTime
|
||||
|
|
@ -88,8 +88,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
// Seek videoRev to the mirror of currentFwdTime and play until targetTime.
|
||||
// If currently showing the forward video, wait for the seek before switching.
|
||||
function playReverse(targetTime) {
|
||||
const revStart = Math.max(0, videoDuration - currentFwdTime)
|
||||
revTarget = targetTime
|
||||
|
|
@ -104,37 +102,19 @@
|
|||
}
|
||||
}
|
||||
|
||||
// --- Navigate: move one item up or down ---
|
||||
let scrollLockTimer = null // internal state for navigate()
|
||||
function navigate(direction) {
|
||||
if (!canScroll || segmentEnds.length === 0) return
|
||||
|
||||
const prevItem = currentItem
|
||||
const newItem = direction === 'down'
|
||||
? Math.min(prevItem + 1, itemCount - 1)
|
||||
: Math.max(prevItem - 1, 0)
|
||||
|
||||
if (newItem === prevItem) return // at boundary, ignore
|
||||
|
||||
canScroll = false
|
||||
clearTimeout(scrollLockTimer)
|
||||
scrollLockTimer = setTimeout(() => { canScroll = true }, SCROLL_LOCK_MS)
|
||||
currentItem = newItem
|
||||
// --- Navigate: move one item up or down (called by composable) ---
|
||||
function navigate(direction, newItem) {
|
||||
const prevItem = currentItem
|
||||
currentItem = newItem
|
||||
|
||||
if (direction === 'down' && videoFwd && !videoFwd.paused) {
|
||||
// Fast path: forward video already playing — just move the stop point
|
||||
fwdTarget = segmentEnds[currentItem]
|
||||
|
||||
} else if (direction === 'up' && videoRev && !videoRev.paused) {
|
||||
// Fast path: reverse video already playing — just move the stop point.
|
||||
// Read live currentTime because currentFwdTime is stale during play.
|
||||
const liveFwdPos = videoDuration - videoRev.currentTime
|
||||
const segBoundary = segmentEnds[currentItem]
|
||||
const targetFwd = liveFwdPos > segBoundary ? segBoundary : 0
|
||||
revTarget = videoDuration - Math.max(targetFwd, REV_TARGET_MIN)
|
||||
|
||||
} else {
|
||||
// General case: stop whatever is playing, then start in the right direction
|
||||
stopActiveVideo()
|
||||
if (direction === 'down') {
|
||||
playForward(segmentEnds[currentItem])
|
||||
|
|
@ -148,38 +128,6 @@
|
|||
requestAnimationFrame(() => { if (isActive) computeOffset() })
|
||||
}
|
||||
|
||||
// --- Wheel capture (non-passive, attached in onMount) ---
|
||||
let scrollDelta = 0 // internal state for onWheel()
|
||||
let lastScrollAt = 0
|
||||
function onWheel(e) {
|
||||
e.preventDefault()
|
||||
if (!isActive || !canScroll) return
|
||||
const now = Date.now()
|
||||
if (now - lastScrollAt > WHEEL_DEBOUNCE_MS) scrollDelta = 0
|
||||
lastScrollAt = now
|
||||
scrollDelta += e.deltaY
|
||||
if (Math.abs(scrollDelta) >= WHEEL_THRESHOLD) {
|
||||
navigate(scrollDelta > 0 ? 'down' : 'up')
|
||||
scrollDelta = 0
|
||||
}
|
||||
}
|
||||
|
||||
// --- Touch ---
|
||||
let touchStartY = 0 // internal state for touch handlers
|
||||
function onTouchStart(e) { touchStartY = e.touches[0].clientY }
|
||||
function onTouchEnd(e) {
|
||||
if (!isActive || !canScroll) return
|
||||
const diff = touchStartY - e.changedTouches[0].clientY
|
||||
if (Math.abs(diff) >= TOUCH_THRESHOLD) navigate(diff > 0 ? 'down' : 'up')
|
||||
}
|
||||
|
||||
// --- Keyboard ---
|
||||
function onKeyDown(e) {
|
||||
if (!isActive || !canScroll) return
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); navigate('down') }
|
||||
if (e.key === 'ArrowUp') { e.preventDefault(); navigate('up') }
|
||||
}
|
||||
|
||||
// --- Playback lifecycle ---
|
||||
function initPlayback() {
|
||||
setTimeout(() => {
|
||||
|
|
@ -190,9 +138,8 @@
|
|||
videoFwd.currentTime = 0
|
||||
currentFwdTime = 0
|
||||
isReverse = false
|
||||
fwdTarget = dur / itemCount // = segmentEnds[0]
|
||||
fwdTarget = dur / itemCount
|
||||
videoFwd.play().catch(() => {})
|
||||
// Pre-seek reverse video to buffer the region needed for the first UP scroll
|
||||
if (videoRev) videoRev.currentTime = Math.max(0, dur - dur / itemCount)
|
||||
requestAnimationFrame(() => computeOffset())
|
||||
}
|
||||
|
|
@ -216,15 +163,13 @@
|
|||
currentItem = 0
|
||||
offsetY = 0
|
||||
isReverse = false
|
||||
canScroll = true
|
||||
clearTimeout(scrollLockTimer)
|
||||
nav.reset()
|
||||
}
|
||||
|
||||
// --- onMount: set up video listeners + wheel + resize ---
|
||||
// --- onMount ---
|
||||
onMount(() => {
|
||||
sectionEl?.addEventListener('wheel', onWheel, { passive: false })
|
||||
sectionEl?.addEventListener('wheel', nav.onWheel, { passive: false })
|
||||
|
||||
// Forward video: update currentFwdTime and pause when target is reached
|
||||
const onFwdUpdate = () => {
|
||||
if (!videoFwd || videoFwd.paused || fwdTarget === null) return
|
||||
if (videoFwd.currentTime >= fwdTarget) {
|
||||
|
|
@ -235,7 +180,6 @@
|
|||
}
|
||||
videoFwd?.addEventListener('timeupdate', onFwdUpdate)
|
||||
|
||||
// Reverse video: update currentFwdTime (mirrored) and pause when target is reached
|
||||
const onRevUpdate = () => {
|
||||
if (!videoRev || videoRev.paused || revTarget === null) return
|
||||
if (videoRev.currentTime >= revTarget) {
|
||||
|
|
@ -249,16 +193,16 @@
|
|||
const onResize = () => { if (isActive) computeOffset() }
|
||||
window.addEventListener('resize', onResize)
|
||||
window.addEventListener('orientationchange', onResize)
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
window.addEventListener('keydown', nav.onKeyDown)
|
||||
|
||||
return () => {
|
||||
sectionEl?.removeEventListener('wheel', onWheel)
|
||||
sectionEl?.removeEventListener('wheel', nav.onWheel)
|
||||
videoFwd?.removeEventListener('timeupdate', onFwdUpdate)
|
||||
videoRev?.removeEventListener('timeupdate', onRevUpdate)
|
||||
window.removeEventListener('resize', onResize)
|
||||
window.removeEventListener('orientationchange', onResize)
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
clearTimeout(scrollLockTimer)
|
||||
window.removeEventListener('keydown', nav.onKeyDown)
|
||||
nav.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -276,8 +220,8 @@
|
|||
class="expertise golden-grid slide"
|
||||
aria-label="Expertise"
|
||||
bind:this={sectionEl}
|
||||
ontouchstart={onTouchStart}
|
||||
ontouchend={onTouchEnd}
|
||||
ontouchstart={nav.onTouchStart}
|
||||
ontouchend={nav.onTouchEnd}
|
||||
>
|
||||
<!-- Video background (decorative) -->
|
||||
<div class="expertise-bg" aria-hidden="true">
|
||||
|
|
@ -390,15 +334,14 @@
|
|||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* Sliding container — position:relative so el.offsetTop is relative to this element
|
||||
(CSS transforms do NOT affect offsetTop, so computeOffset() is always accurate) */
|
||||
/* Sliding container */
|
||||
.expertise-text {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.expertise-text, .expertise-item {
|
||||
transition: all 0.6s cubic-bezier(0.65, 0, 0.35, 1);
|
||||
transition: all 0.6s var(--ease-standard);
|
||||
}
|
||||
|
||||
/* Individual text items */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue