Feat: vue Expertise avec scroll capturé et vidéo segmentée
All checks were successful
Deploy / Deploy to Production (push) Successful in 18s
All checks were successful
Deploy / Deploy to Production (push) Successful in 18s
- Expertise.svelte : effet fullpage vertical avec wheel/touch capturé, vidéo forward/reverse découpée en N segments égaux (1 par bloc body), centrage texte actif via offsetTop, transitions CSS 0.6s - Variables CSS : ajout font-size expertise desktop/tablet/mobile - JSON template : pageTitle depuis writer, items depuis body blocks - Vidéos : BACKGROUND_VIDEO_MISSION + REVERSE copiées dans assets/video/ Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
66afa1daad
commit
3618137657
5 changed files with 412 additions and 27 deletions
BIN
assets/video/BACKGROUND_VIDEO_MISSION.mp4
Normal file
BIN
assets/video/BACKGROUND_VIDEO_MISSION.mp4
Normal file
Binary file not shown.
BIN
assets/video/BACKGROUND_VIDEO_MISSION_REVERSE.mp4
Normal file
BIN
assets/video/BACKGROUND_VIDEO_MISSION_REVERSE.mp4
Normal file
Binary file not shown.
|
|
@ -1,21 +1,10 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
$specificData = [
|
$specificData = [
|
||||||
'intro' => [
|
'pageTitle' => $page->writer()->value(),
|
||||||
'title' => $page->intro_title()->value(),
|
'items' => $page->body()->toBlocks()->filter(fn($b) => $b->type() === 'text')->map(fn($block) => [
|
||||||
'text' => $page->intro_text()->value()
|
'text' => $block->text()->value()
|
||||||
],
|
])->values()
|
||||||
'sections' => $page->expertise_sections()->toStructure()->map(function($section) {
|
|
||||||
return [
|
|
||||||
'title' => $section->title()->value(),
|
|
||||||
'icon' => $section->icon()->value(),
|
|
||||||
'content' => $section->content()->toBlocks()
|
|
||||||
];
|
|
||||||
})->values(),
|
|
||||||
'objective' => [
|
|
||||||
'title' => $page->objective_title()->value(),
|
|
||||||
'text' => $page->objective_text()->value()
|
|
||||||
]
|
|
||||||
];
|
];
|
||||||
|
|
||||||
$pageData = array_merge($genericData, $specificData);
|
$pageData = array_merge($genericData, $specificData);
|
||||||
|
|
|
||||||
|
|
@ -55,4 +55,9 @@
|
||||||
--font-size-title-hero-tablet: 64px;
|
--font-size-title-hero-tablet: 64px;
|
||||||
--font-size-button-tablet: 12px;
|
--font-size-button-tablet: 12px;
|
||||||
--font-size-caption-tablet: 11px;
|
--font-size-caption-tablet: 11px;
|
||||||
|
|
||||||
|
/* Font sizes — expertise items */
|
||||||
|
--font-size-expertise: 22px;
|
||||||
|
--font-size-expertise-mobile: 18px;
|
||||||
|
--font-size-expertise-tablet: 20px;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,415 @@
|
||||||
<script>
|
<script>
|
||||||
import { fade } from 'svelte/transition'
|
import { onMount } from 'svelte'
|
||||||
|
import { slides } from '@state/slides.svelte'
|
||||||
|
|
||||||
let { data } = $props()
|
let { data } = $props()
|
||||||
|
|
||||||
|
// --- State ---
|
||||||
|
let currentItem = $state(0)
|
||||||
|
let canScroll = $state(true)
|
||||||
|
let isReverse = $state(false)
|
||||||
|
let offsetY = $state(0)
|
||||||
|
let videoDuration = $state(0)
|
||||||
|
|
||||||
|
// --- DOM refs ---
|
||||||
|
let videoFwd = $state(null)
|
||||||
|
let videoRev = $state(null)
|
||||||
|
let textContainer = $state(null)
|
||||||
|
let itemEls = $state([])
|
||||||
|
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)
|
||||||
|
let fwdPosition = 0 // current logical position in the forward video timeline
|
||||||
|
|
||||||
|
// --- Active slide ---
|
||||||
|
const isActive = $derived(slides.active?.id === 'expertise')
|
||||||
|
|
||||||
|
// --- Items & segment ends ---
|
||||||
|
const items = $derived(data?.items ?? [])
|
||||||
|
const N = $derived(items.length)
|
||||||
|
|
||||||
|
// segmentEnds[i] = timestamp where forward video pauses after reaching item i
|
||||||
|
const segmentEnds = $derived(
|
||||||
|
N > 0 && videoDuration > 0
|
||||||
|
? Array.from({ length: N }, (_, i) => videoDuration * (i + 1) / N)
|
||||||
|
: []
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- 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
|
||||||
|
const wrapperRect = textContainer.parentElement.getBoundingClientRect()
|
||||||
|
const el = itemEls[currentItem]
|
||||||
|
offsetY = wh / 2 - wrapperRect.top - el.offsetTop - el.offsetHeight / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Navigate: move one item up or down ---
|
||||||
|
let scrollLockTimer = null
|
||||||
|
function navigate(direction) {
|
||||||
|
if (!canScroll || segmentEnds.length === 0) return
|
||||||
|
|
||||||
|
const prevItem = currentItem
|
||||||
|
const newItem = direction === 'down'
|
||||||
|
? Math.min(prevItem + 1, N - 1)
|
||||||
|
: Math.max(prevItem - 1, 0)
|
||||||
|
|
||||||
|
if (newItem === prevItem) return // at boundary, ignore
|
||||||
|
|
||||||
|
canScroll = false
|
||||||
|
clearTimeout(scrollLockTimer)
|
||||||
|
scrollLockTimer = setTimeout(() => { canScroll = true }, 650)
|
||||||
|
currentItem = newItem
|
||||||
|
|
||||||
|
if (direction === 'down' && videoFwd && !videoFwd.paused) {
|
||||||
|
// Fast path: forward video is already playing — just move the stop point forward.
|
||||||
|
// No pause, no seek, no stutter.
|
||||||
|
fwdTarget = segmentEnds[currentItem]
|
||||||
|
|
||||||
|
} else if (direction === 'up' && videoRev && !videoRev.paused) {
|
||||||
|
// Fast path: reverse video is already playing — just move the stop point.
|
||||||
|
// Read live currentTime to compute real position (fwdPosition is stale during play).
|
||||||
|
const liveFwdPos = videoDuration - videoRev.currentTime
|
||||||
|
const segBoundary = segmentEnds[currentItem]
|
||||||
|
const targetFwd = liveFwdPos > segBoundary ? segBoundary : 0
|
||||||
|
revTarget = videoDuration - Math.max(targetFwd, 0.1)
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// General case: need to start a new play or switch direction.
|
||||||
|
// Capture the real position if a video is mid-play, then stop it.
|
||||||
|
if (videoFwd && !videoFwd.paused) {
|
||||||
|
fwdPosition = videoFwd.currentTime
|
||||||
|
videoFwd.pause()
|
||||||
|
fwdTarget = null
|
||||||
|
}
|
||||||
|
if (videoRev && !videoRev.paused) {
|
||||||
|
fwdPosition = videoDuration - videoRev.currentTime
|
||||||
|
videoRev.pause()
|
||||||
|
revTarget = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (direction === 'down') {
|
||||||
|
fwdTarget = segmentEnds[currentItem]
|
||||||
|
if (videoFwd) videoFwd.currentTime = fwdPosition
|
||||||
|
if (isReverse) {
|
||||||
|
// Switching reverse → forward: wait for seek before showing forward video
|
||||||
|
videoFwd?.addEventListener('seeked', () => {
|
||||||
|
isReverse = false
|
||||||
|
videoFwd?.play().catch(() => {})
|
||||||
|
}, { once: true })
|
||||||
|
} else {
|
||||||
|
videoFwd?.play().catch(() => {})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const segBoundary = segmentEnds[currentItem]
|
||||||
|
const targetFwd = fwdPosition > segBoundary ? segBoundary : 0
|
||||||
|
const revStart = Math.max(0, videoDuration - fwdPosition)
|
||||||
|
revTarget = videoDuration - Math.max(targetFwd, 0.1)
|
||||||
|
|
||||||
|
if (videoRev) videoRev.currentTime = revStart
|
||||||
|
if (!isReverse) {
|
||||||
|
// Switching forward → reverse: wait for seek before showing reverse video
|
||||||
|
videoRev?.addEventListener('seeked', () => {
|
||||||
|
isReverse = true
|
||||||
|
videoRev?.play().catch(() => {})
|
||||||
|
}, { once: true })
|
||||||
|
} else {
|
||||||
|
videoRev?.play().catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(() => { if (isActive) computeOffset() })
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Wheel capture (non-passive, attached in onMount) ---
|
||||||
|
let scrollDelta = 0
|
||||||
|
let lastScrollAt = 0
|
||||||
|
function onWheel(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!isActive || !canScroll) return
|
||||||
|
const now = Date.now()
|
||||||
|
if (now - lastScrollAt > 100) scrollDelta = 0
|
||||||
|
lastScrollAt = now
|
||||||
|
scrollDelta += e.deltaY
|
||||||
|
if (Math.abs(scrollDelta) >= 25) {
|
||||||
|
navigate(scrollDelta > 0 ? 'down' : 'up')
|
||||||
|
scrollDelta = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Touch ---
|
||||||
|
let touchStartY = 0
|
||||||
|
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) >= 50) navigate(diff > 0 ? 'down' : 'up')
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- onMount: set up video listeners + wheel + resize ---
|
||||||
|
onMount(() => {
|
||||||
|
sectionEl?.addEventListener('wheel', onWheel, { passive: false })
|
||||||
|
|
||||||
|
// Forward video: update fwdPosition and pause when target is reached
|
||||||
|
const onFwdUpdate = () => {
|
||||||
|
if (!videoFwd || videoFwd.paused || fwdTarget === null) return
|
||||||
|
if (videoFwd.currentTime >= fwdTarget) {
|
||||||
|
fwdPosition = videoFwd.currentTime
|
||||||
|
videoFwd.pause()
|
||||||
|
fwdTarget = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
videoFwd?.addEventListener('timeupdate', onFwdUpdate)
|
||||||
|
|
||||||
|
// Reverse video: update fwdPosition (mirrored) and pause when target is reached
|
||||||
|
const onRevUpdate = () => {
|
||||||
|
if (!videoRev || videoRev.paused || revTarget === null) return
|
||||||
|
if (videoRev.currentTime >= revTarget) {
|
||||||
|
// fwdPosition is the mirror of where we stopped in the reverse video
|
||||||
|
fwdPosition = videoDuration - videoRev.currentTime
|
||||||
|
videoRev.pause()
|
||||||
|
revTarget = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
videoRev?.addEventListener('timeupdate', onRevUpdate)
|
||||||
|
|
||||||
|
const onResize = () => { if (isActive) computeOffset() }
|
||||||
|
window.addEventListener('resize', onResize)
|
||||||
|
window.addEventListener('orientationchange', onResize)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
sectionEl?.removeEventListener('wheel', onWheel)
|
||||||
|
videoFwd?.removeEventListener('timeupdate', onFwdUpdate)
|
||||||
|
videoRev?.removeEventListener('timeupdate', onRevUpdate)
|
||||||
|
window.removeEventListener('resize', onResize)
|
||||||
|
window.removeEventListener('orientationchange', onResize)
|
||||||
|
clearTimeout(scrollLockTimer)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Effect: react to slide activation / deactivation ---
|
||||||
|
$effect(() => {
|
||||||
|
if (isActive) {
|
||||||
|
setTimeout(() => {
|
||||||
|
const tryPlay = () => {
|
||||||
|
const dur = videoFwd?.duration
|
||||||
|
if (!dur || N === 0) return
|
||||||
|
videoDuration = dur
|
||||||
|
videoFwd.currentTime = 0
|
||||||
|
fwdPosition = 0
|
||||||
|
isReverse = false
|
||||||
|
fwdTarget = dur / N // = segmentEnds[0]
|
||||||
|
videoFwd.play().catch(() => {})
|
||||||
|
// Pre-seek reverse video to its expected start position for the first UP scroll.
|
||||||
|
// Forces the browser to buffer that region → eliminates first-use stutter.
|
||||||
|
if (videoRev) videoRev.currentTime = Math.max(0, dur - dur / N)
|
||||||
|
requestAnimationFrame(() => computeOffset())
|
||||||
|
}
|
||||||
|
if (videoFwd?.duration) {
|
||||||
|
tryPlay()
|
||||||
|
} else {
|
||||||
|
videoFwd?.addEventListener('loadedmetadata', () => {
|
||||||
|
videoDuration = videoFwd.duration
|
||||||
|
tryPlay()
|
||||||
|
}, { once: true })
|
||||||
|
}
|
||||||
|
}, 300)
|
||||||
|
} else {
|
||||||
|
// Pause and reset
|
||||||
|
fwdTarget = null
|
||||||
|
revTarget = null
|
||||||
|
fwdPosition = 0
|
||||||
|
if (videoFwd) { videoFwd.pause(); videoFwd.currentTime = 0 }
|
||||||
|
if (videoRev) { videoRev.pause(); videoRev.currentTime = 0 }
|
||||||
|
currentItem = 0
|
||||||
|
offsetY = 0
|
||||||
|
isReverse = false
|
||||||
|
canScroll = true
|
||||||
|
clearTimeout(scrollLockTimer)
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="expertise" transition:fade>
|
<section
|
||||||
<div class="expertise__container">
|
class="expertise golden-grid slide"
|
||||||
<h1>{data?.title || 'Expertise'}</h1>
|
aria-label="Expertise"
|
||||||
<p>Expertise view - To be implemented</p>
|
bind:this={sectionEl}
|
||||||
|
ontouchstart={onTouchStart}
|
||||||
|
ontouchend={onTouchEnd}
|
||||||
|
>
|
||||||
|
<!-- Video background (decorative) -->
|
||||||
|
<div class="expertise-bg" aria-hidden="true">
|
||||||
|
<video
|
||||||
|
bind:this={videoFwd}
|
||||||
|
class:active={!isReverse}
|
||||||
|
muted
|
||||||
|
playsinline
|
||||||
|
preload="auto"
|
||||||
|
>
|
||||||
|
<source src="/assets/video/BACKGROUND_VIDEO_MISSION.mp4" type="video/mp4" />
|
||||||
|
</video>
|
||||||
|
<video
|
||||||
|
bind:this={videoRev}
|
||||||
|
class:active={isReverse}
|
||||||
|
muted
|
||||||
|
playsinline
|
||||||
|
preload="auto"
|
||||||
|
>
|
||||||
|
<source src="/assets/video/BACKGROUND_VIDEO_MISSION_REVERSE.mp4" type="video/mp4" />
|
||||||
|
</video>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<!-- Decorative vertical lines -->
|
||||||
|
<div class="vertical-line vertical-line-col8" aria-hidden="true"></div>
|
||||||
|
<div class="vertical-line vertical-line-col14" aria-hidden="true"></div>
|
||||||
|
|
||||||
|
<!-- Title -->
|
||||||
|
<h2 class="expertise-title font-face-terminal">
|
||||||
|
{@html data?.pageTitle ?? ''}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<!-- Sliding text container -->
|
||||||
|
<div class="expertise-text-wrapper" aria-live="polite" aria-atomic="true">
|
||||||
|
<div
|
||||||
|
class="expertise-text"
|
||||||
|
bind:this={textContainer}
|
||||||
|
style="transform: translateY({offsetY}px)"
|
||||||
|
>
|
||||||
|
{#each items as item, i}
|
||||||
|
<p
|
||||||
|
class="expertise-item"
|
||||||
|
class:active={i === currentItem}
|
||||||
|
bind:this={itemEls[i]}
|
||||||
|
>
|
||||||
|
{@html item.text}
|
||||||
|
</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.expertise {
|
.expertise {
|
||||||
min-height: 100vh;
|
background-color: #000;
|
||||||
padding: 8rem 2rem 4rem;
|
|
||||||
color: #fff;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.expertise__container {
|
/* Full-grid video background */
|
||||||
max-width: 1200px;
|
.expertise-bg {
|
||||||
margin: 0 auto;
|
grid-area: 1/1 / span 20 / span 20;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expertise-bg video {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expertise-bg video.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom vertical lines */
|
||||||
|
.vertical-line-col8 {
|
||||||
|
grid-area: 1/8 / span 20 / span 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vertical-line-col14 {
|
||||||
|
grid-area: 1/14 / span 20 / span 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Title */
|
||||||
|
.expertise-title {
|
||||||
|
grid-area: 8/6 / span 5 / span 5;
|
||||||
|
z-index: var(--z-content);
|
||||||
|
text-align: left;
|
||||||
|
font-size: var(--font-size-title-hero);
|
||||||
|
color: var(--color-text);
|
||||||
|
align-self: center;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Wrapper clips the sliding container */
|
||||||
|
.expertise-text-wrapper {
|
||||||
|
grid-area: 8/11 / span 7 / span 6;
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: var(--z-content);
|
||||||
|
display: flex;
|
||||||
|
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) */
|
||||||
|
.expertise-text {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
transition: transform 0.6s cubic-bezier(0.77, 0, 0.175, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Individual text items */
|
||||||
|
.expertise-item {
|
||||||
|
font-size: var(--font-size-expertise);
|
||||||
|
color: var(--color-text);
|
||||||
|
text-align: left;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin-bottom: 50px;
|
||||||
|
opacity: 0.3;
|
||||||
|
transform: scale(0.9);
|
||||||
|
transform-origin: left center;
|
||||||
|
transition: opacity 0.5s ease, transform 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expertise-item.active {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile (≤ 700px) */
|
||||||
|
@media screen and (max-width: 700px) {
|
||||||
|
.expertise-title {
|
||||||
|
grid-area: 5/4 / span 1 / span 7;
|
||||||
|
font-size: var(--font-size-title-main-mobile);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expertise-text-wrapper {
|
||||||
|
grid-area: 6/4 / span 8 / span 14;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expertise-item {
|
||||||
|
font-size: var(--font-size-expertise-mobile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tablet (701–912px) */
|
||||||
|
@media screen and (min-width: 701px) and (max-width: 912px) {
|
||||||
|
.expertise-title {
|
||||||
|
grid-area: 5/6 / span 4 / span 12;
|
||||||
|
font-size: var(--font-size-title-main-tablet);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expertise-text-wrapper {
|
||||||
|
grid-area: 8/6 / span 8 / span 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expertise-item {
|
||||||
|
font-size: var(--font-size-expertise-tablet);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reduced motion */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.expertise-text,
|
||||||
|
.expertise-item {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue