Update blog editor features

This commit is contained in:
fatemeh sane
2026-09-13 10:39:25 +03:30
parent 20c95e73b3
commit bb6c586d99
21 changed files with 1235 additions and 187 deletions
+107
View File
@@ -0,0 +1,107 @@
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 ویرایشگر — تیترهای H2 و H3 شناسه لنگر می‌گیرند
* (با slugify و پشتیبانی فارسی) و HTML برگشتی همان است که باید رندر شود.
*/
export function extractToc(html: string): { 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((el) => {
const text = (el.textContent ?? '').replace(/\s+/g, ' ').trim()
if (!text) return
const id = uniqueSlug(slugifyFa(text), used)
el.setAttribute('id', id)
items.push({ id, text, level: el.tagName === 'H3' ? 3 : 2 })
})
return { items, html: doc.body.innerHTML }
}
type TableOfContentsProps = {
items: TocItem[]
title?: string
className?: string
}
/** فهرست مطالب کلیک‌پذیر با هایلایت خودکار تیتر فعال (IntersectionObserver) */
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-r border-ivory/10 pr-3">
{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-right text-sm font-light leading-8 transition-colors duration-200 ease-lux ${
item.level === 3 ? 'pr-4 text-[13px]' : ''
} ${
isActive
? 'font-normal text-copper'
: 'text-ivory/60 hover:text-copper'
}`}
>
{item.text}
</button>
</li>
)
})}
</ul>
</nav>
)
}