Files
aramesh-backend/frontend/src/components/admin/ArticleEditor.tsx
T
fatemeh sane 049291c109 feat(admin): enhance editor UI (links, image captions) & fix categories
Added animated auto-opening link modal, custom image node with isolated caption deletion, fixed single-select category data binding, improved featured image layout, and updated README.
2026-09-15 11:33:04 +03:30

393 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { buildLinkRel, isAllowedHref, isUrlLike, normalizeHref, parseLinkRel } from '../../utils/link'
import { ArticleImage } from './ArticleImage'
import { EnglishText } from './EnglishText'
import { LinkModal } from './LinkModal'
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,
]
type ArticleEditorProps = {
doc: Record<string, unknown>
onChange: (json: Record<string, unknown>) => void
}
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 }: 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
},
},
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, direction])
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()
})
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={() => editor.chain().focus().setParagraph().run()}
>
<PilcrowIcon className="h-4 w-4" />
</ToolButton>
<ToolButton
label="تیتر ۱"
active={editor.isActive('heading', { level: 1 })}
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
>
<Heading1Icon className="h-4 w-4" />
</ToolButton>
<ToolButton
label="تیتر ۲"
active={editor.isActive('heading', { level: 2 })}
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
>
<Heading2Icon className="h-4 w-4" />
</ToolButton>
<ToolButton
label="تیتر ۳"
active={editor.isActive('heading', { level: 3 })}
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
>
<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 }))}
/>
)}
</>
)
}