From 7a0defc3d4b9858c5a219db23c6626787ed8cd75 Mon Sep 17 00:00:00 2001 From: fatemeh sane Date: Thu, 17 Sep 2026 10:10:50 +0330 Subject: [PATCH] fix(admin): resolve Tiptap editor bugs, dynamic RTL/LTR layout, sticky TOC, and equalize Bidi typography --- frontend/README.md | 17 ++- frontend/src/App.tsx | 4 +- frontend/src/admin/ArticleWritePage.tsx | 12 +- frontend/src/admin/EntityDrawer.tsx | 8 +- frontend/src/components/TableOfContents.tsx | 55 ++++++-- .../src/components/admin/ArticleEditor.tsx | 52 +++++++- .../src/components/admin/BidiTypography.ts | 64 +++++++++ frontend/src/components/admin/ImageField.tsx | 74 +++++------ .../src/components/admin/ImageNodeView.tsx | 72 +++++++---- frontend/src/components/admin/blockFormat.ts | 121 ++++++++++++++++++ .../src/components/admin/useSmartTextField.ts | 83 ++++++++++++ frontend/src/data/constants.ts | 3 + frontend/src/index.css | 33 ++++- frontend/src/pages/ArticleDetail.tsx | 52 ++++++-- frontend/src/utils/bidiText.ts | 74 +++++++++++ frontend/src/utils/direction.ts | 30 +++++ frontend/src/utils/slug.ts | 32 ++--- frontend/src/utils/tiptapDoc.ts | 28 ++++ frontend/vite.config.ts | 6 +- 19 files changed, 685 insertions(+), 135 deletions(-) create mode 100644 frontend/src/components/admin/BidiTypography.ts create mode 100644 frontend/src/components/admin/blockFormat.ts create mode 100644 frontend/src/components/admin/useSmartTextField.ts create mode 100644 frontend/src/utils/bidiText.ts create mode 100644 frontend/src/utils/direction.ts diff --git a/frontend/README.md b/frontend/README.md index f025e0f..d5e5a4c 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -3,9 +3,19 @@ A modern React + Vite + TypeScript frontend application with a luxurious dark theme (Background: `#27221c`, Accent: `#d5874c`). All text content and assets are fetched dynamically from the backend API (`GET /api/site`). -## Typography +## Typography & Smart Bidi Text The **Pinar** font is installed and served locally. The application no longer relies on any external servers or CDNs for font rendering, and the UI is fully localized for Persian (Farsi) users. +**Smart Bidi Typography:** To maintain perfect visual weight and baseline alignment when mixing English words within Persian text, we utilize a dynamic CSS calculation for Latin characters: `calc(1em - 2px)`. This ensures that English text automatically scales down slightly to match the natural density and baseline of the Persian font, preventing any visual imbalance in mixed-language sentences. + +## Recent UI/UX Improvements +* **Tiptap Editor Fixes**: + * Resolved the initialization bug where the editor body remained empty during edit mode. + * Fixed Tiptap block boundaries so formatting a new line (e.g., changing it to H2) no longer alters the formatting of the previous line. +* **Dynamic TOC & Layout**: + * The Table of Contents (TOC) is now strictly `position: sticky` and extracts all H2/H3 tags flawlessly. + * The entire article layout (including Author, Read Time, and TOC) dynamically flips RTL/LTR based on the article's primary language. + ## CMS, SEO & Editor Features The project includes a robust, custom-built Digital Twin (Admin Panel) with advanced content management capabilities, emphasizing a premium UI/UX: @@ -15,8 +25,8 @@ The project includes a robust, custom-built Digital Twin (Admin Panel) with adva * **Article Metadata & Reading Time:** A beautifully designed metadata component displaying the author's avatar, name, publication date, and an auto-calculated dynamic reading time (displayed dynamically with Persian numerals, e.g., "۵ دقیقه مطالعه") directly below the H1 title. * **Dynamic Table of Contents (TOC):** Auto-generates a clickable TOC from `h2` and `h3` tags within the article, using a custom Persian-supported `slugify` logic for anchor links. * **Custom Tiptap WYSIWYG Editor:** - * **Fixed Toolbar & Typography:** Dedicated LTR/RTL toggles (`data-dir="ltr|rtl"`) with specific font scaling (RTL Base: 14px, LTR Base: 12px) mapped to a fixed toolbar that maintains layout stability. - * **Advanced Image & Caption Nodes:** Built via `ReactNodeViewRenderer`, rendering HTML wrapped in `
`. Features strictly isolated UI deletion controls (separate 'X' buttons to delete either the image entirely or just the caption text). + * **Fixed Toolbar & Typography:** Dedicated LTR/RTL toggles (`data-dir="ltr|rtl"`) mapped to a fixed toolbar that ensures layout stability. + * **Advanced Image & Caption Nodes:** Built via `ReactNodeViewRenderer`, rendering HTML wrapped in `
`. Features strictly isolated UI deletion controls. * **Animated Link Modal:** A premium, auto-opening modal with smooth fade/scale transition effects. Includes seamless checkbox toggles for "Open in new tab" (`target="_blank" rel="noopener noreferrer"`) and SEO link attributes (`nofollow/dofollow`). * **Full Jalali (Shamsi) Calendar Integration:** * Admin panel utilizes `react-multi-date-picker` for a visual Persian date selection experience. @@ -50,4 +60,3 @@ npm run build * `src/admin/` — Live editing layer (Digital Twin): includes the admin toolbar, entity drawers, and the article editor. * `src/seo.ts` — Manages page-level SEO tags (Title, Description, Open Graph, Canonical, JSON-LD). * `src/data/site.ts` — Shared TypeScript data models and interfaces. - diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3fa715f..c49974b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -97,8 +97,10 @@ function AppChrome() { function AppShell() { const { data, error, reload } = useSiteData(); + // overflow-x-clip جای overflow-x-hidden: اسکرول‌افزار نمی‌سازد، پس position: sticky + // (فهرست مطالب مقاله) به صفحه می‌چسبد و با محتوا پایین نمی‌رود؛ برش افقی هم حفظ می‌شود. return ( -
+
{error ? (
diff --git a/frontend/src/admin/ArticleWritePage.tsx b/frontend/src/admin/ArticleWritePage.tsx index 8ee707c..519b35e 100644 --- a/frontend/src/admin/ArticleWritePage.tsx +++ b/frontend/src/admin/ArticleWritePage.tsx @@ -15,6 +15,7 @@ import type { CategoryOption } from '../components/admin/LuxeSelect' import { ARTICLE_CATEGORIES } from './entities' import { FEATURED_IMAGE_ALT_SUFFIX } from '../data/constants' import { formatReadingTime } from '../utils/readingTime' +import { toArticleBodyDoc } from '../utils/tiptapDoc' import type { Article, ArticleBodyDoc } from '../data/site' const EMPTY_DOC = { type: 'doc', content: [{ type: 'paragraph' }] } as unknown as ArticleBodyDoc @@ -111,6 +112,11 @@ export function ArticleWritePage() { const { field: imageTitleField } = useController({ name: 'image_title', control }) const [createdId, setCreatedId] = useState(null) + /** + * شناسه محتوای بارگذاری‌شده — با رسیدن مقاله از سرور عوض می‌شود تا + * بدنه ویرایشگر TipTap با متن واقعی مقاله مقداردهی شود. + */ + const [loadedContentKey, setLoadedContentKey] = useState('new') const [deleting, setDeleting] = useState(false) const draftRef = useRef(draft) draftRef.current = draft @@ -144,9 +150,12 @@ export function ArticleWritePage() { setDraft({ ...article, tags: normalizeTags(article.tags), - body: (Array.isArray(article.body) ? EMPTY_DOC : article.body) as ArticleBodyDoc, + // بدنه ذخیره‌شده (سند جدید یا پاراگراف‌های قدیمی) به سند قابل ویرایش تبدیل می‌شود + body: toArticleBodyDoc(article.body), }) setCreatedId(article.id) + // بدنه ویرایشگر با محتوای بارگذاری‌شده همگام می‌شود + setLoadedContentKey(article.id) // فرم متادیتا هم با مقادیر مقاله بارگذاری‌شده همگام می‌شود reset({ title: article.title ?? '', @@ -384,6 +393,7 @@ export function ArticleWritePage() { {/* متن */}
} onChange={(json) => { // زمان مطالعه بهصورت پویا از متن سند محاسبه میشود (۲۰۰ واژه در دقیقه) diff --git a/frontend/src/admin/EntityDrawer.tsx b/frontend/src/admin/EntityDrawer.tsx index ed4df9b..3058828 100644 --- a/frontend/src/admin/EntityDrawer.tsx +++ b/frontend/src/admin/EntityDrawer.tsx @@ -5,6 +5,7 @@ import Swal from 'sweetalert2' import { useSiteData } from '../api' import { useAdmin, useFieldSaver } from './AdminContext' import { adminApi } from './adminClient' +import { toArticleBodyDoc } from '../utils/tiptapDoc' import type { EntityConfig, FieldConfig } from './entities' import { ImageField } from '../components/admin/ImageField' import { JalaliDatePicker } from '../components/admin/JalaliDatePicker' @@ -413,10 +414,9 @@ function WysiwygField({ onSave: (value: unknown) => Promise registerFlush?: (fn: () => void) => void }) { - const [doc, setDoc] = useState>(() => - value && typeof value === 'object' && !Array.isArray(value) - ? (value as Record) - : { type: 'doc', content: [{ type: 'paragraph' }] }, + const [doc, setDoc] = useState>( + // بدنه ذخیره‌شده (سند جدید، رشته JSON یا پاراگراف‌های قدیمی) به سند قابل ویرایش تبدیل می‌شود + () => toArticleBodyDoc(value), ) const timer = useRef | null>(null) const pending = useRef | null>(null) diff --git a/frontend/src/components/TableOfContents.tsx b/frontend/src/components/TableOfContents.tsx index 4462d9a..a56c56c 100644 --- a/frontend/src/components/TableOfContents.tsx +++ b/frontend/src/components/TableOfContents.tsx @@ -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 — همه گره‌های تودرتو پیمایش می‌شوند و
به فاصله تبدیل + * می‌شود تا تیترهای چندخطی یا شامل مارک (مثل متن انگلیسی) کامل خوانده شوند. */ -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() 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(items[0]?.id ?? '') @@ -79,7 +109,8 @@ export function TableOfContents({ items, title = 'فهرست مطالب', classN {title}

- - {/* وقتی کپشنی وجود ندارد، فقط یک دکمه افزودن نمایش داده می‌شود */} - {!showCaption && ( - - )} ) } diff --git a/frontend/src/components/admin/blockFormat.ts b/frontend/src/components/admin/blockFormat.ts new file mode 100644 index 0000000..c134073 --- /dev/null +++ b/frontend/src/components/admin/blockFormat.ts @@ -0,0 +1,121 @@ +import { setBlockType, splitBlock } from '@tiptap/pm/commands' +import type { EditorView } from '@tiptap/pm/view' +import type { ResolvedPos } from '@tiptap/pm/model' + +/** قالب بلوک جاری — پاراگراف یا یکی از سطوح تیتر */ +export type BlockFormat = 'paragraph' | 1 | 2 | 3 + +type LineBounds = { + /** ابتدای بلوک جاری */ + start: number + /** پایان بلوک جاری */ + end: number + /** ابتدای خط جاری (پس از نزدیک‌ترین شکست نرم قبل از نشانگر) */ + lineStart: number + /** پایان خط جاری (نزدیک‌ترین شکست نرم پس از نشانگر) */ + lineEnd: number + /** موقعیت شکست نرم قبل از خط جاری — null یعنی خط، ابتدای بلوک است */ + before: number | null + /** موقعیت شکست نرم بعد از خط جاری — null یعنی خط، انتهای بلوک است */ + after: number | null +} + +/** مرزهای خط جاری داخل بلوک جاری — خطوطی که با شکست نرم (
) از هم جدا شده‌اند */ +function lineBounds($from: ResolvedPos): LineBounds { + const start = $from.start() + const end = $from.end() + let lineStart = start + let lineEnd = end + let before: number | null = null + let after: number | null = null + + $from.parent.forEach((node, offset) => { + if (node.type.name !== 'hardBreak') return + const pos = start + offset + if (pos < $from.pos) { + lineStart = pos + 1 + before = pos + } else if (after === null) { + lineEnd = pos + after = pos + } + }) + + return { start, end, lineStart, lineEnd, before, after } +} + +/** + * جدا کردن خط جاری از خطوط چسبیده با شکست نرم (Shift+Enter یا متن چسبانده‌شده از Word). + * بدون این جداسازی، عوض کردن قالب یک خط، قالب همه خطوط همان بلوک را با هم عوض می‌کند. + * اگر گزینش از خط عبور کند (قالب‌بندی چندخطیِ عمدی) دست‌نخورده می‌ماند. + */ +function isolateCurrentLine(view: EditorView): boolean { + const { state } = view + const { selection } = state + const bounds = lineBounds(selection.$from) + + if (bounds.before === null && bounds.after === null) return false + + const selectionInsideLine = + selection.empty || + (selection.from >= bounds.lineStart && selection.to <= bounds.lineEnd) + if (!selectionInsideLine) return false + + const { tr } = state + if (bounds.after !== null) { + tr.delete(bounds.after, bounds.after + 1) + tr.split(bounds.after) + } + if (bounds.before !== null) { + tr.delete(bounds.before, bounds.before + 1) + tr.split(bounds.before) + } + if (!tr.docChanged) return false + + view.dispatch(tr) + return true +} + +/** + * قالب‌بندی بلوک جاری (تیتر ۱/۲/۳ یا پاراگراف) — فقط همان خطی که نشانگر در آن است + * و به خطوط بالا و پایین کاری ندارد. کلیک دوباره روی همان سطح تیتر، آن را به + * پاراگراف برمی‌گرداند (رفتار toggle). + */ +export function applyBlockFormat(view: EditorView, format: BlockFormat): boolean { + view.focus() + isolateCurrentLine(view) + + const { state } = view + const paragraph = state.schema.nodes.paragraph + const heading = state.schema.nodes.heading + if (!paragraph || !heading) return false + + if (format === 'paragraph') return setBlockType(paragraph)(state, view.dispatch, view) + + const current = state.selection.$from.parent + const sameHeading = current.type === heading && current.attrs.level === format + return sameHeading + ? setBlockType(paragraph)(state, view.dispatch, view) + : setBlockType(heading, { level: format })(state, view.dispatch, view) +} + +/** + * Enter داخل تیتر: بلوک تازه پاراگراف می‌شود (نه تیتر تازه) تا قالب تیتر به خط + * بعدی سرایت نکند و مرز بلوک‌ها تمیز بماند. + * این هندلر پیش از keymap پیش‌فرض اجرا می‌شود و فقط همین حالت را می‌گیرد. + */ +export function handleHeadingEnter(view: EditorView, event: KeyboardEvent): boolean { + if (event.key !== 'Enter' || event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) { + return false + } + + const { state } = view + const { selection } = state + const paragraph = state.schema.nodes.paragraph + if (!paragraph || !selection.empty || selection.$from.parent.type.name !== 'heading') return false + + // خط تازه با splitBlock ساخته و بعد به پاراگراف تبدیل می‌شود + if (!splitBlock(state, view.dispatch, view)) return false + setBlockType(paragraph)(view.state, view.dispatch, view) + return true +} diff --git a/frontend/src/components/admin/useSmartTextField.ts b/frontend/src/components/admin/useSmartTextField.ts new file mode 100644 index 0000000..b4ee25a --- /dev/null +++ b/frontend/src/components/admin/useSmartTextField.ts @@ -0,0 +1,83 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +type SmartTextFieldOptions = { + /** مقدار ذخیره‌شده در بیرون (منبع حقیقت) */ + value: string + /** پیشنهاد خودکار — وقتی کاربر چیزی ننوشته، همین متن نمایش داده و ذخیره می‌شود */ + fallback?: string + /** آخرین گزینه وقتی پیشنهادی هم وجود ندارد (مثل «Photo 1») */ + hardFallback?: string + /** ذخیره مقدار نهایی — فقط وقتی نسبت به مقدار ذخیره‌شده تغییر کرده باشد */ + onCommit?: (next: string) => void | Promise +} + +/** + * فیلد متنی با «پیش‌فرض هوشمند» برای Alt/Title تصویر: + * - متن کاربر تا لحظه ذخیره فقط در حالت محلی می‌ماند؛ در حین تایپ نه trim می‌شود و نه از + * بیرون بازنویسی می‌شود، پس فاصله انتهای متن و جای نشانگر حفظ می‌شود. + * - با فوکوس، پیش‌فرض خودکار پاک می‌شود تا کاربر بلافاصله تایپ کند. + * - با بلور، اگر فیلد خالی بماند پیش‌فرض خودکار برمی‌گردد و مقدار نهایی ذخیره می‌شود. + */ +export function useSmartTextField({ + value, + fallback = '', + hardFallback = '', + onCommit, +}: SmartTextFieldOptions) { + const [text, setText] = useState(value) + const [focused, setFocused] = useState(false) + + // تازه‌ترین مقادیر در ref نگه داشته می‌شوند تا هندلرها به closure قدیمی وابسته نباشند + const textRef = useRef(text) + textRef.current = text + const focusedRef = useRef(focused) + focusedRef.current = focused + const fallbackRef = useRef(fallback) + fallbackRef.current = fallback + const hardFallbackRef = useRef(hardFallback) + hardFallbackRef.current = hardFallback + const valueRef = useRef(value) + valueRef.current = value + + // همگام‌سازی با مقدار ذخیره‌شده از بیرون (بارگذاری مقاله، به‌روزرسانی خوش‌بینانه) + // اما هرگز در حین ویرایش کاربر — وگرنه نشانگر می‌پرد و فاصله‌ها می‌خورند + useEffect(() => { + if (focusedRef.current) return + setText(value) + }, [value]) + + /** مقدار نهایی فیلد: متن کاربر → پیشنهاد خودکار → پیش‌فرض ثابت */ + const resolve = useCallback((): string => { + const current = textRef.current.trim() + if (current) return current + return fallbackRef.current.trim() || hardFallbackRef.current.trim() + }, []) + + const commit = useCallback(async (): Promise => { + const next = resolve() + setText(next) + if (onCommit && next !== valueRef.current.trim()) await onCommit(next) + }, [onCommit, resolve]) + + const onFocus = useCallback(() => { + setFocused(true) + // پیش‌فرض خودکار متن واقعی نیست؛ با فوکوس پاک می‌شود تا کاربر بلافاصله تایپ کند + const current = textRef.current.trim() + const auto = fallbackRef.current.trim() || hardFallbackRef.current.trim() + if (!current || current === auto) setText('') + }, []) + + const onBlur = useCallback(() => { + setFocused(false) + void commit() + }, [commit]) + + const onChange = useCallback((next: string) => { + setText(next) + }, []) + + /** متن نمایشی — در نبود متن کاربر، پیشنهاد خودکار نشان داده می‌شود */ + const display = focused ? text : text !== '' ? text : fallback + + return { display, focused, onFocus, onBlur, onChange, commit, resolve } +} diff --git a/frontend/src/data/constants.ts b/frontend/src/data/constants.ts index 37bf2ad..e8a9b3f 100644 --- a/frontend/src/data/constants.ts +++ b/frontend/src/data/constants.ts @@ -5,6 +5,9 @@ /** عنوان پیش‌فرض تصویر، وقتی کاربر عنوانی وارد نکرده باشد */ export const DEFAULT_IMAGE_TITLE = 'Photo 1' +/** متن جایگزین پیش‌فرض تصویرهای داخل متن (وقتی نام فایل چیزی به دست نمی‌دهد) */ +export const DEFAULT_IMAGE_ALT = 'تصویر مقاله' + /** پسوند پیشنهاد Alt برای تصویر شاخص — در کنار عنوان رکورد قرار می‌گیرد */ export const FEATURED_IMAGE_ALT_SUFFIX = 'تصویر شاخص' diff --git a/frontend/src/index.css b/frontend/src/index.css index 28955f1..735bcb4 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -134,6 +134,37 @@ body { [data-dir='ltr'] .article-body h3 { font-size: 12px; } + +/* ---------- تایپوگرافی دوزبانه (فارسی و لاتین در یک خط) ---------- + متن لاتین همیشه ۲ پیکسل کوچک‌تر از متن فارسیِ همان بلوک است: + تیتر۱ ۱۸→۱۶، تیتر۲ ۱۶→۱۴، تیتر۳ و پاراگراف ۱۴→۱۲. + calc(1em - 2px) نسبت به اندازه همان بلوک حساب می‌شود، پس قاعده در همه سطح‌ها درست + می‌ماند و خط پایه هم جابه‌جا نمی‌شود (line-height نسبی است). + در مقاله انگلیسی (data-dir=ltr) پایه همان اندازه کوچک‌تر است و واژه‌های فارسی + ۲ پیکسل بزرگ‌تر می‌شوند تا تفاضل همیشه ۲ پیکسل بماند. */ +.article-body .latin, +.article-body .persian, +.article-body .english-text { + unicode-bidi: isolate; +} + +.article-body .latin, +.article-body .english-text { + font-size: calc(1em - 2px); +} + +.article-body .persian { + font-size: 1em; +} + +[data-dir='ltr'] .article-body .latin, +[data-dir='ltr'] .article-body .english-text { + font-size: 1em; +} + +[data-dir='ltr'] .article-body .persian { + font-size: calc(1em + 2px); +} .article-body span[data-small="true"] { font-size: 12px; /* مارک اختصاصی برای سایز ۱۲ - انگلیسی */ } @@ -141,7 +172,7 @@ body { .english-text { direction: ltr; unicode-bidi: isolate; - font-size: 12px; + /* اندازه از قواعد دوزبانه بالا می‌آید: ۲ پیکسل کوچک‌تر از متن فارسی همان بلوک */ } .article-body ul, .article-body ol { diff --git a/frontend/src/pages/ArticleDetail.tsx b/frontend/src/pages/ArticleDetail.tsx index 69ac6e9..9f9986a 100644 --- a/frontend/src/pages/ArticleDetail.tsx +++ b/frontend/src/pages/ArticleDetail.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { Link, useParams } from 'react-router-dom'; -import { ArrowLeftIcon, ChevronLeftIcon } from 'lucide-react'; +import { ArrowLeftIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'; import { renderToHTMLString } from '@tiptap/static-renderer'; import { Reveal } from '../components/Reveal'; import { ButtonLink } from '../components/Button'; @@ -12,17 +12,23 @@ import { useSite } from '../api'; import { applySeo } from '../seo'; import { useEffect } from 'react'; import { ARTICLE_EXTENSIONS } from '../components/admin/ArticleEditor'; +import { docToText } from '../utils/tiptapDoc'; +import { detectDirection } from '../utils/direction'; +import { wrapScriptRuns } from '../utils/bidiText'; import type { Article } from '../data/site'; /** بدنه مقاله را به HTML و فهرست مطالب تبدیل می‌کند — سند TipTap جدید یا پاراگراف‌های قدیمی */ -function renderBody(article: Article): { html: string | null; toc: TocItem[]; legacy: string[] } { +function renderBody( + article: Article, + dir: 'rtl' | 'ltr', +): { html: string | null; toc: TocItem[]; legacy: string[] } { const body = article.body as unknown; if (Array.isArray(body)) return { html: null, toc: [], legacy: body }; if (body && typeof body === 'object' && (body as { type?: string }).type === 'doc') { try { const html = renderToHTMLString({ extensions: ARTICLE_EXTENSIONS, content: body as never }); - // تیترهای H2/H3 شناسه لنگر می‌گیرند تا فهرست مطالب به آن‌ها بپرد - const { items, html: htmlWithIds } = extractToc(html); + // تیترها شناسه لنگر می‌گیرند و متن لاتین/فارسی برای قاعده «۲ پیکسل کوچک‌تر» بسته‌بندی می‌شود + const { items, html: htmlWithIds } = extractToc(html, (root) => wrapScriptRuns(root, dir)); return { html: htmlWithIds, toc: items, legacy: [] }; } catch { return { html: null, toc: [], legacy: [] }; @@ -73,12 +79,23 @@ export function ArticleDetail() { } - const { html, toc, legacy } = renderBody(article); + const dir = detectDirection(article.title, article.excerpt, docToText(article.body)); + const { html, toc, legacy } = renderBody(article, dir); const related = articles.filter((a) => a.slug !== article.slug && a.status !== 'draft').slice(0, 3); + /** + * جهت غالب متن مقاله — بدنه (با data-dir، طبق قاعده CSS مقاله) و سمت فهرست مطالب + * از همین پیروی می‌کنند: فارسی راست‌به‌چپ (فهرست سمت راست)، انگلیسی چپ‌به‌راست (فهرست سمت چپ). + */ + const hasToc = toc.length > 0; + // در چیدمان راست‌به‌چپ ستون شماره ۱ سمت راست است؛ پس ستون‌ها صریح شماره می‌خورند + const gridColumns = hasToc ? 'lg:grid-cols-[17rem_minmax(0,1fr)]' : ''; + const bodyColumns = 'lg:col-start-2'; + const tocColumns = 'lg:col-start-1'; + return ( <> -
+
{article.image_alt صفحه اصلی - + {dir === 'ltr' ? : } وبلاگ - + {dir === 'ltr' ? : } {article.category}

{article.title}

@@ -112,10 +129,14 @@ export function ArticleDetail() {
-
-
+
+
-

+

{article.excerpt}

@@ -134,9 +155,12 @@ export function ArticleDetail() { )}
- {/* فهرست مطالب — در دسکتاپ کنار متن چسبان می‌ماند */} - {toc.length > 0 && ( -