'use client' import { EditorContent, useEditor } from '@tiptap/react' import StarterKit from '@tiptap/starter-kit' import Underline from '@tiptap/extension-underline' import Link from '@tiptap/extension-link' import TextAlign from '@tiptap/extension-text-align' import { Color } from '@tiptap/extension-color' import { TextStyle } from '@tiptap/extension-text-style' import Highlight from '@tiptap/extension-highlight' import Image from '@tiptap/extension-image' import React, { useCallback, useEffect, useRef, useState } from 'react' import { buttonVariants } from '@heroui/react' import { Dropdown, DropdownItem, DropdownMenu, DropdownTrigger } from '@/components/heroui/Dropdown' import { addToast } from '@/lib/toast' import { texts } from '@/texts' import Button from '@/components/formElements/Button' import Input from '@/components/formElements/Input' import FileUpload from '@/components/media/FileUpload' import ConsumerModal from '@/components/consumer/ConsumerModal' import { fileAddress, coerceToString } from '@/helpers' import { cn } from '@/lib/cn' const CONSUMER_TOOLBAR_TRIGGER_CLASS = 'min-h-10 rounded-[10px] border border-consumer-border bg-consumer-surface px-3 text-sm font-medium text-consumer-text outline-none' const ADMIN_TOOLBAR_TRIGGER_CLASS = cn(buttonVariants({ size: 'sm', variant: 'secondary' }), 'outline-none') interface TextEditorProps { label?: string required?: boolean value?: string className?: string variant?: 'default' | 'consumer' onChange: (value: string) => void } const CONSUMER_TEXT_COLORS = [ { label: 'رنگ پیش‌فرض', value: null, swatchClassName: 'bg-consumer-text' }, { label: 'آبی', value: '#2563EB', swatchClassName: 'bg-blue-600' }, { label: 'بنفش', value: '#7C3AED', swatchClassName: 'bg-violet-600' }, { label: 'سبز', value: '#15803D', swatchClassName: 'bg-green-700' }, { label: 'نارنجی', value: '#C2410C', swatchClassName: 'bg-orange-700' }, { label: 'قرمز', value: '#B91C1C', swatchClassName: 'bg-red-700' }, ] as const const TextEditor = ({ label, required, value, onChange, className, variant = 'default' }: TextEditorProps) => { const [imageId, setImageId] = useState('') const [imageAlt, setImageAlt] = useState('') const [isOpenUploadImageModal, setIsOpenUploadImageModal] = useState(false) const isConsumer = variant === 'consumer' // جلوگیری از destroy/rebind با هر رندر والد (مثل inline onChange در فرم‌ها) const onChangeRef = useRef(onChange) useEffect(() => { onChangeRef.current = onChange }, [onChange]) const editor = useEditor({ extensions: [ StarterKit, Underline, TextStyle, Color, Highlight, Image.configure({ allowBase64: true, }), TextAlign.configure({ types: ['heading', 'paragraph'], }), Link.configure({ openOnClick: false, autolink: true, defaultProtocol: 'https', protocols: ['http', 'https'], isAllowedUri: (url, ctx) => { try { // construct URL const parsedUrl = url.includes(':') ? new URL(url) : new URL(`${ctx.defaultProtocol}://${url}`) // use default validation if (!ctx.defaultValidate(parsedUrl.href)) { return false } // disallowed protocols const disallowedProtocols = ['ftp', 'file', 'mailto'] const protocol = parsedUrl.protocol.replace(':', '') if (disallowedProtocols.includes(protocol)) { return false } // only allow protocols specified in ctx.protocols const allowedProtocols = ctx.protocols.map((p) => (typeof p === 'string' ? p : p.scheme)) if (!allowedProtocols.includes(protocol)) { return false } // all checks have passed return true } catch { return false } }, }), ], content: value ?? '', immediatelyRender: false, onUpdate: ({ editor: current }) => { onChangeRef.current(current.getHTML()) }, }) // فقط وقتی value از بیرون عوض شده (مثلاً reset فرم)، محتوا را همگام کن — نه موقع تایپ useEffect(() => { if (!editor) return const next = value ?? '' if (next === editor.getHTML()) return // هنگام فوکوس، setContent کرسر را می‌پرد و تایپ را خراب می‌کند if (editor.isFocused) return editor.commands.setContent(next, { emitUpdate: false }) }, [value, editor]) const setHeading = (level: 1 | 2 | 3 | 4 | 5 | 6) => { if (editor) { editor.chain().focus().toggleHeading({ level }).run() } } const setLink = useCallback(() => { if (editor) { const previousUrl = editor.getAttributes('link').href const url = window.prompt('URL', previousUrl) // cancelled if (url === null) { return } // empty if (url === '') { editor.chain().focus().extendMarkRange('link').unsetLink().run() return } // update link try { editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run() } catch { addToast({ title: texts.common.invalidUrl, color: 'danger' }) } } }, [editor]) if (!editor) { return null } return (
{label && ( )}
{isConsumer ? ( <> عنوان editor.chain().focus().setParagraph().run()} > متن عادی { setHeading(2) }} > عنوان اصلی { setHeading(3) }} > عنوان فرعی رنگ {CONSUMER_TEXT_COLORS.map((color) => ( } onPress={() => { const chain = editor.chain().focus() if (color.value) chain.setColor(color.value).run() else chain.unsetColor().run() }} > {color.label} ))} فهرست editor.chain().focus().toggleOrderedList().run()} > شماره‌دار editor.chain().focus().toggleBulletList().run()} > نشانه‌دار ) : ( <> font editor?.chain().focus().toggleBold().run()} > bold editor?.chain().focus().toggleItalic().run()} > italic editor?.chain().focus().toggleUnderline().run()} > underline editor?.chain().focus().toggleStrike().run()} > strike heading { setHeading(1) }} > H1 { setHeading(2) }} > H2 { setHeading(3) }} > H3 { setHeading(4) }} > H4 { setHeading(5) }} > H5 { setHeading(6) }} > H6 align editor?.chain().focus().setTextAlign('left').run()} > left editor?.chain().focus().setTextAlign('center').run()} > center editor?.chain().focus().setTextAlign('right').run()} > right editor?.chain().focus().setTextAlign('justify').run()} > justify link link editor?.chain().focus().unsetLink().run()} > unlink list editor?.chain().focus().toggleOrderedList().run()} > ordered list editor?.chain().focus().toggleBulletList().run()} > unordered list )}
{!isConsumer ?
: null} { editor ?.chain() .focus() .setImage({ src: fileAddress(imageId), alt: imageAlt.trim() }) .run() setImageId('') setImageAlt('') setIsOpenUploadImageModal(false) }} onClose={() => { setImageId('') setImageAlt('') setIsOpenUploadImageModal(false) }} onOpenChange={setIsOpenUploadImageModal} >
{ setImageId(fileId) }} /> { setImageAlt(coerceToString(next)) }} />
) } export default TextEditor