fix(admin): resolve Tiptap editor bugs, dynamic RTL/LTR layout, sticky TOC, and equalize Bidi typography
This commit is contained in:
+13
-4
@@ -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 `<figure><figcaption>`. 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 `<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`).
|
||||
* **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.
|
||||
|
||||
|
||||
@@ -97,8 +97,10 @@ function AppChrome() {
|
||||
function AppShell() {
|
||||
const { data, error, reload } = useSiteData();
|
||||
|
||||
// overflow-x-clip جای overflow-x-hidden: اسکرولافزار نمیسازد، پس position: sticky
|
||||
// (فهرست مطالب مقاله) به صفحه میچسبد و با محتوا پایین نمیرود؛ برش افقی هم حفظ میشود.
|
||||
return (
|
||||
<div dir="rtl" lang="fa" className="min-h-screen w-full overflow-x-hidden bg-ink font-pinar text-ivory">
|
||||
<div dir="rtl" lang="fa" className="min-h-screen w-full overflow-x-clip bg-ink font-pinar text-ivory">
|
||||
<Preloader />
|
||||
{error ? (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center px-6 text-center">
|
||||
|
||||
@@ -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<number | null>(null)
|
||||
/**
|
||||
* شناسه محتوای بارگذاریشده — با رسیدن مقاله از سرور عوض میشود تا
|
||||
* بدنه ویرایشگر TipTap با متن واقعی مقاله مقداردهی شود.
|
||||
*/
|
||||
const [loadedContentKey, setLoadedContentKey] = useState<string | number>('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() {
|
||||
{/* متن */}
|
||||
<div className="mt-8">
|
||||
<ArticleEditor
|
||||
contentKey={loadedContentKey}
|
||||
doc={draft.body as unknown as Record<string, unknown>}
|
||||
onChange={(json) => {
|
||||
// زمان مطالعه بهصورت پویا از متن سند محاسبه میشود (۲۰۰ واژه در دقیقه)
|
||||
|
||||
@@ -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<boolean>
|
||||
registerFlush?: (fn: () => void) => void
|
||||
}) {
|
||||
const [doc, setDoc] = useState<Record<string, unknown>>(() =>
|
||||
value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: { type: 'doc', content: [{ type: 'paragraph' }] },
|
||||
const [doc, setDoc] = useState<Record<string, unknown>>(
|
||||
// بدنه ذخیرهشده (سند جدید، رشته JSON یا پاراگرافهای قدیمی) به سند قابل ویرایش تبدیل میشود
|
||||
() => toArticleBodyDoc(value),
|
||||
)
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const pending = useRef<Record<string, unknown> | null>(null)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -25,10 +25,13 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { useAdmin } from '../../admin/AdminContext'
|
||||
import { adminApi } from '../../admin/adminClient'
|
||||
import { DEFAULT_IMAGE_ALT } from '../../data/constants'
|
||||
import { buildLinkRel, isAllowedHref, isUrlLike, normalizeHref, parseLinkRel } from '../../utils/link'
|
||||
import { ArticleImage } from './ArticleImage'
|
||||
import { BidiTypography, bidiPluginKey } from './BidiTypography'
|
||||
import { EnglishText } from './EnglishText'
|
||||
import { LinkModal } from './LinkModal'
|
||||
import { applyBlockFormat, handleHeadingEnter } from './blockFormat'
|
||||
import type { LinkModalValue } from './LinkModal'
|
||||
|
||||
/**
|
||||
@@ -59,13 +62,22 @@ export const ARTICLE_EXTENSIONS = [
|
||||
}),
|
||||
LegacySmallSizeMark,
|
||||
EnglishText,
|
||||
BidiTypography,
|
||||
]
|
||||
|
||||
type ArticleEditorProps = {
|
||||
doc: Record<string, unknown>
|
||||
onChange: (json: Record<string, unknown>) => void
|
||||
/**
|
||||
* شناسه محتوا — با هر تغییر آن، بدنه ویرایشگر از `doc` جاری دوباره بارگذاری میشود
|
||||
* (مثلاً وقتی مقاله از سرور میرسد). بدون این prop محتوا فقط یکبار مقداردهی میشود.
|
||||
*/
|
||||
contentKey?: string | number
|
||||
}
|
||||
|
||||
/** نمونه ویرایشگر TipTap — همان چیزی که useEditor برمیگرداند */
|
||||
type EditorInstance = NonNullable<ReturnType<typeof useEditor>>
|
||||
|
||||
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) — نود تصویر کپشندار، مودال لینک سئو-محور،
|
||||
* کلید راستچین/چپچین (فقط روی متن اثر دارد، نه نوار ابزار) و آپلود تصویر به MinIO.
|
||||
*/
|
||||
export function ArticleEditor({ doc, onChange }: ArticleEditorProps) {
|
||||
export function ArticleEditor({ doc, onChange, contentKey }: ArticleEditorProps) {
|
||||
const { runSave } = useAdmin()
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
@@ -192,6 +204,8 @@ export function ArticleEditor({ doc, onChange }: ArticleEditorProps) {
|
||||
})
|
||||
return false
|
||||
},
|
||||
// Enter داخل تیتر، بلوک تازه را پاراگراف میکند تا قالب تیتر سرایت نکند
|
||||
handleKeyDown: (view, event) => handleHeadingEnter(view, event),
|
||||
},
|
||||
onUpdate: ({ editor: instance }) => onChange(instance.getJSON()),
|
||||
})
|
||||
@@ -201,14 +215,40 @@ export function ArticleEditor({ doc, onChange }: ArticleEditorProps) {
|
||||
if (!editor) return
|
||||
editor.view.dom.setAttribute('dir', direction)
|
||||
editor.view.dom.setAttribute('lang', direction === 'rtl' ? 'fa' : 'en')
|
||||
// تزئینهای دوزبانه (اندازه ۲ پیکسل کوچکتر متن لاتین) با جهت تازه بازمحاسبه میشوند
|
||||
editor.view.dispatch(editor.state.tr.setMeta(bidiPluginKey, 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) {
|
||||
if (!editor) return
|
||||
setUploading(true)
|
||||
await runSave(async () => {
|
||||
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)
|
||||
}
|
||||
@@ -271,28 +311,28 @@ export function ArticleEditor({ doc, onChange }: ArticleEditorProps) {
|
||||
<ToolButton
|
||||
label="پاراگراف"
|
||||
active={editor.isActive('paragraph')}
|
||||
onClick={() => editor.chain().focus().setParagraph().run()}
|
||||
onClick={() => applyBlockFormat(editor.view, 'paragraph')}
|
||||
>
|
||||
<PilcrowIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
label="تیتر ۱"
|
||||
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" />
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
label="تیتر ۲"
|
||||
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" />
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
label="تیتر ۳"
|
||||
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" />
|
||||
</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)
|
||||
}
|
||||
@@ -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 { DEFAULT_IMAGE_TITLE } from '../../data/constants'
|
||||
import { adminApi } from '../../admin/adminClient'
|
||||
import { useAdmin } from '../../admin/AdminContext'
|
||||
import { useSmartTextField } from './useSmartTextField'
|
||||
|
||||
type ImageFieldProps = {
|
||||
label: string
|
||||
@@ -30,6 +31,7 @@ const META_FIELD_CLASS =
|
||||
* انتخاب و آپلود تصویر به MinIO با پیشنمایش بزرگ.
|
||||
* ترتیب فیلدها: ۱) عنوان تصویر (پیشفرض «Photo 1») ۲) متن جایگزین (پیشفرض «{عنوان} - تصویر شاخص»).
|
||||
* هر دو «دستی یا پیشفرض» هستند و Alt هرگز از URL ساخته نمیشود.
|
||||
* پیشفرض خودکار با فوکوس پاک میشود تا کاربر بلافاصله تایپ کند و با بلورِ خالی برمیگردد.
|
||||
*/
|
||||
export function ImageField({
|
||||
label,
|
||||
@@ -47,40 +49,25 @@ export function ImageField({
|
||||
const titleId = useId()
|
||||
const altId = useId()
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [altText, setAltText] = useState(alt ?? '')
|
||||
const [titleText, setTitleText] = useState(title ?? '')
|
||||
|
||||
const showTitle = title !== undefined || !!onSaveTitle
|
||||
const showAlt = alt !== undefined || !!onSaveAlt
|
||||
|
||||
// همگامسازی با مقادیر ذخیرهشده (پس از بهروزرسانی خوشبینانه در درخت داده)
|
||||
useEffect(() => {
|
||||
setAltText(alt ?? '')
|
||||
}, [alt])
|
||||
useEffect(() => {
|
||||
setTitleText(title ?? '')
|
||||
}, [title])
|
||||
const titleField = useSmartTextField({
|
||||
value: title ?? '',
|
||||
fallback: titleFallback,
|
||||
hardFallback: DEFAULT_IMAGE_TITLE,
|
||||
onCommit: onSaveTitle,
|
||||
})
|
||||
const altField = useSmartTextField({
|
||||
value: alt ?? '',
|
||||
fallback: altFallback,
|
||||
hardFallback: label,
|
||||
onCommit: onSaveAlt,
|
||||
})
|
||||
|
||||
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) => {
|
||||
if (!file) return
|
||||
setUploading(true)
|
||||
@@ -89,18 +76,15 @@ export function ImageField({
|
||||
const { url } = await adminApi.upload(file)
|
||||
const ok = await onSave(url)
|
||||
if (!ok) throw new Error('ذخیره نشد')
|
||||
await persistMeta()
|
||||
// پیشفرضهای Alt/Title همان لحظه ثبت میشوند تا تصویر بدون متادیتا نماند
|
||||
if (showTitle) await titleField.commit()
|
||||
if (showAlt) await altField.commit()
|
||||
})
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const commitMeta = () => {
|
||||
if (!onSaveTitle && !onSaveAlt) return
|
||||
void persistMeta()
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-bold text-ivory/80">{label}</p>
|
||||
@@ -110,8 +94,8 @@ export function ImageField({
|
||||
{value ? (
|
||||
<img
|
||||
src={value}
|
||||
alt={altText || label}
|
||||
title={titleText || undefined}
|
||||
alt={altField.display || label}
|
||||
title={titleField.display || undefined}
|
||||
className="h-[27rem] w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
@@ -149,10 +133,11 @@ export function ImageField({
|
||||
id={titleId}
|
||||
type="text"
|
||||
dir="auto"
|
||||
value={titleDisplayed}
|
||||
onChange={(event) => setTitleText(event.target.value)}
|
||||
onBlur={commitMeta}
|
||||
placeholder={titleFallback || DEFAULT_IMAGE_TITLE}
|
||||
value={titleField.display}
|
||||
onChange={(event) => titleField.onChange(event.target.value)}
|
||||
onFocus={titleField.onFocus}
|
||||
onBlur={titleField.onBlur}
|
||||
placeholder={titleFallback.trim() || DEFAULT_IMAGE_TITLE}
|
||||
className={META_FIELD_CLASS}
|
||||
/>
|
||||
</div>
|
||||
@@ -167,11 +152,12 @@ export function ImageField({
|
||||
id={altId}
|
||||
type="text"
|
||||
dir="auto"
|
||||
value={altDisplayed}
|
||||
onChange={(event) => setAltText(event.target.value)}
|
||||
onBlur={commitMeta}
|
||||
value={altField.display}
|
||||
onChange={(event) => altField.onChange(event.target.value)}
|
||||
onFocus={altField.onFocus}
|
||||
onBlur={altField.onBlur}
|
||||
placeholder={
|
||||
altFallback || 'توضیح کوتاه تصویر برای سئو و صفحهخوانها'
|
||||
altFallback.trim() || 'توضیح کوتاه تصویر برای سئو و صفحهخوانها'
|
||||
}
|
||||
className={META_FIELD_CLASS}
|
||||
/>
|
||||
|
||||
@@ -13,29 +13,48 @@ type ImageAttrs = {
|
||||
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'
|
||||
|
||||
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>
|
||||
* دو دکمه حذف مستقل دارد: یکی برای خود تصویر و یکی برای حذف کامل کپشن.
|
||||
* با حذف کپشن، عنصر <figcaption> بهطور کامل از DOM حذف میشود و فقط تصویر میماند.
|
||||
*
|
||||
* ویرایش کاملاً درونخطی است (بدون پاپاور/مودال):
|
||||
* - هر تصویر یک «جعبه توضیح/کپشن» همراه خود دارد و متن آن در figcaption ذخیره میشود.
|
||||
* - دکمه X کپشن را کامل حذف میکند: صفت کپشن به مقدار پیشفرض برمیگردد و عنصر
|
||||
* figcaption با ورودیاش از DOM ویرایشگر (و از خروجی ذخیرهشده) حذف میشود؛
|
||||
* فقط تصویر میماند و «افزودن کپشن» دوباره جعبه را باز میکند.
|
||||
* - ویرایش Alt در ویرایشگر وجود ندارد؛ Alt هنگام درج از نام فایل ثبت میشود.
|
||||
* - دکمه X روی تصویر کل تصویر را حذف میکند.
|
||||
*/
|
||||
export function ImageNodeView({ node, updateAttributes, deleteNode, selected }: NodeViewProps) {
|
||||
const { src, alt, title, caption } = node.attrs as ImageAttrs
|
||||
const hasCaption = Boolean((caption ?? '').trim())
|
||||
const [editingCaption, setEditingCaption] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [captionOpen, setCaptionOpen] = useState(true)
|
||||
const captionInputRef = useRef<HTMLInputElement>(null)
|
||||
const focusOnOpen = useRef(false)
|
||||
const showCaption = captionOpen || hasCaption
|
||||
|
||||
const showCaption = hasCaption || editingCaption
|
||||
|
||||
// هنگام افزودن کپشن تازه، ورودی خودکار فوکوس میگیرد
|
||||
// فقط وقتی کاربر «افزودن کپشن» را میزند ورودی فوکوس میگیرد (نه هنگام بارگذاری مقاله)
|
||||
useEffect(() => {
|
||||
if (editingCaption && !hasCaption) inputRef.current?.focus()
|
||||
}, [editingCaption, hasCaption])
|
||||
if (!focusOnOpen.current) return
|
||||
focusOnOpen.current = false
|
||||
captionInputRef.current?.focus()
|
||||
}, [captionOpen])
|
||||
|
||||
/** حذف کپشن: مقدار صفت پاک و عنصر figcaption کاملاً unmount میشود */
|
||||
/** حذف کامل کپشن: عنصر figcaption و ورودیاش از DOM ویرایشگر بیرون میرود */
|
||||
const removeCaption = () => {
|
||||
updateAttributes({ caption: '' })
|
||||
setEditingCaption(false)
|
||||
setCaptionOpen(false)
|
||||
}
|
||||
|
||||
const addCaption = () => {
|
||||
focusOnOpen.current = true
|
||||
setCaptionOpen(true)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -58,18 +77,18 @@ export function ImageNodeView({ node, updateAttributes, deleteNode, selected }:
|
||||
<XIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
{showCaption && (
|
||||
{showCaption ? (
|
||||
<figcaption className="article-figure__caption" contentEditable={false}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
ref={captionInputRef}
|
||||
type="text"
|
||||
value={caption ?? ''}
|
||||
onChange={(event) => updateAttributes({ caption: event.target.value })}
|
||||
placeholder="توضیح تصویر (کپشن)…"
|
||||
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
|
||||
type="button"
|
||||
onClick={removeCaption}
|
||||
@@ -80,21 +99,18 @@ export function ImageNodeView({ node, updateAttributes, deleteNode, selected }:
|
||||
<XIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</figcaption>
|
||||
)}
|
||||
</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"
|
||||
onClick={addCaption}
|
||||
className={ADD_CAPTION_CLASS}
|
||||
>
|
||||
<PlusIcon className="h-3 w-3" aria-hidden="true" />
|
||||
افزودن کپشن
|
||||
</button>
|
||||
)}
|
||||
</figure>
|
||||
</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 }
|
||||
}
|
||||
@@ -5,6 +5,9 @@
|
||||
/** عنوان پیشفرض تصویر، وقتی کاربر عنوانی وارد نکرده باشد */
|
||||
export const DEFAULT_IMAGE_TITLE = 'Photo 1'
|
||||
|
||||
/** متن جایگزین پیشفرض تصویرهای داخل متن (وقتی نام فایل چیزی به دست نمیدهد) */
|
||||
export const DEFAULT_IMAGE_ALT = 'تصویر مقاله'
|
||||
|
||||
/** پسوند پیشنهاد Alt برای تصویر شاخص — در کنار عنوان رکورد قرار میگیرد */
|
||||
export const FEATURED_IMAGE_ALT_SUFFIX = 'تصویر شاخص'
|
||||
|
||||
|
||||
+32
-1
@@ -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 {
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<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
|
||||
src={article.image}
|
||||
alt={article.image_alt || article.title}
|
||||
@@ -96,11 +113,11 @@ export function ArticleDetail() {
|
||||
<Link to="/" className="transition-colors hover:text-copper">
|
||||
صفحه اصلی
|
||||
</Link>
|
||||
<ChevronLeftIcon className="h-3.5 w-3.5" />
|
||||
{dir === 'ltr' ? <ChevronRightIcon className="h-3.5 w-3.5" /> : <ChevronLeftIcon className="h-3.5 w-3.5" />}
|
||||
<Link to="/blog" className="transition-colors hover:text-copper">
|
||||
وبلاگ
|
||||
</Link>
|
||||
<ChevronLeftIcon className="h-3.5 w-3.5" />
|
||||
{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>
|
||||
</nav>
|
||||
<h1 className="mt-5 max-w-[700px] text-hero font-bold text-ivory">{article.title}</h1>
|
||||
@@ -112,10 +129,14 @@ export function ArticleDetail() {
|
||||
|
||||
<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 grid max-w-5xl gap-12 lg:grid-cols-[minmax(0,1fr)_17rem] lg:gap-14">
|
||||
<div className="min-w-0">
|
||||
<div
|
||||
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>
|
||||
<p className="border-r-2 border-copper 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}
|
||||
</p>
|
||||
</Reveal>
|
||||
@@ -134,9 +155,12 @@ export function ArticleDetail() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* فهرست مطالب — در دسکتاپ کنار متن چسبان میماند */}
|
||||
{toc.length > 0 && (
|
||||
<aside className="order-first lg:order-none">
|
||||
{/* فهرست مطالب — در دسکتاپ کنار متن میچسبد و همراه مقاله پایین میآید */}
|
||||
{hasToc && (
|
||||
<aside
|
||||
dir={dir}
|
||||
className={`order-first lg:order-none lg:row-start-1 ${tocColumns}`}
|
||||
>
|
||||
<TableOfContents items={toc} className="lg:sticky lg:top-28" />
|
||||
</aside>
|
||||
)}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -1,31 +1,27 @@
|
||||
import slugify from 'slugify'
|
||||
|
||||
/**
|
||||
* تبدیل متن (فارسی یا انگلیسی) به شناسه/لنگر امن.
|
||||
* slugify حروف فارسی و عربی را به معادل لاتین ترانویسی میکند تا لنگرهای
|
||||
* فهرست مطالب در همه مرورگرها ASCII، پایدار و قابل اشتراکگذاری بمانند.
|
||||
* تبدیل متن (فارسی یا انگلیسی) به شناسه/لنگر امن برای فهرست مطالب.
|
||||
* حروف و اعداد فارسی/عربی و لاتین در شناسه میمانند (در URL بهصورت percent-encoded
|
||||
* نوشته میشوند و در همه مرورگرها کار میکنند)؛ فقط نشانهها و فاصلهها به خط تیره
|
||||
* تبدیل میشوند. نیمفاصله و نویسههای نامرئی جهت هم حذف میشوند تا تیترهای تکراری
|
||||
* شناسه یکتا بگیرند.
|
||||
*/
|
||||
export function slugifyFa(input: string): string {
|
||||
const normalized = (input ?? '')
|
||||
return (input ?? '')
|
||||
.toString()
|
||||
.normalize('NFC')
|
||||
// نیمفاصله، اتصالدهنده و نویسههای نامرئی جهت → فاصله
|
||||
.replace(/[\u200b-\u200f\u202a-\u202e]/g, ' ')
|
||||
// نیمفاصله، اتصالدهنده و نویسههای نامرئی جهت → حذف
|
||||
.replace(/[\u200b-\u200f\u202a-\u202e\ufeff]/g, ' ')
|
||||
// یکسانسازی حروف عربی با معادل فارسی
|
||||
.replace(/[يى]/g, 'ی')
|
||||
.replace(/ك/g, 'ک')
|
||||
.replace(/[ۀة]/g, 'ه')
|
||||
.toLowerCase()
|
||||
// فقط حروف/اعداد یونیکد (فارسی، عربی، لاتین و...) و فاصله/خط تیره بماند
|
||||
.replace(/[^\p{L}\p{N}\s-]/gu, '')
|
||||
.trim()
|
||||
|
||||
const slug = slugify(normalized, {
|
||||
lower: true,
|
||||
trim: true,
|
||||
replacement: '-',
|
||||
// فقط حروف/اعداد/فاصله/خط تیره بماند — بقیه نشانهها حذف شوند
|
||||
remove: /[^\w\s-]/g,
|
||||
})
|
||||
|
||||
return slug.replace(/-{2,}/g, '-').replace(/^-+|-+$/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-{2,}/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
}
|
||||
|
||||
/** ساخت شناسه یکتا — تیترهای تکراری پسوند عددی میگیرند (مثل about، about-2) */
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
* هم چکلیست سئو و هم محاسبه زمان مطالعه از همینجا استفاده میکنند (DRY).
|
||||
*/
|
||||
|
||||
import type { ArticleBodyDoc } from '../data/site'
|
||||
|
||||
type DocNode = {
|
||||
type?: string
|
||||
text?: string
|
||||
@@ -73,3 +75,29 @@ export function firstParagraph(body: unknown): string {
|
||||
const paragraph = content?.find((child) => child?.type === '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 }
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@ import react from '@vitejs/plugin-react'
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
// در حالت توسعه، /api و /media به Traefik پروکسی میشوند (پورت ۵۱۷۳ روی هاست)
|
||||
port: 5174, // سرور توسعه فرانتاند روی این پورت اجرا میشود تا تداخل نکند
|
||||
strictPort: true, // جلوگیری از تغییر خودکار پورت
|
||||
proxy: {
|
||||
// در حالت توسعه، درخواستهای API و Media به Traefik (پورت ۵۱۷۳) فرستاده میشوند
|
||||
'/api': 'http://localhost:5173',
|
||||
'/media': 'http://localhost:5173',
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user