feat(timeline): خط روی دایره تمام میشود و کارت پایانی زیر آن مینشیند — لبهٔ بالای کارت دقیقاً روی نقطهٔ پایان خط (اندازهگیری DOM برای هر محتوا/اندازه)
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import React, { useRef } from 'react'
|
import React, { useLayoutEffect, useRef, useState } from 'react'
|
||||||
import { motion, useScroll, useTransform, useInView } from 'framer-motion'
|
import { motion, useScroll, useTransform, useInView } from 'framer-motion'
|
||||||
import { useSite, type TimelineItem } from '../../api'
|
import { useSite, type TimelineItem } from '../../api'
|
||||||
import { useLang } from '../../i18n/lang'
|
import { useLang } from '../../i18n/lang'
|
||||||
@@ -9,17 +9,17 @@ const ease = [0.23, 1, 0.32, 1] as const
|
|||||||
|
|
||||||
type Milestone = TimelineItem
|
type Milestone = TimelineItem
|
||||||
|
|
||||||
function Item({ item, index, featured = false }: { item: Milestone; index: number; featured?: boolean }) {
|
function Item({ item, index, featured = false, onLi }: { item: Milestone; index: number; featured?: boolean; onLi?: (el: HTMLLIElement | null) => void }) {
|
||||||
const ref = useRef<HTMLLIElement>(null)
|
const ref = useRef<HTMLLIElement | null>(null)
|
||||||
const active = useInView(ref, { once: true, amount: 0.6 })
|
const active = useInView(ref, { once: true, amount: 0.6 })
|
||||||
const onRight = index % 2 === 0
|
const onRight = index % 2 === 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li ref={ref} className={`relative md:grid md:grid-cols-2 md:gap-16 ${featured ? 'mt-10 md:mt-16' : ''}`}>
|
<li ref={(el) => { ref.current = el; onLi?.(el) }} className={`relative md:grid md:grid-cols-2 md:gap-16 ${featured ? 'mt-10 md:mt-16' : ''}`}>
|
||||||
{/* دایرهها: مرکز دایره در موبایل و دسکتاپ دقیقاً روی مرکز خط 2px است */}
|
{/* دایرهها: مرکز دایره در موبایل و دسکتاپ دقیقاً روی مرکز خط 2px است */}
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className={`absolute right-[-5px] top-1/2 z-10 -translate-y-1/2 h-3 w-3 rounded-full transition-all duration-500 ease-lux md:right-1/2 md:translate-x-1/2 ${
|
className={`absolute right-[-5px] z-10 -translate-y-1/2 h-3 w-3 rounded-full transition-all duration-500 ease-lux md:right-1/2 md:translate-x-1/2 ${featured ? 'top-0' : 'top-1/2'} ${
|
||||||
active
|
active
|
||||||
? 'bg-copper shadow-[0_0_15px_rgba(197,143,92,0.6),0_0_0_6px_rgba(213,135,76,0.15)]'
|
? 'bg-copper shadow-[0_0_15px_rgba(197,143,92,0.6),0_0_0_6px_rgba(213,135,76,0.15)]'
|
||||||
: 'bg-ivory/20'
|
: 'bg-ivory/20'
|
||||||
@@ -56,12 +56,34 @@ export function Timeline() {
|
|||||||
const { timeline } = useSite()
|
const { timeline } = useSite()
|
||||||
const { t } = useLang()
|
const { t } = useLang()
|
||||||
const ref = useRef<HTMLOListElement>(null)
|
const ref = useRef<HTMLOListElement>(null)
|
||||||
|
const lastLiRef = useRef<HTMLLIElement | null>(null)
|
||||||
|
// فاصلهٔ انتهای فهرست تا بالای کارت پایانی — خط دقیقاً روی بالای کارت تمام میشود
|
||||||
|
const [lineBottom, setLineBottom] = useState(0)
|
||||||
const { scrollYProgress } = useScroll({
|
const { scrollYProgress } = useScroll({
|
||||||
target: ref,
|
target: ref,
|
||||||
offset: ['start 70%', 'end 100%'],
|
offset: ['start 70%', 'end 110%'],
|
||||||
})
|
})
|
||||||
const scaleY = useTransform(scrollYProgress, [0, 1], [0, 1])
|
const scaleY = useTransform(scrollYProgress, [0, 1], [0, 1])
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const measure = () => {
|
||||||
|
const ol = ref.current
|
||||||
|
const li = lastLiRef.current
|
||||||
|
if (!ol || !li) return
|
||||||
|
const gap = ol.getBoundingClientRect().bottom - li.getBoundingClientRect().top
|
||||||
|
setLineBottom(Math.max(0, Math.round(gap)))
|
||||||
|
}
|
||||||
|
measure()
|
||||||
|
const observer = new ResizeObserver(measure)
|
||||||
|
if (ref.current) observer.observe(ref.current)
|
||||||
|
if (lastLiRef.current) observer.observe(lastLiRef.current)
|
||||||
|
window.addEventListener('resize', measure)
|
||||||
|
return () => {
|
||||||
|
observer.disconnect()
|
||||||
|
window.removeEventListener('resize', measure)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="bg-char py-24 md:py-32" aria-labelledby="timeline-title">
|
<section className="bg-char py-24 md:py-32" aria-labelledby="timeline-title">
|
||||||
<div className="mx-auto w-full max-w-site px-6 lg:px-10">
|
<div className="mx-auto w-full max-w-site px-6 lg:px-10">
|
||||||
@@ -82,15 +104,16 @@ export function Timeline() {
|
|||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="absolute inset-y-0 right-0 w-[2px] bg-ivory/10 md:right-1/2 md:translate-x-1/2"
|
className="absolute inset-y-0 right-0 w-[2px] bg-ivory/10 md:right-1/2 md:translate-x-1/2"
|
||||||
|
style={{ bottom: lineBottom }}
|
||||||
/>
|
/>
|
||||||
{/* خط متحرک - عرض 2px و همراستایی دقیق با مرکز */}
|
{/* خط متحرک - عرض 2px و همراستایی دقیق با مرکز */}
|
||||||
<motion.span
|
<motion.span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="absolute inset-y-0 right-0 w-[2px] origin-top bg-gradient-to-b from-copper/80 via-copper to-copper/55 shadow-[0_0_10px_rgba(197,143,92,0.5)] md:right-1/2 md:translate-x-1/2"
|
className="absolute inset-y-0 right-0 w-[2px] origin-top bg-gradient-to-b from-copper/80 via-copper to-copper/55 shadow-[0_0_10px_rgba(197,143,92,0.5)] md:right-1/2 md:translate-x-1/2"
|
||||||
style={{ scaleY }}
|
style={{ scaleY, bottom: lineBottom }}
|
||||||
/>
|
/>
|
||||||
{timeline.map((item, i) => (
|
{timeline.map((item, i) => (
|
||||||
<Item key={item.id} item={item} index={i} featured={i === timeline.length - 1} />
|
<Item key={item.id} item={item} index={i} featured={i === timeline.length - 1} onLi={i === timeline.length - 1 ? (el) => { lastLiRef.current = el } : undefined} />
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
<div className="mt-12">
|
<div className="mt-12">
|
||||||
|
|||||||
Reference in New Issue
Block a user