فرانت‌اند: ویرایشگر WYSIWYG وبلاگ (TipTap) با آپلود تصویر در متن

- ArticleEditor: ویرایشگر TipTap v3 راست‌به‌چسب با نوار ابزار فارسی —
  بولد/ایتالیک/زیرخط/خط‌خورده، تیتر H2/H3، لیست نقطه‌ای/شماره‌ای،
  نقل‌قول، لینک (مسدودسازی javascript: با isAllowedUri)، واگرد/ازنو
- درج تصویر داخل متن: دکمه آپلود، چسباندن یا کشیدن فایل — ذخیره در
  MinIO از طریق /api/admin/upload و درج URL نسبی /media/...
- ذخیره خودکار: debounce ۸۰۰ms + flush هنگام بستن کشو
- کشوی مقاله عریض‌تر (max-w-3xl)؛ فیلد body مقالات به‌جای لیست متنی،
  ویرایشگر WYSIWYG رندر می‌کند
- رندر صفحه مقاله: تبدیل سند به HTML با @tiptap/static-renderer
  (فقط تگ‌های شناخته‌شده — بدون ریسک XSS) + استایل‌های .article-body
  (تیتر با خط مسی، لیست، نقل‌قول، تصویر) + پشتیبانی از ردیف‌های قدیمی
- وابستگی‌های جدید: @tiptap/react، @tiptap/pm، @tiptap/starter-kit،
  @tiptap/extension-image، @tiptap/extension-link، @tiptap/static-renderer
This commit is contained in:
MMD
2026-09-06 23:33:08 +03:30
parent 8f11df99ea
commit cfe5512ec7
8 changed files with 1188 additions and 16 deletions
@@ -0,0 +1,267 @@
import { useRef, useState } from 'react'
import { EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Image from '@tiptap/extension-image'
import Link from '@tiptap/extension-link'
import {
BoldIcon,
ItalicIcon,
Heading2Icon,
Heading3Icon,
ImagePlusIcon,
Link2Icon,
ListIcon,
ListOrderedIcon,
QuoteIcon,
Redo2Icon,
StrikethroughIcon,
UnderlineIcon,
Undo2Icon,
Loader2Icon,
} from 'lucide-react'
import Swal from 'sweetalert2'
import { useAdmin } from '../../admin/AdminContext'
import { adminApi } from '../../admin/adminClient'
/** اکستنشن‌های مشترک ویرایشگر و رندر مقاله — هر دو باید یکسان باشند */
export const ARTICLE_EXTENSIONS = [
StarterKit.configure({
link: false,
heading: { levels: [2, 3] },
}),
Image.configure({ allowBase64: false }),
Link.configure({
openOnClick: false,
HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' },
isAllowedUri: (uri: string) => /^(https?:\/\/|\/|#|mailto:|tel:)/i.test(uri),
}),
]
type ArticleEditorProps = {
doc: Record<string, unknown>
onChange: (json: Record<string, unknown>) => void
}
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}
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 editor = useEditor({
extensions: ARTICLE_EXTENSIONS,
content: doc && Object.keys(doc).length ? doc : undefined,
immediatelyRender: false,
editorProps: {
attributes: {
dir: 'rtl',
class: 'article-body min-h-[300px] px-4 py-4 focus:outline-none',
'data-wysiwyg': 'true',
},
handlePaste: (_event_ignored, event) => {
const file = event.clipboardData?.files?.[0]
if (file && file.type.startsWith('image/')) {
event.preventDefault()
void uploadAndInsert(file)
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
},
},
onUpdate: ({ editor: instance }) => onChange(instance.getJSON()),
})
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 setLink = () => {
if (!editor) return
if (editor.isActive('link')) {
editor.chain().focus().unsetLink().run()
return
}
void Swal.fire({
title: 'نشانی لینک',
input: 'text',
inputValue: 'https://',
background: '#1e1a16',
color: '#F5F3EF',
showCancelButton: true,
confirmButtonText: 'ثبت',
cancelButtonText: 'انصراف',
buttonsStyling: false,
customClass: {
popup: 'font-pinar border border-ivory/10',
title: 'font-pinar font-bold text-ivory',
input: 'font-pinar',
confirmButton: 'bg-copper text-char font-bold px-8 py-2.5 mx-2',
cancelButton: 'border border-ivory/30 text-ivory font-light px-8 py-2.5 mx-2',
},
}).then((result) => {
if (!result.isConfirmed || !result.value) return
editor.chain().focus().extendMarkRange('link').setLink({ href: result.value }).run()
})
}
if (!editor) return null
return (
<div className="border border-ivory/15 bg-char">
<div className="flex flex-wrap items-center gap-0.5 border-b border-ivory/10 px-2 py-1.5">
<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('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={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={setLink}>
<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>
)
}