433 lines
16 KiB
TypeScript
433 lines
16 KiB
TypeScript
import { useEffect, useRef, useState } from 'react'
|
||
import { EditorContent, useEditor } from '@tiptap/react'
|
||
import StarterKit from '@tiptap/starter-kit'
|
||
import Link from '@tiptap/extension-link'
|
||
import { Mark, mergeAttributes } from '@tiptap/core'
|
||
import {
|
||
AlignLeftIcon,
|
||
AlignRightIcon,
|
||
BoldIcon,
|
||
Heading1Icon,
|
||
Heading2Icon,
|
||
Heading3Icon,
|
||
ImagePlusIcon,
|
||
ItalicIcon,
|
||
Link2Icon,
|
||
ListIcon,
|
||
ListOrderedIcon,
|
||
Loader2Icon,
|
||
PilcrowIcon,
|
||
QuoteIcon,
|
||
Redo2Icon,
|
||
StrikethroughIcon,
|
||
UnderlineIcon,
|
||
Undo2Icon,
|
||
} 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'
|
||
|
||
/**
|
||
* مارکهای قدیمی (سایز ۱۲ و متن انگلیسی) — کنترل آنها از نوار ابزار برداشته شده و
|
||
* فقط برای اینکه محتوای قبلی از دست نرود در شِما نگه داشته شدهاند.
|
||
*/
|
||
const LegacySmallSizeMark = Mark.create({
|
||
name: 'smallSize',
|
||
parseHTML() {
|
||
return [{ tag: 'span[data-small]' }]
|
||
},
|
||
renderHTML({ HTMLAttributes }) {
|
||
return ['span', mergeAttributes(HTMLAttributes, { 'data-small': 'true' }), 0]
|
||
},
|
||
})
|
||
|
||
/** اکستنشنهای مشترک ویرایشگر و رندر مقاله — هر دو باید یکسان باشند */
|
||
export const ARTICLE_EXTENSIONS = [
|
||
StarterKit.configure({
|
||
link: false,
|
||
heading: { levels: [1, 2, 3] },
|
||
}),
|
||
ArticleImage.configure({ allowBase64: false }),
|
||
Link.configure({
|
||
openOnClick: false,
|
||
HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' },
|
||
isAllowedUri: (uri: string) => isAllowedHref(uri),
|
||
}),
|
||
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'
|
||
|
||
/** نحوه اعمال لینک: روی انتخاب فعلی، درج متن تازه یا تبدیل محدوده تایپشده */
|
||
type LinkApplyMode = 'selection' | 'insert' | 'range'
|
||
|
||
type LinkModalState = {
|
||
open: boolean
|
||
editing: boolean
|
||
mode: LinkApplyMode
|
||
range: { from: number; to: number } | null
|
||
initial: LinkModalValue
|
||
}
|
||
|
||
const CLOSED_LINK_MODAL: LinkModalState = {
|
||
open: false,
|
||
editing: false,
|
||
mode: 'selection',
|
||
range: null,
|
||
initial: { href: '', newTab: true, nofollow: false },
|
||
}
|
||
|
||
/** چند نویسه پیش از نشانگر برای تشخیص نشانیِ تایپشده بررسی میشود */
|
||
const URL_LOOKBACK = 300
|
||
|
||
function ToolButton({
|
||
onClick,
|
||
active = false,
|
||
label,
|
||
children,
|
||
}: {
|
||
onClick: () => void
|
||
active?: boolean
|
||
label: string
|
||
children: React.ReactNode
|
||
}) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
title={label}
|
||
aria-label={label}
|
||
aria-pressed={active}
|
||
onMouseDown={(e) => e.preventDefault()}
|
||
onClick={onClick}
|
||
className={`flex h-8 w-8 items-center justify-center border transition-colors duration-150 ${
|
||
active
|
||
? 'border-copper/50 bg-copper/15 text-copper'
|
||
: 'border-transparent text-ivory/60 hover:border-ivory/15 hover:text-copper'
|
||
}`}
|
||
>
|
||
{children}
|
||
</button>
|
||
)
|
||
}
|
||
|
||
const Divider = () => <span className="mx-1 h-5 w-px bg-ivory/10" aria-hidden="true" />
|
||
|
||
/**
|
||
* ویرایشگر WYSIWYG مقاله (TipTap) — نود تصویر کپشندار، مودال لینک سئو-محور،
|
||
* کلید راستچین/چپچین (فقط روی متن اثر دارد، نه نوار ابزار) و آپلود تصویر به MinIO.
|
||
*/
|
||
export function ArticleEditor({ doc, onChange, contentKey }: ArticleEditorProps) {
|
||
const { runSave } = useAdmin()
|
||
const fileRef = useRef<HTMLInputElement>(null)
|
||
const [uploading, setUploading] = useState(false)
|
||
const [direction, setDirection] = useState<TextDirection>('rtl')
|
||
const [linkModal, setLinkModal] = useState<LinkModalState>(CLOSED_LINK_MODAL)
|
||
|
||
const editor = useEditor({
|
||
extensions: ARTICLE_EXTENSIONS,
|
||
content: doc && Object.keys(doc).length ? doc : undefined,
|
||
immediatelyRender: false,
|
||
editorProps: {
|
||
attributes: {
|
||
dir: 'rtl',
|
||
lang: 'fa',
|
||
class: 'article-body min-h-[300px] px-4 py-4 focus:outline-none',
|
||
'data-wysiwyg': 'true',
|
||
},
|
||
handlePaste: (_view, event, _slice) => {
|
||
const file = event.clipboardData?.files?.[0]
|
||
if (file && file.type.startsWith('image/')) {
|
||
event.preventDefault()
|
||
void uploadAndInsert(file)
|
||
return true
|
||
}
|
||
// چسباندن یک نشانی → مودال لینک بلافاصله باز میشود
|
||
const text = event.clipboardData?.getData('text/plain')?.trim() ?? ''
|
||
if (isUrlLike(text)) {
|
||
event.preventDefault()
|
||
setLinkModal({
|
||
open: true,
|
||
editing: false,
|
||
mode: 'insert',
|
||
range: null,
|
||
initial: { href: normalizeHref(text) ?? text, newTab: true, nofollow: false },
|
||
})
|
||
return true
|
||
}
|
||
return false
|
||
},
|
||
handleDrop: (_view, event, _slice, moved) => {
|
||
if (moved) return false
|
||
const file = event.dataTransfer?.files?.[0]
|
||
if (file && file.type.startsWith('image/')) {
|
||
event.preventDefault()
|
||
void uploadAndInsert(file)
|
||
return true
|
||
}
|
||
return false
|
||
},
|
||
// پایان تایپ یک نشانی (فاصله بعد از آن) → مودال لینک باز میشود
|
||
handleTextInput: (view, from, to, text) => {
|
||
if (text !== ' ' || from !== to) return false
|
||
const start = Math.max(0, from - URL_LOOKBACK)
|
||
const before = view.state.doc.textBetween(start, from, ' ', ' ').trimEnd()
|
||
const lastToken = before.slice(before.lastIndexOf(' ') + 1)
|
||
if (!isUrlLike(lastToken)) return false
|
||
setLinkModal({
|
||
open: true,
|
||
editing: false,
|
||
mode: 'range',
|
||
range: { from: from - lastToken.length, to: from },
|
||
initial: { href: normalizeHref(lastToken) ?? lastToken, newTab: true, nofollow: false },
|
||
})
|
||
return false
|
||
},
|
||
// Enter داخل تیتر، بلوک تازه را پاراگراف میکند تا قالب تیتر سرایت نکند
|
||
handleKeyDown: (view, event) => handleHeadingEnter(view, event),
|
||
},
|
||
onUpdate: ({ editor: instance }) => onChange(instance.getJSON()),
|
||
})
|
||
|
||
// جهت متن فقط روی ناحیه ویرایش اعمال میشود؛ نوار ابزار همیشه راستچین میماند
|
||
useEffect(() => {
|
||
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)
|
||
// نام پیشفرض از نام فایل ساخته میشود و در نبود آن، مقدار پیشفرض ثابت میماند
|
||
const defaultAlt = file.name.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').trim()
|
||
editor
|
||
.chain()
|
||
.focus()
|
||
.setImage({ src: url, alt: defaultAlt || DEFAULT_IMAGE_ALT })
|
||
.run()
|
||
})
|
||
setUploading(false)
|
||
}
|
||
|
||
/** باز کردن مودال برای لینک انتخاب فعلی (یا ویرایش لینک زیر نشانگر) */
|
||
const openLinkModalForSelection = () => {
|
||
if (!editor) return
|
||
const attrs = editor.getAttributes('link') as {
|
||
href?: string | null
|
||
target?: string | null
|
||
rel?: string | null
|
||
}
|
||
const editing = Boolean(attrs.href)
|
||
setLinkModal({
|
||
open: true,
|
||
editing,
|
||
mode: 'selection',
|
||
range: null,
|
||
initial: {
|
||
href: attrs.href ?? '',
|
||
newTab: editing ? attrs.target === '_blank' : true,
|
||
nofollow: editing ? parseLinkRel(attrs.rel).nofollow : false,
|
||
},
|
||
})
|
||
}
|
||
|
||
const applyLink = ({ href, newTab, nofollow }: LinkModalValue) => {
|
||
if (!editor) return
|
||
const linkAttrs = { href, target: newTab ? '_blank' : null, rel: buildLinkRel({ newTab, nofollow }) }
|
||
const chain = editor.chain().focus()
|
||
|
||
if (linkModal.mode === 'insert') {
|
||
// نشانی چسباندهشده: متن لینک تازه درج میشود
|
||
chain.insertContent({ type: 'text', text: href, marks: [{ type: 'link', attrs: linkAttrs }] }).run()
|
||
} else if (linkModal.mode === 'range' && linkModal.range) {
|
||
// نشانی تایپشده: همان محدوده به لینک تبدیل میشود
|
||
chain.setTextSelection(linkModal.range).extendMarkRange('link').setLink(linkAttrs).run()
|
||
} else {
|
||
chain.extendMarkRange('link').setLink(linkAttrs).run()
|
||
}
|
||
|
||
setLinkModal((state) => ({ ...state, open: false }))
|
||
}
|
||
|
||
const removeLink = () => {
|
||
editor?.chain().focus().extendMarkRange('link').unsetLink().run()
|
||
setLinkModal((state) => ({ ...state, open: false }))
|
||
}
|
||
|
||
if (!editor) return null
|
||
|
||
return (
|
||
<>
|
||
<div className="border border-ivory/15 bg-char" data-dir={direction}>
|
||
{/* نوار ابزار: همیشه راستچین و در بالای ویرایشگر ثابت میماند */}
|
||
<div
|
||
dir="rtl"
|
||
className="sticky top-14 z-20 flex flex-wrap items-center gap-1 border-b border-ivory/10 bg-char px-2 py-1.5"
|
||
>
|
||
<ToolButton
|
||
label="پاراگراف"
|
||
active={editor.isActive('paragraph')}
|
||
onClick={() => applyBlockFormat(editor.view, 'paragraph')}
|
||
>
|
||
<PilcrowIcon className="h-4 w-4" />
|
||
</ToolButton>
|
||
<ToolButton
|
||
label="تیتر ۱"
|
||
active={editor.isActive('heading', { level: 1 })}
|
||
onClick={() => applyBlockFormat(editor.view, 1)}
|
||
>
|
||
<Heading1Icon className="h-4 w-4" />
|
||
</ToolButton>
|
||
<ToolButton
|
||
label="تیتر ۲"
|
||
active={editor.isActive('heading', { level: 2 })}
|
||
onClick={() => applyBlockFormat(editor.view, 2)}
|
||
>
|
||
<Heading2Icon className="h-4 w-4" />
|
||
</ToolButton>
|
||
<ToolButton
|
||
label="تیتر ۳"
|
||
active={editor.isActive('heading', { level: 3 })}
|
||
onClick={() => applyBlockFormat(editor.view, 3)}
|
||
>
|
||
<Heading3Icon className="h-4 w-4" />
|
||
</ToolButton>
|
||
|
||
<Divider />
|
||
|
||
{/* جهت متن — فقط محتوای ویرایشگر را عوض میکند */}
|
||
<ToolButton
|
||
label="راستچین (فارسی)"
|
||
active={direction === 'rtl'}
|
||
onClick={() => setDirection('rtl')}
|
||
>
|
||
<AlignRightIcon className="h-4 w-4" />
|
||
</ToolButton>
|
||
<ToolButton
|
||
label="چپچین (انگلیسی)"
|
||
active={direction === 'ltr'}
|
||
onClick={() => setDirection('ltr')}
|
||
>
|
||
<AlignLeftIcon className="h-4 w-4" />
|
||
</ToolButton>
|
||
|
||
<Divider />
|
||
|
||
<ToolButton label="بولد" active={editor.isActive('bold')} onClick={() => editor.chain().focus().toggleBold().run()}>
|
||
<BoldIcon className="h-4 w-4" />
|
||
</ToolButton>
|
||
<ToolButton label="ایتالیک" active={editor.isActive('italic')} onClick={() => editor.chain().focus().toggleItalic().run()}>
|
||
<ItalicIcon className="h-4 w-4" />
|
||
</ToolButton>
|
||
<ToolButton label="زیرخط" active={editor.isActive('underline')} onClick={() => editor.chain().focus().toggleUnderline().run()}>
|
||
<UnderlineIcon className="h-4 w-4" />
|
||
</ToolButton>
|
||
<ToolButton label="خطخورده" active={editor.isActive('strike')} onClick={() => editor.chain().focus().toggleStrike().run()}>
|
||
<StrikethroughIcon className="h-4 w-4" />
|
||
</ToolButton>
|
||
|
||
<Divider />
|
||
|
||
<ToolButton label="لیست نقطهای" active={editor.isActive('bulletList')} onClick={() => editor.chain().focus().toggleBulletList().run()}>
|
||
<ListIcon className="h-4 w-4" />
|
||
</ToolButton>
|
||
<ToolButton label="لیست شمارهای" active={editor.isActive('orderedList')} onClick={() => editor.chain().focus().toggleOrderedList().run()}>
|
||
<ListOrderedIcon className="h-4 w-4" />
|
||
</ToolButton>
|
||
<ToolButton label="نقلقول" active={editor.isActive('blockquote')} onClick={() => editor.chain().focus().toggleBlockquote().run()}>
|
||
<QuoteIcon className="h-4 w-4" />
|
||
</ToolButton>
|
||
|
||
<Divider />
|
||
|
||
<ToolButton label="افزودن لینک" active={editor.isActive('link')} onClick={openLinkModalForSelection}>
|
||
<Link2Icon className="h-4 w-4" />
|
||
</ToolButton>
|
||
<ToolButton label="درج تصویر" onClick={() => fileRef.current?.click()}>
|
||
{uploading ? <Loader2Icon className="h-4 w-4 animate-spin" /> : <ImagePlusIcon className="h-4 w-4" />}
|
||
</ToolButton>
|
||
|
||
<Divider />
|
||
|
||
<ToolButton label="واگرد" onClick={() => editor.chain().focus().undo().run()}>
|
||
<Undo2Icon className="h-4 w-4" />
|
||
</ToolButton>
|
||
<ToolButton label="ازنو" onClick={() => editor.chain().focus().redo().run()}>
|
||
<Redo2Icon className="h-4 w-4" />
|
||
</ToolButton>
|
||
</div>
|
||
|
||
<EditorContent editor={editor} />
|
||
|
||
<input
|
||
ref={fileRef}
|
||
type="file"
|
||
data-editor-file-input
|
||
accept="image/jpeg,image/png,image/webp,image/gif,image/avif"
|
||
className="hidden"
|
||
onChange={(e) => {
|
||
const file = e.target.files?.[0]
|
||
if (file) void uploadAndInsert(file)
|
||
e.target.value = ''
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
{/* مودال لینک — انیمیشن ورود/خروج را خودش با کلاسهای Tailwind مدیریت میکند */}
|
||
{linkModal.open && (
|
||
<LinkModal
|
||
initial={linkModal.initial}
|
||
editing={linkModal.editing}
|
||
onApply={applyLink}
|
||
onRemove={removeLink}
|
||
onClose={() => setLinkModal((state) => ({ ...state, open: false }))}
|
||
/>
|
||
)}
|
||
</>
|
||
)
|
||
}
|