'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 (
{/* 텍스트 스타일 */} executeCommand(() => editor.chain().focus().toggleBold().run()) } disabled={disabled} >

굵게 (Ctrl+B)

executeCommand(() => editor.chain().focus().toggleItalic().run()) } disabled={disabled} >

기울임 (Ctrl+I)

executeCommand(() => editor.chain().focus().toggleUnderline().run()) } disabled={disabled} >

밑줄

executeCommand(() => editor.chain().focus().toggleStrike().run()) } disabled={disabled} >

취소선

{/* 제목 및 단락 */} {([1, 2, 3] as Array<1 | 2 | 3>).map((level) => ( executeCommand(() => editor.chain().focus().toggleHeading({ level }).run() ) } className="flex items-center" > 제목 {level} ))} executeCommand(() => editor.chain().focus().setParagraph().run()) } className="flex items-center" > 본문 {/* 글자 크기 - 동적 width 적용 */}
{ const size = e.target.value setFontSize(size) if (size && parseInt(size) >= 8 && parseInt(size) <= 72) { executeCommand(() => editor .chain() .focus() .setMark('textStyle', { fontSize: `${size}px` }) .run() ) } }} style={{ width: `${getFontSizeInputWidth(fontSize)}px` }} className="h-8 text-xs" disabled={disabled} /> {[8, 9, 10, 11, 12, 14, 16, 18, 20, 24, 28, 32, 36, 48, 72].map((size) => ( { setFontSize(size.toString()) executeCommand(() => editor .chain() .focus() .setMark('textStyle', { fontSize: `${size}px` }) .run() ) }} className="flex items-center" > {size}px ))}
{/* 리스트 */} executeCommand(() => editor.chain().focus().toggleBulletList().run()) } disabled={disabled} >

글머리 기호

executeCommand(() => editor.chain().focus().toggleOrderedList().run()) } disabled={disabled} >

번호 매기기

executeCommand(() => editor.chain().focus().toggleBlockquote().run()) } disabled={disabled} >

인용문

{/* 텍스트 정렬 */} {toolbarState.textAlign === 'center' ? ( ) : toolbarState.textAlign === 'right' ? ( ) : toolbarState.textAlign === 'justify' ? ( ) : ( )}

텍스트 정렬

executeCommand(() => editor.chain().focus().setTextAlign('left').run()) } className="flex items-center" > 왼쪽 정렬 executeCommand(() => editor.chain().focus().setTextAlign('center').run()) } className="flex items-center" > 가운데 정렬 executeCommand(() => editor.chain().focus().setTextAlign('right').run()) } className="flex items-center" > 오른쪽 정렬 executeCommand(() => editor.chain().focus().setTextAlign('justify').run()) } className="flex items-center" > 양쪽 정렬
{/* 링크 */} { if (toolbarState.link) { executeCommand(() => editor.chain().focus().unsetLink().run()) } else { const url = window.prompt('URL을 입력하세요:') if (url) { executeCommand(() => editor.chain().focus().setLink({ href: url }).run()) } } }} disabled={disabled} >

링크 {toolbarState.link ? '제거' : '삽입'}

{/* 이미지 업로드 */}
{ const file = e.target.files?.[0] if (file) handleImageUpload(file) }} /> { document.getElementById('image-upload-rt')?.click() }} disabled={disabled} >

이미지 삽입

{/* 첨자 */} executeCommand(() => editor.chain().focus().toggleSubscript().run()) } disabled={disabled} >

아래 첨자

executeCommand(() => editor.chain().focus().toggleSuperscript().run()) } disabled={disabled} >

위 첨자

{/* 하이라이트 */} executeCommand(() => editor.chain().focus().toggleHighlight().run()) } disabled={disabled} >

하이라이트

{/* 체크리스트 */} executeCommand(() => editor.chain().focus().toggleTaskList().run()) } disabled={disabled} >

체크리스트

{/* 테이블 */} {!toolbarState.table ? ( { if (editor && editor.isActive('table')) { alert('커서를 테이블 밖으로 이동시키세요') return } setIsTableDialogOpen(true) }} disabled={disabled} >

테이블 삽입

테이블 크기 설정 생성할 테이블의 행과 열 수를 입력하세요 (1-20)
setTableRows(e.target.value)} placeholder="3" />
setTableCols(e.target.value)} placeholder="3" />
) : (

테이블 편집

executeCommand(() => editor.chain().focus().addRowBefore().run()) } className="flex items-center" > 위에 행 추가 executeCommand(() => editor.chain().focus().addRowAfter().run()) } className="flex items-center" > 아래에 행 추가 executeCommand(() => editor.chain().focus().addColumnBefore().run()) } className="flex items-center" > 왼쪽에 열 추가 executeCommand(() => editor.chain().focus().addColumnAfter().run()) } className="flex items-center" > 오른쪽에 열 추가 executeCommand(() => editor.chain().focus().deleteRow().run()) } className="flex items-center" > 행 삭제 executeCommand(() => editor.chain().focus().deleteColumn().run()) } className="flex items-center" > 열 삭제 executeCommand(() => editor.chain().focus().deleteTable().run()) } className="flex items-center text-red-600" > 테이블 삭제
)} {/* 실행 취소/다시 실행 */} executeCommand(() => editor.chain().focus().undo().run()) } disabled={!editor.can().undo() || disabled} >

실행 취소 (Ctrl+Z)

executeCommand(() => editor.chain().focus().redo().run()) } disabled={!editor.can().redo() || disabled} >

다시 실행 (Ctrl+Y)

) } // --------------------------------------------------------------------------- // Layout & rendering // --------------------------------------------------------------------------- const containerStyle = height === '100%' ? { height: '100%' } : { height } const editorContentStyle = height === '100%' ? { flex: 1, minHeight: 0 } : { height: `calc(${height} - 60px)` } return (
) }