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.
This commit is contained in:
@@ -1,53 +1,63 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { useEffect, 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 { Mark, mergeAttributes } from '@tiptap/core'
|
||||
import {
|
||||
AlignLeftIcon,
|
||||
AlignRightIcon,
|
||||
BoldIcon,
|
||||
ItalicIcon,
|
||||
Heading1Icon,
|
||||
Heading2Icon,
|
||||
Heading3Icon,
|
||||
ImagePlusIcon,
|
||||
LanguagesIcon,
|
||||
ItalicIcon,
|
||||
Link2Icon,
|
||||
ListIcon,
|
||||
ListOrderedIcon,
|
||||
Loader2Icon,
|
||||
PilcrowIcon,
|
||||
QuoteIcon,
|
||||
Redo2Icon,
|
||||
StrikethroughIcon,
|
||||
UnderlineIcon,
|
||||
Undo2Icon,
|
||||
Loader2Icon,
|
||||
} from 'lucide-react'
|
||||
import Swal from 'sweetalert2'
|
||||
import { useAdmin } from '../../admin/AdminContext'
|
||||
import { adminApi } from '../../admin/adminClient'
|
||||
import { ENGLISH_TEXT_MARK, EnglishText } from './EnglishText'
|
||||
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 SmallSizeMark = Mark.create({
|
||||
/**
|
||||
* مارکهای قدیمی (سایز ۱۲ و متن انگلیسی) — کنترل آنها از نوار ابزار برداشته شده و
|
||||
* فقط برای اینکه محتوای قبلی از دست نرود در شِما نگه داشته شدهاند.
|
||||
*/
|
||||
const LegacySmallSizeMark = Mark.create({
|
||||
name: 'smallSize',
|
||||
parseHTML() { return [{ tag: 'span[data-small]' }] },
|
||||
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] },
|
||||
}),
|
||||
Image.configure({ allowBase64: false }),
|
||||
ArticleImage.configure({ allowBase64: false }),
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' },
|
||||
isAllowedUri: (uri: string) => /^(https?:\/\/|\/|#|mailto:|tel:)/i.test(uri),
|
||||
isAllowedUri: (uri: string) => isAllowedHref(uri),
|
||||
}),
|
||||
SmallSizeMark,
|
||||
LegacySmallSizeMark,
|
||||
EnglishText,
|
||||
]
|
||||
|
||||
@@ -56,6 +66,30 @@ type ArticleEditorProps = {
|
||||
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,
|
||||
@@ -72,6 +106,7 @@ function ToolButton({
|
||||
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 ${
|
||||
@@ -87,10 +122,16 @@ function ToolButton({
|
||||
|
||||
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,
|
||||
@@ -99,16 +140,30 @@ export function ArticleEditor({ doc, onChange }: ArticleEditorProps) {
|
||||
editorProps: {
|
||||
attributes: {
|
||||
dir: 'rtl',
|
||||
lang: 'fa',
|
||||
class: 'article-body min-h-[300px] px-4 py-4 focus:outline-none',
|
||||
'data-wysiwyg': 'true',
|
||||
},
|
||||
handlePaste: (_event_ignored, event) => {
|
||||
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) => {
|
||||
@@ -121,176 +176,217 @@ export function ArticleEditor({ doc, onChange }: ArticleEditorProps) {
|
||||
}
|
||||
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)
|
||||
|
||||
// استفاده از ریگکس یونیکد اصلاحشده برای نام فایل و Alt
|
||||
const originalName = file.name.replace(/\.[^.]+$/, '')
|
||||
const cleanAlt = originalName.replace(/[^\w\u0600-\u06FF\s-]/g, '-').replace(/\s+/g, '-').toLowerCase()
|
||||
|
||||
await runSave(async () => {
|
||||
const { url } = await adminApi.upload(file)
|
||||
editor.chain().focus().setImage({ src: url, alt: cleanAlt }).run()
|
||||
editor.chain().focus().setImage({ src: url, alt: file.name.replace(/\.[^.]+$/, '') }).run()
|
||||
})
|
||||
setUploading(false)
|
||||
}
|
||||
|
||||
const setLink = () => {
|
||||
/** باز کردن مودال برای لینک انتخاب فعلی (یا ویرایش لینک زیر نشانگر) */
|
||||
const openLinkModalForSelection = () => {
|
||||
if (!editor) return
|
||||
if (editor.isActive('link')) {
|
||||
editor.chain().focus().unsetLink().run()
|
||||
return
|
||||
const attrs = editor.getAttributes('link') as {
|
||||
href?: string | null
|
||||
target?: string | null
|
||||
rel?: string | null
|
||||
}
|
||||
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',
|
||||
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,
|
||||
},
|
||||
}).then((result) => {
|
||||
if (!result.isConfirmed || !result.value) return
|
||||
editor.chain().focus().extendMarkRange('link').setLink({ href: result.value }).run()
|
||||
})
|
||||
}
|
||||
|
||||
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">
|
||||
<div className="flex flex-wrap items-center gap-1 border-b border-ivory/10 px-2 py-1.5">
|
||||
|
||||
{/* قالببندی بلوک — پاراگراف و تیترها با سایز مشخص */}
|
||||
<ToolButton
|
||||
label="پاراگراف (۱۴ پیکسل)"
|
||||
active={editor.isActive('paragraph')}
|
||||
onClick={() => editor.chain().focus().setParagraph().run()}
|
||||
<>
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
<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 />
|
||||
<Divider />
|
||||
|
||||
<select
|
||||
className="h-8 bg-ink border border-ivory/15 text-ivory/90 text-xs px-2 outline-none focus:border-copper cursor-pointer transition-colors"
|
||||
value={editor.isActive('smallSize') ? '12' : '14'}
|
||||
{/* جهت متن — فقط محتوای ویرایشگر را عوض میکند */}
|
||||
<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) => {
|
||||
if (e.target.value === '12') {
|
||||
editor.chain().focus().setMark('smallSize').run()
|
||||
} else {
|
||||
editor.chain().focus().unsetMark('smallSize').run()
|
||||
}
|
||||
const file = e.target.files?.[0]
|
||||
if (file) void uploadAndInsert(file)
|
||||
e.target.value = ''
|
||||
}}
|
||||
>
|
||||
<option value="14">سایز ۱۴ (فارسی)</option>
|
||||
<option value="12">سایز ۱۲ (انگلیسی)</option>
|
||||
</select>
|
||||
|
||||
{/* متن انگلیسی — span با dir=ltr و سایز ۱۲ پیکسل */}
|
||||
<ToolButton
|
||||
label="متن انگلیسی (۱۲ پیکسل، چپبهراست)"
|
||||
active={editor.isActive(ENGLISH_TEXT_MARK)}
|
||||
onClick={() => editor.chain().focus().toggleMark(ENGLISH_TEXT_MARK).run()}
|
||||
>
|
||||
<LanguagesIcon 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={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>
|
||||
{/* مودال لینک — انیمیشن ورود/خروج را خودش با کلاسهای Tailwind مدیریت میکند */}
|
||||
{linkModal.open && (
|
||||
<LinkModal
|
||||
initial={linkModal.initial}
|
||||
editing={linkModal.editing}
|
||||
onApply={applyLink}
|
||||
onRemove={removeLink}
|
||||
onClose={() => setLinkModal((state) => ({ ...state, open: false }))}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user