admin/components/formElements/TextEditor.tsx
alisaza e1eaf5eff5 feat: initial ghabilee-admin backoffice app
Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js
app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
2026-09-05 13:12:59 +03:30

554 lines
18 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

'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 { 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'
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 (
<div className={cn(className, isConsumer && 'flex min-w-0 w-full flex-col gap-1')}>
{label && (
<label
className={isConsumer ? 'mb-1 block text-sm font-medium text-secondary-20' : 'mb-2 block'}
data-slot="label"
>
{label}
{required && <span className="text-fourth ms-1">*</span>}
</label>
)}
<div
className={
isConsumer
? 'overflow-hidden rounded-[10px] border border-consumer-border bg-consumer-surface shadow-none'
: 'border p-2 rounded-xl'
}
>
<div
className={
isConsumer
? 'toolbar flex flex-wrap items-center gap-2 border-b border-consumer-border bg-consumer-surface-muted p-2'
: 'toolbar flex flex-wrap gap-2'
}
>
{isConsumer ? (
<>
<Dropdown>
<DropdownTrigger>
<button
aria-label="عنوان"
className="min-h-10 rounded-[10px] border border-consumer-border bg-consumer-surface px-3 text-sm font-medium text-consumer-text"
type="button"
>
عنوان
</button>
</DropdownTrigger>
<DropdownMenu aria-label="نوع عنوان">
<DropdownItem
key="paragraph"
onPress={() => editor.chain().focus().setParagraph().run()}
>
متن عادی
</DropdownItem>
<DropdownItem
key="heading-2"
onPress={() => {
setHeading(2)
}}
>
عنوان اصلی
</DropdownItem>
<DropdownItem
key="heading-3"
onPress={() => {
setHeading(3)
}}
>
عنوان فرعی
</DropdownItem>
</DropdownMenu>
</Dropdown>
<Dropdown>
<DropdownTrigger>
<button
aria-label="رنگ متن"
className="min-h-10 rounded-[10px] border border-consumer-border bg-consumer-surface px-3 text-sm font-medium text-consumer-text"
type="button"
>
رنگ
</button>
</DropdownTrigger>
<DropdownMenu aria-label="رنگ متن">
{CONSUMER_TEXT_COLORS.map((color) => (
<DropdownItem
key={color.label}
startContent={<span className={cn('size-4 rounded-full border border-black/10', color.swatchClassName)} />}
onPress={() => {
const chain = editor.chain().focus()
if (color.value) chain.setColor(color.value).run()
else chain.unsetColor().run()
}}
>
{color.label}
</DropdownItem>
))}
</DropdownMenu>
</Dropdown>
<Dropdown>
<DropdownTrigger>
<button
aria-label="فهرست"
className="min-h-10 rounded-[10px] border border-consumer-border bg-consumer-surface px-3 text-sm font-medium text-consumer-text"
type="button"
>
فهرست
</button>
</DropdownTrigger>
<DropdownMenu aria-label="نوع فهرست">
<DropdownItem
key="ordered"
onPress={() => editor.chain().focus().toggleOrderedList().run()}
>
شمارهدار
</DropdownItem>
<DropdownItem
key="unordered"
onPress={() => editor.chain().focus().toggleBulletList().run()}
>
نشانهدار
</DropdownItem>
</DropdownMenu>
</Dropdown>
</>
) : (
<>
<Dropdown>
<DropdownTrigger>
<Button
size="sm"
variant="bordered"
>
font
</Button>
</DropdownTrigger>
<DropdownMenu aria-label="Static Actions">
<DropdownItem
key="bold"
onPress={() => editor?.chain().focus().toggleBold().run()}
>
bold
</DropdownItem>
<DropdownItem
key="italic"
onPress={() => editor?.chain().focus().toggleItalic().run()}
>
italic
</DropdownItem>
<DropdownItem
key="underline"
onPress={() => editor?.chain().focus().toggleUnderline().run()}
>
underline
</DropdownItem>
<DropdownItem
key="strike"
onPress={() => editor?.chain().focus().toggleStrike().run()}
>
strike
</DropdownItem>
</DropdownMenu>
</Dropdown>
<Dropdown>
<DropdownTrigger>
<Button
size="sm"
variant="bordered"
>
heading
</Button>
</DropdownTrigger>
<DropdownMenu aria-label="Static Actions">
<DropdownItem
key="H1"
onPress={() => {
setHeading(1)
}}
>
H1
</DropdownItem>
<DropdownItem
key="H2"
onPress={() => {
setHeading(2)
}}
>
H2
</DropdownItem>
<DropdownItem
key="H3"
onPress={() => {
setHeading(3)
}}
>
H3
</DropdownItem>
<DropdownItem
key="H4"
onPress={() => {
setHeading(4)
}}
>
H4
</DropdownItem>
<DropdownItem
key="H5"
onPress={() => {
setHeading(5)
}}
>
H5
</DropdownItem>
<DropdownItem
key="H6"
onPress={() => {
setHeading(6)
}}
>
H6
</DropdownItem>
</DropdownMenu>
</Dropdown>
<Dropdown>
<DropdownTrigger>
<Button
size="sm"
variant="bordered"
>
align
</Button>
</DropdownTrigger>
<DropdownMenu aria-label="Static Actions">
<DropdownItem
key="left"
onPress={() => editor?.chain().focus().setTextAlign('left').run()}
>
left
</DropdownItem>
<DropdownItem
key="center"
onPress={() => editor?.chain().focus().setTextAlign('center').run()}
>
center
</DropdownItem>
<DropdownItem
key="right"
onPress={() => editor?.chain().focus().setTextAlign('right').run()}
>
right
</DropdownItem>
<DropdownItem
key="justify"
onPress={() => editor?.chain().focus().setTextAlign('justify').run()}
>
justify
</DropdownItem>
</DropdownMenu>
</Dropdown>
<Dropdown>
<DropdownTrigger>
<Button
size="sm"
variant="bordered"
>
link
</Button>
</DropdownTrigger>
<DropdownMenu aria-label="Static Actions">
<DropdownItem
key="link"
onPress={setLink}
>
link
</DropdownItem>
<DropdownItem
key="unlink"
onPress={() => editor?.chain().focus().unsetLink().run()}
>
unlink
</DropdownItem>
</DropdownMenu>
</Dropdown>
<Dropdown>
<DropdownTrigger>
<Button
size="sm"
variant="bordered"
>
list
</Button>
</DropdownTrigger>
<DropdownMenu aria-label="Static Actions">
<DropdownItem
key="ordered"
onPress={() => editor?.chain().focus().toggleOrderedList().run()}
>
ordered list
</DropdownItem>
<DropdownItem
key="unordered"
onPress={() => editor?.chain().focus().toggleBulletList().run()}
>
unordered list
</DropdownItem>
</DropdownMenu>
</Dropdown>
<Button
size="sm"
variant="bordered"
onClick={() => {
setIsOpenUploadImageModal(!isOpenUploadImageModal)
}}
>
image
</Button>
<Button
size="sm"
variant="bordered"
onClick={() => editor?.chain().focus().toggleHighlight().run()}
>
highlight
</Button>
<Button
size="sm"
variant="bordered"
>
<input
data-testid="setColor"
type="color"
value={editor?.getAttributes('textStyle').color}
onInput={(event) => {
const target = event.target as HTMLInputElement // Type Assertion
editor?.chain().focus().setColor(target.value).run()
}}
/>
</Button>
</>
)}
</div>
{!isConsumer ? <hr className="my-2" /> : null}
<EditorContent
className={cn(
'[&_.tiptap]:!outline-none no-reset',
isConsumer
? '[&_.tiptap]:min-h-32 [&_.tiptap]:px-3 [&_.tiptap]:py-3 [&_.tiptap]:text-sm [&_.tiptap]:font-medium [&_.tiptap]:text-consumer-text'
: '[&_.tiptap]:p-2'
)}
editor={editor}
/>
<ConsumerModal
hideCloseButton
hideHeader
acceptBtnDisabled={!imageId || !imageAlt.trim()}
acceptBtnText={texts.common.confirmPlain}
isOpen={isOpenUploadImageModal}
onAccept={() => {
editor
?.chain()
.focus()
.setImage({ src: fileAddress(imageId), alt: imageAlt.trim() })
.run()
setImageId('')
setImageAlt('')
setIsOpenUploadImageModal(false)
}}
onClose={() => {
setImageId('')
setImageAlt('')
setIsOpenUploadImageModal(false)
}}
onOpenChange={setIsOpenUploadImageModal}
>
<div className="flex flex-col gap-3">
<FileUpload
accept={['image']}
fileId={imageId}
fileUploaded={(fileId) => {
setImageId(fileId)
}}
/>
<Input
required
description={texts.common.imageAltDescription}
generalType="input"
label={texts.common.imageAltLabel}
name="imageAlt"
value={imageAlt}
onValueChange={(next) => {
setImageAlt(coerceToString(next))
}}
/>
</div>
</ConsumerModal>
</div>
</div>
)
}
export default TextEditor