fix(admin): resolve Tiptap editor bugs, dynamic RTL/LTR layout, sticky TOC, and equalize Bidi typography

This commit is contained in:
fatemeh sane
2026-09-17 10:10:50 +03:30
parent 049291c109
commit 7a0defc3d4
19 changed files with 685 additions and 135 deletions
+43 -12
View File
@@ -6,23 +6,49 @@ import { slugifyFa, uniqueSlug } from '../utils/slug'
export type TocItem = { id: string; text: string; level: 2 | 3 }
/**
* استخراج فهرست مطالب از HTML ویرایشگر — تیترهای H2 و H3 شناسه لنگر می‌گیرند
* (با slugify و پشتیبانی فارسی) و HTML برگشتی همان است که باید رندر شود.
* متن یک تیتر از HTML — همه گره‌های تودرتو پیمایش می‌شوند و <br> به فاصله تبدیل
* می‌شود تا تیترهای چندخطی یا شامل مارک (مثل متن انگلیسی) کامل خوانده شوند.
*/
export function extractToc(html: string): { items: TocItem[]; html: string } {
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((el) => {
const text = (el.textContent ?? '').replace(/\s+/g, ' ').trim()
doc.body.querySelectorAll('h2, h3').forEach((element, index) => {
const text = headingText(element).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 })
// تیتر بدون حرف و رقم (فقط نشانه) شناسه ترتیبی می‌گیرد تا لنگرش بی‌نام نماند
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 }
}
@@ -32,7 +58,11 @@ type TableOfContentsProps = {
className?: string
}
/** فهرست مطالب کلیک‌پذیر با هایلایت خودکار تیتر فعال (IntersectionObserver) */
/**
* فهرست مطالب کلیک‌پذیر با هایلایت خودکار تیتر فعال (IntersectionObserver).
* همه اندازه‌ها و ترازبندی‌ها منطقی‌اند (start/end) تا در چیدمان راست‌به‌چپ و
* چپ‌به‌راست — بر اساس جهت مقاله — درست بنشیند.
*/
export function TableOfContents({ items, title = 'فهرست مطالب', className = '' }: TableOfContentsProps) {
const [activeId, setActiveId] = useState<string>(items[0]?.id ?? '')
@@ -79,7 +109,8 @@ export function TableOfContents({ items, title = 'فهرست مطالب', classN
{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">
{/* ارتفاع فهرست به ارتفاع در دسترس صفحه (زیر نوار چسبان) محدود می‌شود تا بریده نشود */}
<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 (
@@ -88,8 +119,8 @@ export function TableOfContents({ items, title = 'فهرست مطالب', classN
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]' : ''
className={`block w-full text-start text-sm font-light leading-8 transition-colors duration-200 ease-lux ${
item.level === 3 ? 'ps-4 text-[13px]' : ''
} ${
isActive
? 'font-normal text-copper'