2 Commits
Author SHA1 Message Date
MMD 805f4594d4 Merge remote-tracking branch 'origin/dashboard/blog-changes'
# Conflicts:
#	frontend/src/App.tsx
#	frontend/src/admin/ArticleWritePage.tsx
#	frontend/src/admin/EntityDrawer.tsx
#	frontend/src/pages/ArticleDetail.tsx
2026-09-17 12:31:04 +03:30
fatemeh sane 7a0defc3d4 fix(admin): resolve Tiptap editor bugs, dynamic RTL/LTR layout, sticky TOC, and equalize Bidi typography 2026-09-17 10:10:50 +03:30
19 changed files with 685 additions and 135 deletions
+13 -4
View File
@@ -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`). 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. 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 ## 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: 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. * **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. * **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:** * **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. * **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 `<figure><figcaption>`. Features strictly isolated UI deletion controls (separate 'X' buttons to delete either the image entirely or just the caption text). * **Advanced Image & Caption Nodes:** Built via `ReactNodeViewRenderer`, rendering HTML wrapped in `<figure><figcaption>`. 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`). * **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:** * **Full Jalali (Shamsi) Calendar Integration:**
* Admin panel utilizes `react-multi-date-picker` for a visual Persian date selection experience. * 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/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/seo.ts` — Manages page-level SEO tags (Title, Description, Open Graph, Canonical, JSON-LD).
* `src/data/site.ts` — Shared TypeScript data models and interfaces. * `src/data/site.ts` — Shared TypeScript data models and interfaces.
+3 -1
View File
@@ -115,8 +115,10 @@ function AppShell() {
const { data, error, reload } = useSiteData(); const { data, error, reload } = useSiteData();
const { t, dir, lang } = useLang(); const { t, dir, lang } = useLang();
// overflow-x-clip جای overflow-x-hidden: اسکرول‌افزار نمی‌سازد، پس position: sticky
// (فهرست مطالب مقاله) به صفحه می‌چسبد و با محتوا پایین نمی‌رود؛ برش افقی هم حفظ می‌شود.
return ( return (
<div dir={dir} lang={lang} className="min-h-screen w-full overflow-x-hidden bg-ink font-pinar text-ivory"> <div dir={dir} lang={lang} className="min-h-screen w-full overflow-x-clip bg-ink font-pinar text-ivory">
<Preloader /> <Preloader />
{error ? ( {error ? (
<div className="flex min-h-screen flex-col items-center justify-center px-6 text-center"> <div className="flex min-h-screen flex-col items-center justify-center px-6 text-center">
+11 -1
View File
@@ -15,6 +15,7 @@ import type { CategoryOption } from '../components/admin/LuxeSelect'
import { ARTICLE_CATEGORIES } from './entities' import { ARTICLE_CATEGORIES } from './entities'
import { FEATURED_IMAGE_ALT_SUFFIX } from '../data/constants' import { FEATURED_IMAGE_ALT_SUFFIX } from '../data/constants'
import { formatReadingTime } from '../utils/readingTime' import { formatReadingTime } from '../utils/readingTime'
import { toArticleBodyDoc } from '../utils/tiptapDoc'
import type { Article, ArticleBodyDoc } from '../data/site' import type { Article, ArticleBodyDoc } from '../data/site'
const EMPTY_DOC = { type: 'doc', content: [{ type: 'paragraph' }] } as unknown as ArticleBodyDoc const EMPTY_DOC = { type: 'doc', content: [{ type: 'paragraph' }] } as unknown as ArticleBodyDoc
@@ -117,6 +118,11 @@ export function ArticleWritePage() {
const { field: imageTitleField } = useController({ name: 'image_title', control }) const { field: imageTitleField } = useController({ name: 'image_title', control })
const [createdId, setCreatedId] = useState<number | null>(null) const [createdId, setCreatedId] = useState<number | null>(null)
/**
* شناسه محتوای بارگذاری‌شده — با رسیدن مقاله از سرور عوض می‌شود تا
* بدنه ویرایشگر TipTap با متن واقعی مقاله مقداردهی شود.
*/
const [loadedContentKey, setLoadedContentKey] = useState<string | number>('new')
const [deleting, setDeleting] = useState(false) const [deleting, setDeleting] = useState(false)
const draftRef = useRef(draft) const draftRef = useRef(draft)
draftRef.current = draft draftRef.current = draft
@@ -151,13 +157,16 @@ export function ArticleWritePage() {
setDraft({ setDraft({
...(article as unknown as Draft), ...(article as unknown as Draft),
tags: normalizeTags((article as unknown as { tags?: unknown }).tags), tags: normalizeTags((article as unknown as { tags?: unknown }).tags),
body: (Array.isArray(article.body) ? EMPTY_DOC : article.body) as ArticleBodyDoc, // بدنه ذخیره‌شده (سند جدید یا پاراگراف‌های قدیمی) به سند قابل ویرایش تبدیل می‌شود
body: toArticleBodyDoc(article.body),
body_en: body_en:
rawEnBody && typeof rawEnBody === 'object' && !Array.isArray(rawEnBody) rawEnBody && typeof rawEnBody === 'object' && !Array.isArray(rawEnBody)
? (rawEnBody as ArticleBodyDoc) ? (rawEnBody as ArticleBodyDoc)
: EMPTY_DOC, : EMPTY_DOC,
}) })
setCreatedId(article.id) setCreatedId(article.id)
// بدنه ویرایشگر با محتوای بارگذاری‌شده همگام می‌شود
setLoadedContentKey(article.id)
// فرم متادیتا هم با مقادیر مقاله بارگذاری‌شده همگام می‌شود // فرم متادیتا هم با مقادیر مقاله بارگذاری‌شده همگام می‌شود
reset({ reset({
title: article.title ?? '', title: article.title ?? '',
@@ -410,6 +419,7 @@ export function ArticleWritePage() {
{/* متن — ویرایشگر روی سند زبان جاری کار می‌کند */} {/* متن — ویرایشگر روی سند زبان جاری کار می‌کند */}
<div className="mt-8"> <div className="mt-8">
<ArticleEditor <ArticleEditor
contentKey={loadedContentKey}
doc={(editLang === 'en' ? draft.body_en : draft.body) as unknown as Record<string, unknown>} doc={(editLang === 'en' ? draft.body_en : draft.body) as unknown as Record<string, unknown>}
onChange={(json) => { onChange={(json) => {
// زمان مطالعه به‌صورت پویا از متن سند محاسبه می‌شود — به زبان جاری // زمان مطالعه به‌صورت پویا از متن سند محاسبه می‌شود — به زبان جاری
+4 -4
View File
@@ -6,6 +6,7 @@ import { useSiteData } from '../api'
import { useAdmin, useFieldSaver } from './AdminContext' import { useAdmin, useFieldSaver } from './AdminContext'
import { adminApi } from './adminClient' import { adminApi } from './adminClient'
import { editableFields } from './entities' import { editableFields } from './entities'
import { toArticleBodyDoc } from '../utils/tiptapDoc'
import type { EntityConfig, FieldConfig } from './entities' import type { EntityConfig, FieldConfig } from './entities'
import { ImageField } from '../components/admin/ImageField' import { ImageField } from '../components/admin/ImageField'
import { JalaliDatePicker } from '../components/admin/JalaliDatePicker' import { JalaliDatePicker } from '../components/admin/JalaliDatePicker'
@@ -438,10 +439,9 @@ function WysiwygField({
registerFlush?: (fn: () => void) => void registerFlush?: (fn: () => void) => void
hint?: string hint?: string
}) { }) {
const [doc, setDoc] = useState<Record<string, unknown>>(() => const [doc, setDoc] = useState<Record<string, unknown>>(
value && typeof value === 'object' && !Array.isArray(value) // بدنه ذخیره‌شده (سند جدید، رشته JSON یا پاراگراف‌های قدیمی) به سند قابل ویرایش تبدیل می‌شود
? (value as Record<string, unknown>) () => toArticleBodyDoc(value),
: { type: 'doc', content: [{ type: 'paragraph' }] },
) )
const timer = useRef<ReturnType<typeof setTimeout> | null>(null) const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
const pending = useRef<Record<string, unknown> | null>(null) const pending = useRef<Record<string, unknown> | null>(null)
+43 -12
View File
@@ -6,23 +6,49 @@ import { slugifyFa, uniqueSlug } from '../utils/slug'
export type TocItem = { id: string; text: string; level: 2 | 3 } export type TocItem = { id: string; text: string; level: 2 | 3 }
/** /**
* استخراج فهرست مطالب از HTML ویرایشگر — تیترهای H2 و H3 شناسه لنگر می‌گیرند * متن یک تیتر از HTML — همه گره‌های تودرتو پیمایش می‌شوند و <br> به فاصله تبدیل
* (با slugify و پشتیبانی فارسی) و 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 } if (!html || typeof window === 'undefined') return { items: [], html }
const doc = new DOMParser().parseFromString(html, 'text/html') const doc = new DOMParser().parseFromString(html, 'text/html')
const used = new Map<string, number>() const used = new Map<string, number>()
const items: TocItem[] = [] const items: TocItem[] = []
doc.body.querySelectorAll('h2, h3').forEach((el) => { doc.body.querySelectorAll('h2, h3').forEach((element, index) => {
const text = (el.textContent ?? '').replace(/\s+/g, ' ').trim() const text = headingText(element).replace(/\s+/g, ' ').trim()
if (!text) return if (!text) return
const id = uniqueSlug(slugifyFa(text), used) // تیتر بدون حرف و رقم (فقط نشانه) شناسه ترتیبی می‌گیرد تا لنگرش بی‌نام نماند
el.setAttribute('id', id) const id = uniqueSlug(slugifyFa(text) || `heading-${index + 1}`, used)
items.push({ id, text, level: el.tagName === 'H3' ? 3 : 2 }) element.setAttribute('id', id)
items.push({ id, text, level: element.tagName === 'H3' ? 3 : 2 })
}) })
// تبدیل اختیاری (بسته‌بندی متن لاتین/فارسی برای اندازه دوزبانه) روی همان DOM اجرا می‌شود
transform?.(doc.body)
return { items, html: doc.body.innerHTML } return { items, html: doc.body.innerHTML }
} }
@@ -32,7 +58,11 @@ type TableOfContentsProps = {
className?: string className?: string
} }
/** فهرست مطالب کلیک‌پذیر با هایلایت خودکار تیتر فعال (IntersectionObserver) */ /**
* فهرست مطالب کلیک‌پذیر با هایلایت خودکار تیتر فعال (IntersectionObserver).
* همه اندازه‌ها و ترازبندی‌ها منطقی‌اند (start/end) تا در چیدمان راست‌به‌چپ و
* چپ‌به‌راست — بر اساس جهت مقاله — درست بنشیند.
*/
export function TableOfContents({ items, title = 'فهرست مطالب', className = '' }: TableOfContentsProps) { export function TableOfContents({ items, title = 'فهرست مطالب', className = '' }: TableOfContentsProps) {
const [activeId, setActiveId] = useState<string>(items[0]?.id ?? '') const [activeId, setActiveId] = useState<string>(items[0]?.id ?? '')
@@ -79,7 +109,8 @@ export function TableOfContents({ items, title = 'فهرست مطالب', classN
{title} {title}
</p> </p>
<div className="mt-5 h-px w-12 bg-copper/50" aria-hidden="true" /> <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) => { {items.map((item) => {
const isActive = item.id === activeId const isActive = item.id === activeId
return ( return (
@@ -88,8 +119,8 @@ export function TableOfContents({ items, title = 'فهرست مطالب', classN
type="button" type="button"
onClick={(event) => go(event, item.id)} onClick={(event) => go(event, item.id)}
aria-current={isActive ? 'location' : undefined} aria-current={isActive ? 'location' : undefined}
className={`block w-full text-right text-sm font-light leading-8 transition-colors duration-200 ease-lux ${ className={`block w-full text-start text-sm font-light leading-8 transition-colors duration-200 ease-lux ${
item.level === 3 ? 'pr-4 text-[13px]' : '' item.level === 3 ? 'ps-4 text-[13px]' : ''
} ${ } ${
isActive isActive
? 'font-normal text-copper' ? 'font-normal text-copper'
@@ -25,10 +25,13 @@ import {
} from 'lucide-react' } from 'lucide-react'
import { useAdmin } from '../../admin/AdminContext' import { useAdmin } from '../../admin/AdminContext'
import { adminApi } from '../../admin/adminClient' import { adminApi } from '../../admin/adminClient'
import { DEFAULT_IMAGE_ALT } from '../../data/constants'
import { buildLinkRel, isAllowedHref, isUrlLike, normalizeHref, parseLinkRel } from '../../utils/link' import { buildLinkRel, isAllowedHref, isUrlLike, normalizeHref, parseLinkRel } from '../../utils/link'
import { ArticleImage } from './ArticleImage' import { ArticleImage } from './ArticleImage'
import { BidiTypography, bidiPluginKey } from './BidiTypography'
import { EnglishText } from './EnglishText' import { EnglishText } from './EnglishText'
import { LinkModal } from './LinkModal' import { LinkModal } from './LinkModal'
import { applyBlockFormat, handleHeadingEnter } from './blockFormat'
import type { LinkModalValue } from './LinkModal' import type { LinkModalValue } from './LinkModal'
/** /**
@@ -59,13 +62,22 @@ export const ARTICLE_EXTENSIONS = [
}), }),
LegacySmallSizeMark, LegacySmallSizeMark,
EnglishText, EnglishText,
BidiTypography,
] ]
type ArticleEditorProps = { type ArticleEditorProps = {
doc: Record<string, unknown> doc: Record<string, unknown>
onChange: (json: Record<string, unknown>) => void onChange: (json: Record<string, unknown>) => void
/**
* شناسه محتوا — با هر تغییر آن، بدنه ویرایشگر از `doc` جاری دوباره بارگذاری می‌شود
* (مثلاً وقتی مقاله از سرور می‌رسد). بدون این prop محتوا فقط یک‌بار مقداردهی می‌شود.
*/
contentKey?: string | number
} }
/** نمونه ویرایشگر TipTap — همان چیزی که useEditor برمی‌گرداند */
type EditorInstance = NonNullable<ReturnType<typeof useEditor>>
type TextDirection = 'rtl' | 'ltr' type TextDirection = 'rtl' | 'ltr'
/** نحوه اعمال لینک: روی انتخاب فعلی، درج متن تازه یا تبدیل محدوده تایپ‌شده */ /** نحوه اعمال لینک: روی انتخاب فعلی، درج متن تازه یا تبدیل محدوده تایپ‌شده */
@@ -126,7 +138,7 @@ const Divider = () => <span className="mx-1 h-5 w-px bg-ivory/10" aria-hidden="t
* ویرایشگر WYSIWYG مقاله (TipTap) — نود تصویر کپشن‌دار، مودال لینک سئو-محور، * ویرایشگر WYSIWYG مقاله (TipTap) — نود تصویر کپشن‌دار، مودال لینک سئو-محور،
* کلید راست‌چین/چپ‌چین (فقط روی متن اثر دارد، نه نوار ابزار) و آپلود تصویر به MinIO. * کلید راست‌چین/چپ‌چین (فقط روی متن اثر دارد، نه نوار ابزار) و آپلود تصویر به MinIO.
*/ */
export function ArticleEditor({ doc, onChange }: ArticleEditorProps) { export function ArticleEditor({ doc, onChange, contentKey }: ArticleEditorProps) {
const { runSave } = useAdmin() const { runSave } = useAdmin()
const fileRef = useRef<HTMLInputElement>(null) const fileRef = useRef<HTMLInputElement>(null)
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
@@ -192,6 +204,8 @@ export function ArticleEditor({ doc, onChange }: ArticleEditorProps) {
}) })
return false return false
}, },
// Enter داخل تیتر، بلوک تازه را پاراگراف می‌کند تا قالب تیتر سرایت نکند
handleKeyDown: (view, event) => handleHeadingEnter(view, event),
}, },
onUpdate: ({ editor: instance }) => onChange(instance.getJSON()), onUpdate: ({ editor: instance }) => onChange(instance.getJSON()),
}) })
@@ -201,14 +215,40 @@ export function ArticleEditor({ doc, onChange }: ArticleEditorProps) {
if (!editor) return if (!editor) return
editor.view.dom.setAttribute('dir', direction) editor.view.dom.setAttribute('dir', direction)
editor.view.dom.setAttribute('lang', direction === 'rtl' ? 'fa' : 'en') editor.view.dom.setAttribute('lang', direction === 'rtl' ? 'fa' : 'en')
// تزئین‌های دوزبانه (اندازه ۲ پیکسل کوچک‌تر متن لاتین) با جهت تازه بازمحاسبه می‌شوند
editor.view.dispatch(editor.state.tr.setMeta(bidiPluginKey, direction))
}, [editor, direction]) }, [editor, direction])
/**
* ترکیب (نمونه ویرایشگر، شناسه محتوا) که آخرین بار بارگذاری شده است.
* تغییرهای معمول سند (تایپ کاربر) نباید بدنه را از نو بارگذاری کنند.
*/
const loadedContentRef = useRef<{ editor: EditorInstance; key: string | number } | null>(null)
// بارگذاری بدنه با تغییر شناسه محتوا (رسیدن مقاله از سرور) یا ساخته‌شدن نمونه ویرایشگر
useEffect(() => {
if (!editor || contentKey === undefined) return
const loaded = loadedContentRef.current
if (loaded && loaded.editor === editor && loaded.key === contentKey) return
const incoming = doc && Object.keys(doc).length ? doc : null
if (!incoming) return
loadedContentRef.current = { editor, key: contentKey }
// emitUpdate=false تا بارگذاری، ذخیره خودکارِ بی‌مورد را فعال نکند
editor.commands.setContent(incoming, { emitUpdate: false })
}, [editor, contentKey, doc])
async function uploadAndInsert(file: File) { async function uploadAndInsert(file: File) {
if (!editor) return if (!editor) return
setUploading(true) setUploading(true)
await runSave(async () => { await runSave(async () => {
const { url } = await adminApi.upload(file) const { url } = await adminApi.upload(file)
editor.chain().focus().setImage({ src: url, alt: file.name.replace(/\.[^.]+$/, '') }).run() // نام پیش‌فرض از نام فایل ساخته می‌شود و در نبود آن، مقدار پیش‌فرض ثابت می‌ماند
const defaultAlt = file.name.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').trim()
editor
.chain()
.focus()
.setImage({ src: url, alt: defaultAlt || DEFAULT_IMAGE_ALT })
.run()
}) })
setUploading(false) setUploading(false)
} }
@@ -271,28 +311,28 @@ export function ArticleEditor({ doc, onChange }: ArticleEditorProps) {
<ToolButton <ToolButton
label="پاراگراف" label="پاراگراف"
active={editor.isActive('paragraph')} active={editor.isActive('paragraph')}
onClick={() => editor.chain().focus().setParagraph().run()} onClick={() => applyBlockFormat(editor.view, 'paragraph')}
> >
<PilcrowIcon className="h-4 w-4" /> <PilcrowIcon className="h-4 w-4" />
</ToolButton> </ToolButton>
<ToolButton <ToolButton
label="تیتر ۱" label="تیتر ۱"
active={editor.isActive('heading', { level: 1 })} active={editor.isActive('heading', { level: 1 })}
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()} onClick={() => applyBlockFormat(editor.view, 1)}
> >
<Heading1Icon className="h-4 w-4" /> <Heading1Icon className="h-4 w-4" />
</ToolButton> </ToolButton>
<ToolButton <ToolButton
label="تیتر ۲" label="تیتر ۲"
active={editor.isActive('heading', { level: 2 })} active={editor.isActive('heading', { level: 2 })}
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()} onClick={() => applyBlockFormat(editor.view, 2)}
> >
<Heading2Icon className="h-4 w-4" /> <Heading2Icon className="h-4 w-4" />
</ToolButton> </ToolButton>
<ToolButton <ToolButton
label="تیتر ۳" label="تیتر ۳"
active={editor.isActive('heading', { level: 3 })} active={editor.isActive('heading', { level: 3 })}
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()} onClick={() => applyBlockFormat(editor.view, 3)}
> >
<Heading3Icon className="h-4 w-4" /> <Heading3Icon className="h-4 w-4" />
</ToolButton> </ToolButton>
@@ -0,0 +1,64 @@
import { Extension } from '@tiptap/core'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import type { EditorState } from '@tiptap/pm/state'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
import { LATIN_CLASS, RTL_CLASS, findScriptRuns } from '../../utils/bidiText'
/** نام مارک‌های قدیمی که خودشان اندازه/جهت متن را تعیین می‌کنند */
const LEGACY_MARKS = new Set(['englishText', 'smallSize'])
type BidiState = { dir: 'rtl' | 'ltr' }
/** کلید پلاگین — جهت متن با متای همین کلید به‌روز می‌شود */
export const bidiPluginKey = new PluginKey<BidiState>('bidiTypography')
/**
* تزئین دوزبانه متن ویرایشگر — اجرای لاتین (و در متن انگلیسی، اجرای فارسی) با یک
* span جهت‌دار نشانه‌گذاری می‌شود تا ویرایشگر هم دقیقاً همان اندازه «۲ پیکسل کوچک‌تر»
* خروجی سایت را نشان دهد. تزئین‌ها سند را تغییر نمی‌دهند و ذخیره نمی‌شوند.
*/
export const BidiTypography = Extension.create({
name: 'bidiTypography',
addProseMirrorPlugins() {
return [
new Plugin<BidiState>({
key: bidiPluginKey,
state: {
init: () => ({ dir: 'rtl' }),
apply: (tr, value) => {
const dir = tr.getMeta(bidiPluginKey) as BidiState['dir'] | undefined
return dir && dir !== value.dir ? { dir } : value
},
},
props: {
decorations: (state) =>
buildDecorations(state, bidiPluginKey.getState(state)?.dir ?? 'rtl'),
},
}),
]
},
})
function buildDecorations(state: EditorState, dir: 'rtl' | 'ltr'): DecorationSet {
const className = dir === 'rtl' ? LATIN_CLASS : RTL_CLASS
const runDir = dir === 'rtl' ? 'ltr' : 'rtl'
const decorations: Decoration[] = []
const { doc, schema } = state
doc.descendants((node, pos, parent) => {
if (node.type.name === 'codeBlock' || parent?.type.name === 'codeBlock') return false
if (!node.isText || !node.text) return
if (node.marks.some((mark) => mark.type === schema.marks.code || LEGACY_MARKS.has(mark.type.name))) {
return
}
for (const run of findScriptRuns(node.text, dir)) {
decorations.push(
Decoration.inline(pos + run.from, pos + run.to, { class: className, dir: runDir }),
)
}
})
return DecorationSet.create(doc, decorations)
}
+30 -44
View File
@@ -1,8 +1,9 @@
import { useEffect, useId, useRef, useState } from 'react' import { useId, useRef, useState } from 'react'
import { ImageIcon, ImagePlusIcon, Loader2Icon } from 'lucide-react' import { ImageIcon, ImagePlusIcon, Loader2Icon } from 'lucide-react'
import { DEFAULT_IMAGE_TITLE } from '../../data/constants' import { DEFAULT_IMAGE_TITLE } from '../../data/constants'
import { adminApi } from '../../admin/adminClient' import { adminApi } from '../../admin/adminClient'
import { useAdmin } from '../../admin/AdminContext' import { useAdmin } from '../../admin/AdminContext'
import { useSmartTextField } from './useSmartTextField'
type ImageFieldProps = { type ImageFieldProps = {
label: string label: string
@@ -30,6 +31,7 @@ const META_FIELD_CLASS =
* انتخاب و آپلود تصویر به MinIO با پیش‌نمایش بزرگ. * انتخاب و آپلود تصویر به MinIO با پیش‌نمایش بزرگ.
* ترتیب فیلدها: ۱) عنوان تصویر (پیش‌فرض «Photo 1») ۲) متن جایگزین (پیش‌فرض «{عنوان} - تصویر شاخص»). * ترتیب فیلدها: ۱) عنوان تصویر (پیش‌فرض «Photo 1») ۲) متن جایگزین (پیش‌فرض «{عنوان} - تصویر شاخص»).
* هر دو «دستی یا پیش‌فرض» هستند و Alt هرگز از URL ساخته نمی‌شود. * هر دو «دستی یا پیش‌فرض» هستند و Alt هرگز از URL ساخته نمی‌شود.
* پیش‌فرض خودکار با فوکوس پاک می‌شود تا کاربر بلافاصله تایپ کند و با بلورِ خالی برمی‌گردد.
*/ */
export function ImageField({ export function ImageField({
label, label,
@@ -47,40 +49,25 @@ export function ImageField({
const titleId = useId() const titleId = useId()
const altId = useId() const altId = useId()
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
const [altText, setAltText] = useState(alt ?? '')
const [titleText, setTitleText] = useState(title ?? '')
const showTitle = title !== undefined || !!onSaveTitle const showTitle = title !== undefined || !!onSaveTitle
const showAlt = alt !== undefined || !!onSaveAlt const showAlt = alt !== undefined || !!onSaveAlt
// همگام‌سازی با مقادیر ذخیره‌شده (پس از به‌روزرسانی خوش‌بینانه در درخت داده) const titleField = useSmartTextField({
useEffect(() => { value: title ?? '',
setAltText(alt ?? '') fallback: titleFallback,
}, [alt]) hardFallback: DEFAULT_IMAGE_TITLE,
useEffect(() => { onCommit: onSaveTitle,
setTitleText(title ?? '') })
}, [title]) const altField = useSmartTextField({
value: alt ?? '',
fallback: altFallback,
hardFallback: label,
onCommit: onSaveAlt,
})
if (!isAuthenticated || !isEditMode) return null if (!isAuthenticated || !isEditMode) return null
/** عنوان نمایشی: ورودی کاربر → پیشنهاد → «Photo 1» (با تغییر عنوان مقاله زنده به‌روز می‌شود) */
const titleDisplayed = titleText.trim() || titleFallback.trim() || DEFAULT_IMAGE_TITLE
/** Alt نمایشی: ورودی کاربر → پیشنهاد عنوان‌محور (هرگز URL) */
const altDisplayed = altText.trim() || altFallback.trim()
const resolveTitle = () => titleDisplayed
const resolveAlt = () => altDisplayed || label
const persistMeta = async () => {
const nextTitle = resolveTitle()
const nextAlt = resolveAlt()
setTitleText(nextTitle)
setAltText(nextAlt)
if (showTitle && nextTitle !== (title ?? '').trim() && onSaveTitle) await onSaveTitle(nextTitle)
if (showAlt && nextAlt !== (alt ?? '').trim() && onSaveAlt) await onSaveAlt(nextAlt)
}
const pick = async (file: File | undefined) => { const pick = async (file: File | undefined) => {
if (!file) return if (!file) return
setUploading(true) setUploading(true)
@@ -89,18 +76,15 @@ export function ImageField({
const { url } = await adminApi.upload(file) const { url } = await adminApi.upload(file)
const ok = await onSave(url) const ok = await onSave(url)
if (!ok) throw new Error('ذخیره نشد') if (!ok) throw new Error('ذخیره نشد')
await persistMeta() // پیش‌فرض‌های Alt/Title همان لحظه ثبت می‌شوند تا تصویر بدون متادیتا نماند
if (showTitle) await titleField.commit()
if (showAlt) await altField.commit()
}) })
} finally { } finally {
setUploading(false) setUploading(false)
} }
} }
const commitMeta = () => {
if (!onSaveTitle && !onSaveAlt) return
void persistMeta()
}
return ( return (
<div> <div>
<p className="text-xs font-bold text-ivory/80">{label}</p> <p className="text-xs font-bold text-ivory/80">{label}</p>
@@ -110,8 +94,8 @@ export function ImageField({
{value ? ( {value ? (
<img <img
src={value} src={value}
alt={altText || label} alt={altField.display || label}
title={titleText || undefined} title={titleField.display || undefined}
className="h-[27rem] w-full object-cover" className="h-[27rem] w-full object-cover"
/> />
) : ( ) : (
@@ -149,10 +133,11 @@ export function ImageField({
id={titleId} id={titleId}
type="text" type="text"
dir="auto" dir="auto"
value={titleDisplayed} value={titleField.display}
onChange={(event) => setTitleText(event.target.value)} onChange={(event) => titleField.onChange(event.target.value)}
onBlur={commitMeta} onFocus={titleField.onFocus}
placeholder={titleFallback || DEFAULT_IMAGE_TITLE} onBlur={titleField.onBlur}
placeholder={titleFallback.trim() || DEFAULT_IMAGE_TITLE}
className={META_FIELD_CLASS} className={META_FIELD_CLASS}
/> />
</div> </div>
@@ -167,11 +152,12 @@ export function ImageField({
id={altId} id={altId}
type="text" type="text"
dir="auto" dir="auto"
value={altDisplayed} value={altField.display}
onChange={(event) => setAltText(event.target.value)} onChange={(event) => altField.onChange(event.target.value)}
onBlur={commitMeta} onFocus={altField.onFocus}
onBlur={altField.onBlur}
placeholder={ placeholder={
altFallback || 'توضیح کوتاه تصویر برای سئو و صفحه‌خوان‌ها' altFallback.trim() || 'توضیح کوتاه تصویر برای سئو و صفحه‌خوان‌ها'
} }
className={META_FIELD_CLASS} className={META_FIELD_CLASS}
/> />
+44 -28
View File
@@ -13,29 +13,48 @@ type ImageAttrs = {
const DELETE_BUTTON_CLASS = const DELETE_BUTTON_CLASS =
'flex h-7 w-7 shrink-0 items-center justify-center border border-red-400/50 bg-ink/85 text-red-300 backdrop-blur-sm transition-colors duration-150 hover:bg-red-500/20' 'flex h-7 w-7 shrink-0 items-center justify-center border border-red-400/50 bg-ink/85 text-red-300 backdrop-blur-sm transition-colors duration-150 hover:bg-red-500/20'
const INLINE_FIELD_CLASS =
'w-full border border-ivory/15 bg-ink px-3 py-1.5 text-xs font-light text-ivory placeholder:text-ivory/30 focus:border-copper focus:outline-none'
const ADD_CAPTION_CLASS =
'mt-1.5 inline-flex items-center gap-1.5 border border-dashed border-ivory/25 px-3 py-1 text-[11px] font-light text-ivory/50 transition-colors duration-150 hover:border-copper hover:text-copper'
/** /**
* نمایش تصویر مقاله در ویرایشگر — دقیقاً با همان ساختار خروجی سایت: * نمایش تصویر مقاله در ویرایشگر — دقیقاً با همان ساختار خروجی سایت:
* <figure><img><figcaption>کپشن</figcaption></figure> * <figure><img><figcaption>کپشن</figcaption></figure>
* دو دکمه حذف مستقل دارد: یکی برای خود تصویر و یکی برای حذف کامل کپشن. *
* با حذف کپشن، عنصر <figcaption> به‌طور کامل از DOM حذف می‌شود و فقط تصویر می‌ماند. * ویرایش کاملاً درون‌خطی است (بدون پاپ‌اور/مودال):
* - هر تصویر یک «جعبه توضیح/کپشن» همراه خود دارد و متن آن در figcaption ذخیره می‌شود.
* - دکمه X کپشن را کامل حذف می‌کند: صفت کپشن به مقدار پیش‌فرض برمی‌گردد و عنصر
* figcaption با ورودی‌اش از DOM ویرایشگر (و از خروجی ذخیره‌شده) حذف می‌شود؛
* فقط تصویر می‌ماند و «افزودن کپشن» دوباره جعبه را باز می‌کند.
* - ویرایش Alt در ویرایشگر وجود ندارد؛ Alt هنگام درج از نام فایل ثبت می‌شود.
* - دکمه X روی تصویر کل تصویر را حذف می‌کند.
*/ */
export function ImageNodeView({ node, updateAttributes, deleteNode, selected }: NodeViewProps) { export function ImageNodeView({ node, updateAttributes, deleteNode, selected }: NodeViewProps) {
const { src, alt, title, caption } = node.attrs as ImageAttrs const { src, alt, title, caption } = node.attrs as ImageAttrs
const hasCaption = Boolean((caption ?? '').trim()) const hasCaption = Boolean((caption ?? '').trim())
const [editingCaption, setEditingCaption] = useState(false) const [captionOpen, setCaptionOpen] = useState(true)
const inputRef = useRef<HTMLInputElement>(null) const captionInputRef = useRef<HTMLInputElement>(null)
const focusOnOpen = useRef(false)
const showCaption = captionOpen || hasCaption
const showCaption = hasCaption || editingCaption // فقط وقتی کاربر «افزودن کپشن» را می‌زند ورودی فوکوس می‌گیرد (نه هنگام بارگذاری مقاله)
// هنگام افزودن کپشن تازه، ورودی خودکار فوکوس می‌گیرد
useEffect(() => { useEffect(() => {
if (editingCaption && !hasCaption) inputRef.current?.focus() if (!focusOnOpen.current) return
}, [editingCaption, hasCaption]) focusOnOpen.current = false
captionInputRef.current?.focus()
}, [captionOpen])
/** حذف کپشن: مقدار صفت پاک و عنصر figcaption کاملاً unmount می‌شود */ /** حذف کامل کپشن: عنصر figcaption و ورودی‌اش از DOM ویرایشگر بیرون می‌رود */
const removeCaption = () => { const removeCaption = () => {
updateAttributes({ caption: '' }) updateAttributes({ caption: '' })
setEditingCaption(false) setCaptionOpen(false)
}
const addCaption = () => {
focusOnOpen.current = true
setCaptionOpen(true)
} }
return ( return (
@@ -58,18 +77,18 @@ export function ImageNodeView({ node, updateAttributes, deleteNode, selected }:
<XIcon className="h-3.5 w-3.5" /> <XIcon className="h-3.5 w-3.5" />
</button> </button>
{showCaption && ( {showCaption ? (
<figcaption className="article-figure__caption" contentEditable={false}> <figcaption className="article-figure__caption" contentEditable={false}>
<input <input
ref={inputRef} ref={captionInputRef}
type="text" type="text"
value={caption ?? ''} value={caption ?? ''}
onChange={(event) => updateAttributes({ caption: event.target.value })} onChange={(event) => updateAttributes({ caption: event.target.value })}
placeholder="توضیح تصویر (کپشن)…" placeholder="توضیح تصویر (کپشن)…"
aria-label="کپشن تصویر" aria-label="کپشن تصویر"
className="w-full border border-ivory/15 bg-ink px-3 py-1.5 text-xs font-light text-ivory placeholder:text-ivory/30 focus:border-copper focus:outline-none" className={INLINE_FIELD_CLASS}
/> />
{/* حذف فقط کپشن — تصویر دست‌نخورده می‌ماند */} {/* حذف کامل کپشن — تصویر دست‌نخورده می‌ماند */}
<button <button
type="button" type="button"
onClick={removeCaption} onClick={removeCaption}
@@ -80,21 +99,18 @@ export function ImageNodeView({ node, updateAttributes, deleteNode, selected }:
<XIcon className="h-3.5 w-3.5" /> <XIcon className="h-3.5 w-3.5" />
</button> </button>
</figcaption> </figcaption>
) : (
<button
type="button"
contentEditable={false}
onClick={addCaption}
className={ADD_CAPTION_CLASS}
>
<PlusIcon className="h-3 w-3" aria-hidden="true" />
افزودن کپشن
</button>
)} )}
</figure> </figure>
{/* وقتی کپشنی وجود ندارد، فقط یک دکمه افزودن نمایش داده می‌شود */}
{!showCaption && (
<button
type="button"
contentEditable={false}
onClick={() => setEditingCaption(true)}
className="mt-1.5 inline-flex items-center gap-1.5 border border-dashed border-ivory/25 px-3 py-1 text-[11px] font-light text-ivory/50 transition-colors duration-150 hover:border-copper hover:text-copper"
>
<PlusIcon className="h-3 w-3" aria-hidden="true" />
افزودن کپشن
</button>
)}
</NodeViewWrapper> </NodeViewWrapper>
) )
} }
@@ -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
}
/** مرزهای خط جاری داخل بلوک جاری — خطوطی که با شکست نرم (<br>) از هم جدا شده‌اند */
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
}
@@ -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<unknown>
}
/**
* فیلد متنی با «پیش‌فرض هوشمند» برای 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<void> => {
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 }
}
+3
View File
@@ -5,6 +5,9 @@
/** عنوان پیش‌فرض تصویر، وقتی کاربر عنوانی وارد نکرده باشد */ /** عنوان پیش‌فرض تصویر، وقتی کاربر عنوانی وارد نکرده باشد */
export const DEFAULT_IMAGE_TITLE = 'Photo 1' export const DEFAULT_IMAGE_TITLE = 'Photo 1'
/** متن جایگزین پیش‌فرض تصویرهای داخل متن (وقتی نام فایل چیزی به دست نمی‌دهد) */
export const DEFAULT_IMAGE_ALT = 'تصویر مقاله'
/** پسوند پیشنهاد Alt برای تصویر شاخص — در کنار عنوان رکورد قرار می‌گیرد */ /** پسوند پیشنهاد Alt برای تصویر شاخص — در کنار عنوان رکورد قرار می‌گیرد */
export const FEATURED_IMAGE_ALT_SUFFIX = 'تصویر شاخص' export const FEATURED_IMAGE_ALT_SUFFIX = 'تصویر شاخص'
+32 -1
View File
@@ -154,6 +154,37 @@ html[lang='en'] .article-body h3 {
[data-dir='ltr'] .article-body h3 { [data-dir='ltr'] .article-body h3 {
font-size: 12px; 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"] { .article-body span[data-small="true"] {
font-size: 12px; /* مارک اختصاصی برای سایز ۱۲ - انگلیسی */ font-size: 12px; /* مارک اختصاصی برای سایز ۱۲ - انگلیسی */
} }
@@ -161,7 +192,7 @@ html[lang='en'] .article-body h3 {
.english-text { .english-text {
direction: ltr; direction: ltr;
unicode-bidi: isolate; unicode-bidi: isolate;
font-size: 12px; /* اندازه از قواعد دوزبانه بالا می‌آید: ۲ پیکسل کوچک‌تر از متن فارسی همان بلوک */
} }
.article-body ul, .article-body ul,
.article-body ol { .article-body ol {
+38 -14
View File
@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { Link, useParams } from 'react-router-dom'; 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 { renderToHTMLString } from '@tiptap/static-renderer';
import { Reveal } from '../components/Reveal'; import { Reveal } from '../components/Reveal';
import { ButtonLink } from '../components/Button'; import { ButtonLink } from '../components/Button';
@@ -13,17 +13,23 @@ import { useLang } from '../i18n/lang';
import { applySeo } from '../seo'; import { applySeo } from '../seo';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { ARTICLE_EXTENSIONS } from '../components/admin/ArticleEditor'; 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'; import type { Article } from '../data/site';
/** بدنه مقاله را به HTML و فهرست مطالب تبدیل می‌کند — سند TipTap جدید یا پاراگراف‌های قدیمی */ /** بدنه مقاله را به 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; const body = article.body as unknown;
if (Array.isArray(body)) return { html: null, toc: [], legacy: body }; if (Array.isArray(body)) return { html: null, toc: [], legacy: body };
if (body && typeof body === 'object' && (body as { type?: string }).type === 'doc') { if (body && typeof body === 'object' && (body as { type?: string }).type === 'doc') {
try { try {
const html = renderToHTMLString({ extensions: ARTICLE_EXTENSIONS, content: body as never }); 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: [] }; return { html: htmlWithIds, toc: items, legacy: [] };
} catch { } catch {
return { html: null, toc: [], legacy: [] }; return { html: null, toc: [], legacy: [] };
@@ -76,12 +82,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); 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 ( return (
<> <>
<section className="relative flex h-[56vh] min-h-[400px] items-end overflow-hidden"> <section dir={dir} className="relative flex h-[56vh] min-h-[400px] items-end overflow-hidden">
<img <img
src={article.image} src={article.image}
alt={article.image_alt || article.title} alt={article.image_alt || article.title}
@@ -99,11 +116,11 @@ export function ArticleDetail() {
<Link to={href('/')} className="transition-colors hover:text-copper"> <Link to={href('/')} className="transition-colors hover:text-copper">
{t('home')} {t('home')}
</Link> </Link>
<ChevronLeftIcon className="h-3.5 w-3.5 ltr:rotate-180" /> {dir === 'ltr' ? <ChevronRightIcon className="h-3.5 w-3.5" /> : <ChevronLeftIcon className="h-3.5 w-3.5" />}
<Link to={href('/blog')} className="transition-colors hover:text-copper"> <Link to={href('/blog')} className="transition-colors hover:text-copper">
{t('blog')} {t('blog')}
</Link> </Link>
<ChevronLeftIcon className="h-3.5 w-3.5 ltr:rotate-180" /> {dir === 'ltr' ? <ChevronRightIcon className="h-3.5 w-3.5" /> : <ChevronLeftIcon className="h-3.5 w-3.5" />}
<span className="text-copper">{article.category}</span> <span className="text-copper">{article.category}</span>
</nav> </nav>
<h1 className="mt-5 max-w-[700px] text-hero font-bold text-ivory">{article.title}</h1> <h1 className="mt-5 max-w-[700px] text-hero font-bold text-ivory">{article.title}</h1>
@@ -115,10 +132,14 @@ export function ArticleDetail() {
<article className="bg-ink py-20 lg:py-28"> <article className="bg-ink py-20 lg:py-28">
<div className="mx-auto max-w-site px-6 lg:px-10"> <div className="mx-auto max-w-site px-6 lg:px-10">
<div className="mx-auto grid max-w-5xl gap-12 lg:grid-cols-[minmax(0,1fr)_17rem] lg:gap-14"> <div
<div className="min-w-0"> dir={dir}
data-dir={dir}
className={`mx-auto grid max-w-5xl gap-12 lg:gap-14 ${gridColumns}`}
>
<div className={`min-w-0 lg:row-start-1 ${hasToc ? bodyColumns : ''}`}>
<Reveal> <Reveal>
<p className="border-copper ltr:border-l-2 ltr:pl-6 rtl:border-r-2 rtl:pr-6 text-lg font-light leading-10 text-ivory/80"> <p className="border-s-2 border-copper ps-6 text-lg font-light leading-10 text-ivory/80">
{article.excerpt} {article.excerpt}
</p> </p>
</Reveal> </Reveal>
@@ -137,9 +158,12 @@ export function ArticleDetail() {
)} )}
</div> </div>
{/* فهرست مطالب — در دسکتاپ کنار متن چسبان می‌ماند */} {/* فهرست مطالب — در دسکتاپ کنار متن می‌چسبد و همراه مقاله پایین می‌آید */}
{toc.length > 0 && ( {hasToc && (
<aside className="order-first lg:order-none"> <aside
dir={dir}
className={`order-first lg:order-none lg:row-start-1 ${tocColumns}`}
>
<TableOfContents items={toc} className="lg:sticky lg:top-28" /> <TableOfContents items={toc} className="lg:sticky lg:top-28" />
</aside> </aside>
)} )}
+74
View File
@@ -0,0 +1,74 @@
/**
* دوزبانه‌سازی خودکار متن مقاله — قاعده: متن لاتین همیشه «۲ پیکسل کوچک‌تر» از متن
* فارسیِ همان بلوک است (تیتر۱ ۱۸→۱۶، تیتر۲ ۱۶→۱۴، تیتر۳ و پاراگراف ۱۴→۱۲) و در
* مقاله انگلیسی برعکس، واژه‌های فارسی ۲ پیکسل بزرگ‌تر می‌شوند.
*
* اجرای غیرغالب متن با یک span جهت‌دار جدا می‌شود تا نه جهت متن به‌هم بریزد
* (unicode-bidi: isolate) و نه خط پایه جابه‌جا شود؛ اندازه در CSS با
* calc(1em - 2px) نسبت به همان بلوک حساب می‌شود.
*/
/** اجرای متن لاتین — حروف/ارقام لاتین به‌همراه فاصله و نشانه‌های داخلی */
const LATIN_PATTERN =
"[A-Za-z0-9\\u00c0-\\u024f]+(?:[ \\t.,:;'\"()&/+%\\u2013\\u2014-]+[A-Za-z0-9\\u00c0-\\u024f]+)*"
/** اجرای متن فارسی/عربی — با نیم‌فاصله و ارقام فارسی */
const RTL_PATTERN = '[\\u0590-\\u08ff\\u200c]+(?:[ \\t\\u200c]+[\\u0590-\\u08ff\\u200c]+)*'
/** کلاس span متن لاتین و متن فارسی — همین‌ها در CSS اندازه ۲ پیکسل تفاضلی می‌گیرند */
export const LATIN_CLASS = 'latin'
export const RTL_CLASS = 'persian'
/** عناصری که متنشان دست‌نخورده می‌ماند (کد، مارک‌های قدیمی و اجرای بسته‌بندی‌شده) */
const SKIP_SELECTOR = 'code, pre, kbd, samp, script, style, .english-text, [data-small], .latin, .persian'
/** همه اجراهای غیرغالب یک متن — با بازه نسبت به ابتدای همان متن */
export function findScriptRuns(text: string, dir: 'rtl' | 'ltr'): { from: number; to: number }[] {
const scan = new RegExp(dir === 'rtl' ? LATIN_PATTERN : RTL_PATTERN, 'g')
const runs: { from: number; to: number }[] = []
for (const match of text.matchAll(scan)) {
const from = match.index ?? 0
runs.push({ from, to: from + match[0].length })
}
return runs
}
/**
* بسته‌بندی اجرای غیرغالب همه گره‌های متنی داخل یک ریشه HTML.
* در مقاله فارسی اجرای لاتین و در مقاله انگلیسی اجرای فارسی جدا می‌شود.
*/
export function wrapScriptRuns(root: HTMLElement, dir: 'rtl' | 'ltr'): void {
const document = root.ownerDocument
const className = dir === 'rtl' ? LATIN_CLASS : RTL_CLASS
const runDir = dir === 'rtl' ? 'ltr' : 'rtl'
const texts: Text[] = []
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
const text = node as Text
if (!text.parentElement || text.parentElement.closest(SKIP_SELECTOR)) continue
if (!text.data.trim()) continue
if (findScriptRuns(text.data, dir).length) texts.push(text)
}
for (const text of texts) {
const value = text.data
const fragment = document.createDocumentFragment()
let cursor = 0
for (const run of findScriptRuns(value, dir)) {
if (run.from > cursor) fragment.append(value.slice(cursor, run.from))
const span = document.createElement('span')
span.className = className
span.setAttribute('dir', runDir)
span.textContent = value.slice(run.from, run.to)
fragment.append(span)
cursor = run.to
}
if (cursor < value.length) fragment.append(value.slice(cursor))
text.replaceWith(fragment)
}
}
+30
View File
@@ -0,0 +1,30 @@
/**
* تشخیص جهت غالب متن — پایه چیدمان مقاله و فهرست مطالب.
* سایت فارسی است؛ اما مقاله‌ای که متنش لاتین است باید چپ‌به‌راست خوانده شود.
*/
/** خط‌های راست‌به‌چپ: عبری، عربی و فارسی */
const RTL_CHARS = /[\u0590-\u05ff\u0600-\u06ff\u0700-\u074f\ufb50-\ufdff\ufe70-\ufeff]/
/** ارقام فارسی/عربی — در شمارش جهت بی‌طرف‌اند */
const RTL_DIGITS = /[\u0660-\u0669\u06f0-\u06f9]/
/** حروف لاتین (با علامت‌ها) */
const LTR_CHARS = /[A-Za-z\u00c0-\u024f]/
/**
* جهت غالب نمونه‌های متن: فارسی/عربی → rtl و لاتین → ltr.
* در حالت مساوی (مثلاً متن کوتاه یا فقط رقم) پیش‌فرض سایت، راست‌به‌چپ، برمی‌گردد.
*/
export function detectDirection(...samples: (string | null | undefined)[]): 'rtl' | 'ltr' {
let rtl = 0
let ltr = 0
for (const sample of samples) {
for (const char of sample ?? '') {
if (RTL_DIGITS.test(char)) continue
if (RTL_CHARS.test(char)) rtl += 1
else if (LTR_CHARS.test(char)) ltr += 1
}
}
return rtl >= ltr ? 'rtl' : 'ltr'
}
+14 -18
View File
@@ -1,31 +1,27 @@
import slugify from 'slugify'
/** /**
* تبدیل متن (فارسی یا انگلیسی) به شناسه/لنگر امن. * تبدیل متن (فارسی یا انگلیسی) به شناسه/لنگر امن برای فهرست مطالب.
* slugify حروف فارسی و عربی را به معادل لاتین ترانویسی می‌کند تا لنگرهای * حروف و اعداد فارسی/عربی و لاتین در شناسه می‌مانند (در URL به‌صورت percent-encoded
* فهرست مطالب در همه مرورگرها ASCII، پایدار و قابل اشتراک‌گذاری بمانند. * نوشته می‌شوند و در همه مرورگرها کار می‌کنند)؛ فقط نشانه‌ها و فاصله‌ها به خط تیره
* تبدیل می‌شوند. نیم‌فاصله و نویسه‌های نامرئی جهت هم حذف می‌شوند تا تیترهای تکراری
* شناسه یکتا بگیرند.
*/ */
export function slugifyFa(input: string): string { export function slugifyFa(input: string): string {
const normalized = (input ?? '') return (input ?? '')
.toString() .toString()
.normalize('NFC') .normalize('NFC')
// نیم‌فاصله، اتصال‌دهنده و نویسه‌های نامرئی جهت → فاصله // نیم‌فاصله، اتصال‌دهنده و نویسه‌های نامرئی جهت → حذف
.replace(/[\u200b-\u200f\u202a-\u202e]/g, ' ') .replace(/[\u200b-\u200f\u202a-\u202e\ufeff]/g, ' ')
// یکسان‌سازی حروف عربی با معادل فارسی // یکسان‌سازی حروف عربی با معادل فارسی
.replace(/[يى]/g, 'ی') .replace(/[يى]/g, 'ی')
.replace(/ك/g, 'ک') .replace(/ك/g, 'ک')
.replace(/[ۀة]/g, 'ه') .replace(/[ۀة]/g, 'ه')
.toLowerCase()
// فقط حروف/اعداد یونیکد (فارسی، عربی، لاتین و...) و فاصله/خط تیره بماند
.replace(/[^\p{L}\p{N}\s-]/gu, '')
.trim() .trim()
.replace(/\s+/g, '-')
const slug = slugify(normalized, { .replace(/-{2,}/g, '-')
lower: true, .replace(/^-+|-+$/g, '')
trim: true,
replacement: '-',
// فقط حروف/اعداد/فاصله/خط تیره بماند — بقیه نشانه‌ها حذف شوند
remove: /[^\w\s-]/g,
})
return slug.replace(/-{2,}/g, '-').replace(/^-+|-+$/g, '')
} }
/** ساخت شناسه یکتا — تیترهای تکراری پسوند عددی می‌گیرند (مثل about، about-2) */ /** ساخت شناسه یکتا — تیترهای تکراری پسوند عددی می‌گیرند (مثل about، about-2) */
+28
View File
@@ -3,6 +3,8 @@
* هم چک‌لیست سئو و هم محاسبه زمان مطالعه از همین‌جا استفاده می‌کنند (DRY). * هم چک‌لیست سئو و هم محاسبه زمان مطالعه از همین‌جا استفاده می‌کنند (DRY).
*/ */
import type { ArticleBodyDoc } from '../data/site'
type DocNode = { type DocNode = {
type?: string type?: string
text?: string text?: string
@@ -73,3 +75,29 @@ export function firstParagraph(body: unknown): string {
const paragraph = content?.find((child) => child?.type === 'paragraph') const paragraph = content?.find((child) => child?.type === 'paragraph')
return paragraph ? docToText({ type: 'doc', content: [paragraph] }) : '' return paragraph ? docToText({ type: 'doc', content: [paragraph] }) : ''
} }
const EMPTY_DOC = { type: 'doc', content: [{ type: 'paragraph' }] } as ArticleBodyDoc
/**
* تبدیل بدنه ذخیره‌شده مقاله به سند TipTap برای بارگذاری در ویرایشگر.
* ردیف‌های قدیمی متن را به‌صورت آرایه پاراگراف نگه داشته‌اند و ممکن است رشته JSON هم
* برگردد؛ هر دو شکل به سند تبدیل می‌شوند تا هنگام ویرایش، متن قبلی از دست نرود.
*/
export function toArticleBodyDoc(body: unknown): ArticleBodyDoc {
if (Array.isArray(body)) {
const paragraphs = body
.map((paragraph) => String(paragraph ?? '').trim())
.filter(Boolean)
.map((text) => ({ type: 'paragraph', content: [{ type: 'text', text }] }))
return paragraphs.length ? { type: 'doc', content: paragraphs } : { ...EMPTY_DOC }
}
if (typeof body === 'string') {
try {
return toArticleBodyDoc(JSON.parse(body))
} catch {
return { ...EMPTY_DOC }
}
}
if (body && typeof body === 'object') return body as ArticleBodyDoc
return { ...EMPTY_DOC }
}
+4 -2
View File
@@ -5,10 +5,12 @@ import react from '@vitejs/plugin-react'
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
server: { server: {
// در حالت توسعه، /api و /media به Traefik پروکسی می‌شوند (پورت ۵۱۷۳ روی هاست) port: 5174, // سرور توسعه فرانت‌اند روی این پورت اجرا می‌شود تا تداخل نکند
strictPort: true, // جلوگیری از تغییر خودکار پورت
proxy: { proxy: {
// در حالت توسعه، درخواست‌های API و Media به Traefik (پورت ۵۱۷۳) فرستاده می‌شوند
'/api': 'http://localhost:5173', '/api': 'http://localhost:5173',
'/media': 'http://localhost:5173', '/media': 'http://localhost:5173',
}, },
}, },
}) })