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 — همه گره‌های تودرتو پیمایش می‌شوند و
به فاصله تبدیل * می‌شود تا تیترهای چندخطی یا شامل مارک (مثل متن انگلیسی) کامل خوانده شوند. */ 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() 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(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, id: string) => { event.preventDefault() const el = document.getElementById(id) if (!el) return el.scrollIntoView({ behavior: 'smooth', block: 'start' }) window.history.replaceState(null, '', `#${id}`) } return ( ) }