fix(admin): resolve Tiptap editor bugs, dynamic RTL/LTR layout, sticky TOC, and equalize Bidi typography
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user