'use client'
import React, { useCallback, useRef, useState, useEffect } from 'react'
import { useEditor, EditorContent, type Editor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Underline from '@tiptap/extension-underline'
import { Image as TiptapImage } from '@tiptap/extension-image'
import Link from '@tiptap/extension-link'
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 { Extension } from '@tiptap/core'
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'
// shadcn/ui & lucide
import {
Bold,
Italic,
Underline as UnderlineIcon,
Strikethrough,
ListOrdered,
List,
Quote,
Undo,
Redo,
Link as LinkIcon,
Image as ImageIcon,
AlignLeft,
AlignCenter,
AlignRight,
AlignJustify,
Subscript as SubscriptIcon,
Superscript as SuperscriptIcon,
Table as TableIcon,
Highlighter,
CheckSquare,
Type,
} from 'lucide-react'
import { Toggle } from '@/components/ui/toggle'
import { Separator } from '@/components/ui/separator'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
/* -------------------------------------------------------------------------------------------------
* FontSize extension (wraps TextStyle)
* -------------------------------------------------------------------------------------------------*/
const FontSize = Extension.create({
name: 'fontSize',
addGlobalAttributes() {
return [
{
types: ['textStyle'],
attributes: {
fontSize: {
default: null,
parseHTML: element => {
const size = element.style.fontSize
return size ? size.replace(/[^0-9]/g, '') : null
},
renderHTML: attributes => {
if (!attributes.fontSize) return {}
return {
style: `font-size: ${attributes.fontSize}`,
}
},
},
},
},
]
},
})
/* -------------------------------------------------------------------------------------------------
* Props & component
* -------------------------------------------------------------------------------------------------*/
interface RichTextEditorProps {
value: string
onChange: (val: string) => void
disabled?: boolean
height?: string // e.g. "400px" or "100%"
}
export default function RichTextEditor({
value,
onChange,
disabled,
height = '300px',
}: RichTextEditorProps) {
// ---------------------------------------------------------------------------
// Editor instance
// ---------------------------------------------------------------------------
const editor = useEditor({
extensions: [
StarterKit.configure({
bulletList: false,
orderedList: false,
listItem: false,
blockquote: false,
codeBlock: false,
code: false,
heading: { levels: [1, 2, 3] },
horizontalRule: false,
}),
Underline,
TiptapImage.configure({
HTMLAttributes: {
class: 'max-w-full h-auto',
style: 'max-width: 600px; height: auto;',
},
}),
Link.configure({ openOnClick: true, linkOnPaste: true }),
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,
ListItem,
OrderedList,
Blockquote,
],
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 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
},
},
onUpdate: ({ editor }) => {
onChange(editor.getHTML())
},
})
// ---------------------------------------------------------------------------
// Image handling (base64)
// ---------------------------------------------------------------------------
const handleImageUpload = async (file: File) => {
if (file.size > 3 * 1024 * 1024) {
alert('이미지 크기는 3 MB 이하만 지원됩니다.')
return
}
if (!file.type.startsWith('image/')) {
alert('이미지 파일만 업로드 가능합니다.')
return
}
const reader = new FileReader()
reader.onload = e => {
const base64 = e.target?.result as string
editor?.chain().focus().setImage({ src: base64, alt: file.name }).run()
}
reader.onerror = () => alert('이미지 읽기에 실패했습니다.')
reader.readAsDataURL(file)
}
// ---------------------------------------------------------------------------
// Toolbar (internal component)
// ---------------------------------------------------------------------------
const Toolbar: React.FC<{ editor: Editor | null; disabled?: boolean }> = ({
editor,
disabled,
}) => {
const [fontSize, setFontSize] = useState('16')
const [isTableDialogOpen, setIsTableDialogOpen] = useState(false)
const [tableRows, setTableRows] = useState('3')
const [tableCols, setTableCols] = useState('3')
// 간단한 툴바 상태 계산 - 실시간으로 계산하여 상태 동기화 문제 해결
const getToolbarState = useCallback(() => {
if (!editor) return {
bold: false,
italic: false,
underline: false,
strike: false,
bulletList: false,
orderedList: false,
blockquote: false,
link: false,
highlight: false,
taskList: false,
table: false,
subscript: false,
superscript: false,
heading: false,
textAlign: 'left' as 'left' | 'center' | 'right' | 'justify',
}
const textAlign = editor.isActive({ textAlign: 'center' })
? 'center'
: editor.isActive({ textAlign: 'right' })
? 'right'
: editor.isActive({ textAlign: 'justify' })
? 'justify'
: 'left'
return {
bold: editor.isActive('bold'),
italic: editor.isActive('italic'),
underline: editor.isActive('underline'),
strike: editor.isActive('strike'),
bulletList: editor.isActive('bulletList'),
orderedList: editor.isActive('orderedList'),
blockquote: editor.isActive('blockquote'),
link: editor.isActive('link'),
highlight: editor.isActive('highlight'),
taskList: editor.isActive('taskList'),
table: editor.isActive('table'),
subscript: editor.isActive('subscript'),
superscript: editor.isActive('superscript'),
heading: [1, 2, 3, 4, 5, 6].some(l => editor.isActive('heading', { level: l })),
textAlign: textAlign as 'left' | 'center' | 'right' | 'justify',
}
}, [editor])
const toolbarState = getToolbarState()
// 폰트 사이즈 업데이트 - 복잡한 timeout 로직 제거
useEffect(() => {
if (!editor) return
const updateFontSize = () => {
const currentFontSizeAttr = editor.getAttributes('textStyle').fontSize
if (typeof currentFontSizeAttr === 'string') {
const sizeValue = currentFontSizeAttr.replace('px', '')
setFontSize(sizeValue)
} else {
setFontSize('16')
}
}
updateFontSize()
editor.on('selectionUpdate', updateFontSize)
editor.on('transaction', updateFontSize)
return () => {
editor.off('selectionUpdate', updateFontSize)
editor.off('transaction', updateFontSize)
}
}, [editor])
// 개선된 executeCommand - 포커스 문제 해결 및 단순화
const executeCommand = useCallback(
(command: () => void) => {
if (!editor || disabled) return
// 명령 실행 전 포커스 확보
if (!editor.isFocused) {
editor.commands.focus()
}
// 명령 실행
command()
// 명령 실행 후 포커스 유지
setTimeout(() => {
if (editor && !editor.isFocused) {
editor.commands.focus()
}
}, 10)
},
[editor, disabled]
)
// 폰트 사이즈 입력 필드의 동적 width 계산
const getFontSizeInputWidth = useCallback((size: string) => {
const length = size.length
return Math.max(length * 8 + 16, 40) // 최소 40px, 글자 수에 따라 증가
}, [])
if (!editor) return null
// --- Render toolbar UI ---
return (
굵게 (Ctrl+B) 기울임 (Ctrl+I) 밑줄 취소선 글머리 기호 번호 매기기 인용문 텍스트 정렬 링크 {toolbarState.link ? '제거' : '삽입'} 이미지 삽입 아래 첨자 위 첨자 하이라이트 체크리스트 테이블 편집 실행 취소 (Ctrl+Z) 다시 실행 (Ctrl+Y)