feat(timeline): خط روی دایره تمام می‌شود و کارت پایانی زیر آن می‌نشیند — لبهٔ بالای کارت دقیقاً روی نقطهٔ پایان خط (اندازه‌گیری DOM برای هر محتوا/اندازه)

This commit is contained in:
aramesh-site deploy
2026-10-07 18:20:14 +00:00
parent 82e33fb0e0
commit 9759ab2698
+31 -8
View File
@@ -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 { useSite, type TimelineItem } from '../../api'
import { useLang } from '../../i18n/lang'
@@ -9,17 +9,17 @@ const ease = [0.23, 1, 0.32, 1] as const
type Milestone = TimelineItem
function Item({ item, index, featured = false }: { item: Milestone; index: number; featured?: boolean }) {
const ref = useRef<HTMLLIElement>(null)
function Item({ item, index, featured = false, onLi }: { item: Milestone; index: number; featured?: boolean; onLi?: (el: HTMLLIElement | null) => void }) {
const ref = useRef<HTMLLIElement | null>(null)
const active = useInView(ref, { once: true, amount: 0.6 })
const onRight = index % 2 === 0
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 است */}
<span
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
? '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'
@@ -56,12 +56,34 @@ export function Timeline() {
const { timeline } = useSite()
const { t } = useLang()
const ref = useRef<HTMLOListElement>(null)
const lastLiRef = useRef<HTMLLIElement | null>(null)
// فاصلهٔ انتهای فهرست تا بالای کارت پایانی — خط دقیقاً روی بالای کارت تمام می‌شود
const [lineBottom, setLineBottom] = useState(0)
const { scrollYProgress } = useScroll({
target: ref,
offset: ['start 70%', 'end 100%'],
offset: ['start 70%', 'end 110%'],
})
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 (
<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">
@@ -82,15 +104,16 @@ export function Timeline() {
<span
aria-hidden="true"
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 و همراستایی دقیق با مرکز */}
<motion.span
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"
style={{ scaleY }}
style={{ scaleY, bottom: lineBottom }}
/>
{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>
<div className="mt-12">