- کپشن: قاعدهٔ وسطچینِ .article-body figcaption با قاعدههای [data-dir=...] برای راست/چپچین بازنویسی میشد و عملاً وسطچین نمیشد؛ figcaption از آن فهرستها برداشته شد. متن کپشن در ویرایشگر هم وسطچین شد تا با خروجی سایت یکی باشد. - نام تصویر: نامی که پیش از بارگذاری نوشته میشود، حالا همان Alt تصویر میشود (پیشتر نام فایل مبنا بود و باید دستی در جعبهٔ زیر تصویر اصلاح میشد). - فهرست مطالب: H3 با ps-4 فقط ۴ پیکسل بیشتر از H2 تورفتگی داشت (عملاً نامرئی)؛ حالا ps-6 با فونت یک پله کوچکتر.
141 lines
5.7 KiB
TypeScript
141 lines
5.7 KiB
TypeScript
import { useEffect, useState } from 'react'
|
|
import type { MouseEvent } from 'react'
|
|
import { ListIcon } from 'lucide-react'
|
|
import { slugifyFa, uniqueSlug } from '../utils/slug'
|
|
|
|
export type TocItem = { id: string; text: string; level: 2 | 3 }
|
|
|
|
/**
|
|
* متن یک تیتر از HTML — همه گرههای تودرتو پیمایش میشوند و <br> به فاصله تبدیل
|
|
* میشود تا تیترهای چندخطی یا شامل مارک (مثل متن انگلیسی) کامل خوانده شوند.
|
|
*/
|
|
function headingText(element: Element): string {
|
|
const parts: string[] = []
|
|
|
|
element.childNodes.forEach((node) => {
|
|
if (node.nodeType === Node.TEXT_NODE) parts.push(node.textContent ?? '')
|
|
else if (node.nodeType === Node.ELEMENT_NODE) {
|
|
const child = node as Element
|
|
parts.push(child.tagName === 'BR' ? ' ' : headingText(child))
|
|
}
|
|
})
|
|
|
|
return parts.join('')
|
|
}
|
|
|
|
/**
|
|
* استخراج فهرست مطالب از HTML ویرایشگر — همه تیترهای H2 و H3 در هر عمقی از سند
|
|
* (حتی داخل نقلقول) شناسه لنگر میگیرند؛ متن فارسی در شناسه حفظ میشود و تیترهای
|
|
* تکراری پسوند عددی میگیرند. HTML برگشتی همان است که باید رندر شود.
|
|
*/
|
|
export function extractToc(
|
|
html: string,
|
|
transform?: (root: HTMLElement) => void,
|
|
): { items: TocItem[]; html: string } {
|
|
if (!html || typeof window === 'undefined') return { items: [], html }
|
|
const doc = new DOMParser().parseFromString(html, 'text/html')
|
|
const used = new Map<string, number>()
|
|
const items: TocItem[] = []
|
|
|
|
doc.body.querySelectorAll('h2, h3').forEach((element, index) => {
|
|
const text = headingText(element).replace(/\s+/g, ' ').trim()
|
|
if (!text) return
|
|
// تیتر بدون حرف و رقم (فقط نشانه) شناسه ترتیبی میگیرد تا لنگرش بینام نماند
|
|
const id = uniqueSlug(slugifyFa(text) || `heading-${index + 1}`, used)
|
|
element.setAttribute('id', id)
|
|
items.push({ id, text, level: element.tagName === 'H3' ? 3 : 2 })
|
|
})
|
|
|
|
// تبدیل اختیاری (بستهبندی متن لاتین/فارسی برای اندازه دوزبانه) روی همان DOM اجرا میشود
|
|
transform?.(doc.body)
|
|
|
|
return { items, html: doc.body.innerHTML }
|
|
}
|
|
|
|
type TableOfContentsProps = {
|
|
items: TocItem[]
|
|
title?: string
|
|
className?: string
|
|
}
|
|
|
|
/**
|
|
* فهرست مطالب کلیکپذیر با هایلایت خودکار تیتر فعال (IntersectionObserver).
|
|
* همه اندازهها و ترازبندیها منطقیاند (start/end) تا در چیدمان راستبهچپ و
|
|
* چپبهراست — بر اساس جهت مقاله — درست بنشیند.
|
|
*/
|
|
export function TableOfContents({ items, title = 'فهرست مطالب', className = '' }: TableOfContentsProps) {
|
|
const [activeId, setActiveId] = useState<string>(items[0]?.id ?? '')
|
|
|
|
useEffect(() => {
|
|
setActiveId(items[0]?.id ?? '')
|
|
}, [items])
|
|
|
|
useEffect(() => {
|
|
if (!items.length || typeof IntersectionObserver === 'undefined') return
|
|
const observer = new IntersectionObserver(
|
|
(entries) => {
|
|
const visible = entries
|
|
.filter((entry) => entry.isIntersecting)
|
|
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)
|
|
const first = visible[0]?.target.id
|
|
if (first) setActiveId(first)
|
|
},
|
|
{ rootMargin: '-15% 0px -70% 0px', threshold: [0, 1] },
|
|
)
|
|
items.forEach((item) => {
|
|
const el = document.getElementById(item.id)
|
|
if (el) observer.observe(el)
|
|
})
|
|
return () => observer.disconnect()
|
|
}, [items])
|
|
|
|
if (items.length === 0) return null
|
|
|
|
const go = (event: MouseEvent<HTMLButtonElement>, id: string) => {
|
|
event.preventDefault()
|
|
const el = document.getElementById(id)
|
|
if (!el) return
|
|
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
|
window.history.replaceState(null, '', `#${id}`)
|
|
}
|
|
|
|
return (
|
|
<nav
|
|
aria-label="فهرست مطالب"
|
|
className={`rounded-2xl border border-white/10 bg-char/60 p-6 backdrop-blur-xl shadow-[0_20px_50px_rgba(0,0,0,0.35),inset_0_1px_1px_rgba(255,255,255,0.08)] ${className}`}
|
|
>
|
|
<p className="flex items-center gap-2 text-sm font-bold text-copper">
|
|
<ListIcon className="h-4 w-4" aria-hidden="true" />
|
|
{title}
|
|
</p>
|
|
<div className="mt-5 h-px w-12 bg-copper/50" aria-hidden="true" />
|
|
{/* ارتفاع فهرست به ارتفاع در دسترس صفحه (زیر نوار چسبان) محدود میشود تا بریده نشود */}
|
|
<ul className="mt-4 max-h-[60vh] space-y-0.5 overflow-y-auto border-s border-ivory/10 ps-3 lg:max-h-[calc(100vh_-_15rem)]">
|
|
{items.map((item) => {
|
|
const isActive = item.id === activeId
|
|
return (
|
|
<li key={item.id}>
|
|
<button
|
|
type="button"
|
|
onClick={(event) => go(event, item.id)}
|
|
aria-current={isActive ? 'location' : undefined}
|
|
className={`block w-full text-start text-sm font-light leading-8 transition-colors duration-200 ease-lux ${
|
|
// H3 زیرمجموعهٔ H2 است: تورفتگی واضحتر و یک پله کوچکتر؛ با ps-4
|
|
// تفاوتش با H2 فقط ۴ پیکسل بود و به چشم نمیآمد.
|
|
item.level === 3 ? 'ps-6 text-[13px]' : ''
|
|
} ${
|
|
isActive
|
|
? 'font-normal text-copper'
|
|
: 'text-ivory/60 hover:text-copper'
|
|
}`}
|
|
>
|
|
{item.text}
|
|
</button>
|
|
</li>
|
|
)
|
|
})}
|
|
</ul>
|
|
</nav>
|
|
)
|
|
}
|