'use client' import React, { useRef, useEffect } from 'react' import { useEditor, EditorContent, type Editor } from '@tiptap/react' import StarterKit from '@tiptap/starter-kit' import Underline from '@tiptap/extension-underline' import TextAlign from '@tiptap/extension-text-align' import TextStyle from '@tiptap/extension-text-style' import Subscript from '@tiptap/extension-subscript' import Superscript from '@tiptap/extension-superscript' import Placeholder from '@tiptap/extension-placeholder' import Highlight from '@tiptap/extension-highlight' import TaskList from '@tiptap/extension-task-list' import TaskItem from '@tiptap/extension-task-item' import BulletList from '@tiptap/extension-bullet-list' import ListItem from '@tiptap/extension-list-item' import OrderedList from '@tiptap/extension-ordered-list' import Blockquote from '@tiptap/extension-blockquote' import Table from '@tiptap/extension-table' import TableRow from '@tiptap/extension-table-row' import TableCell from '@tiptap/extension-table-cell' import TableHeader from '@tiptap/extension-table-header' import { toast } from 'sonner' import { FontSize } from './extensions/font-size' import ImageResize from 'tiptap-extension-resize-image' import { Toolbar } from './Toolbar' interface RichTextEditorProps { value: string onChange: (val: string) => void disabled?: boolean height?: string className?: string placeholder?: string debounceMs?: number onReady?: (editor: Editor) => void onFocus?: () => void onBlur?: () => void } export default function RichTextEditor({ value, onChange, disabled, height = '300px', className, placeholder, debounceMs = 200, onReady, onFocus, onBlur, }: RichTextEditorProps) { const updateTimerRef = useRef(undefined) const computedExtensions: unknown[] = [ StarterKit.configure({ bulletList: false, orderedList: false, listItem: false, blockquote: false, codeBlock: false, code: false, heading: { levels: [1, 2, 3] }, horizontalRule: false, }), Underline, ImageResize, TextAlign.configure({ types: ['heading', 'paragraph'], alignments: ['left', 'center', 'right', 'justify'], defaultAlignment: 'left', }), Subscript, Superscript, TextStyle, FontSize, Table.configure({ resizable: true }), TableRow, TableCell, TableHeader, Highlight.configure({ multicolor: true }), TaskList, TaskItem.configure({ nested: true }), BulletList.configure({ HTMLAttributes: { class: 'list-disc ml-5', }, }), ListItem.configure({ HTMLAttributes: { class: 'list-item my-0.5', }, }), OrderedList.configure({ HTMLAttributes: { class: 'list-decimal ml-5', }, }), Blockquote.configure({ HTMLAttributes: { class: 'border-l-4 pl-4 my-3 italic', }, }), ] if (placeholder) { computedExtensions.push(Placeholder.configure({ placeholder })) } // eslint-disable-next-line @typescript-eslint/no-explicit-any const extensionsForEditor = computedExtensions as any const editor = useEditor({ extensions: extensionsForEditor, content: value, editable: !disabled, enablePasteRules: false, enableInputRules: false, immediatelyRender: false, editorProps: { attributes: { class: 'w-full h-full min-h-full bg-background px-3 py-2 text-sm leading-[1.6] ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 prose prose-sm max-w-none', }, handleDrop: (view, event, slice, moved) => { if (!moved && event.dataTransfer?.files.length) { const file = event.dataTransfer.files[0] if (file.type.startsWith('image/')) { handleImageUpload(file) return true } } return false }, handlePaste: (view, event) => { if (event.clipboardData?.files.length) { const file = event.clipboardData.files[0] if (file.type.startsWith('image/')) { handleImageUpload(file) return true } } return false }, handleDOMEvents: { focus: () => { onFocus?.() return false }, blur: () => { onBlur?.() return false }, }, }, onUpdate: ({ editor }) => { if (updateTimerRef.current) window.clearTimeout(updateTimerRef.current) updateTimerRef.current = window.setTimeout(() => { onChange(editor.getHTML()) }, debounceMs) as unknown as number }, }) useEffect(() => { if (!editor) return const current = editor.getHTML() if (value !== current) { editor.commands.setContent(value, false) } }, [editor, value]) useEffect(() => { if (!editor) return editor.setEditable(!disabled) }, [editor, disabled]) const readyCalledRef = useRef(false) useEffect(() => { if (!editor || readyCalledRef.current) return readyCalledRef.current = true onReady?.(editor) }, [editor, onReady]) const readFileAsDataURL = (file: File): Promise => new Promise((resolve, reject) => { const reader = new FileReader() reader.onload = e => resolve(String(e.target?.result)) reader.onerror = reject reader.readAsDataURL(file) }) const handleImageUpload = async (file: File) => { if (file.size > 3 * 1024 * 1024) { toast.error('이미지 크기는 3 MB 이하만 지원됩니다.') return } if (!file.type.startsWith('image/')) { toast.error('이미지 파일만 업로드 가능합니다.') return } try { const dataUrl = await readFileAsDataURL(file) editor?.chain().focus().setImage({ src: dataUrl, alt: file.name }).run() } catch (error) { console.error(error) toast.error('이미지 읽기에 실패했습니다.') } } const containerStyle = { height } return (
) }