summaryrefslogtreecommitdiff
path: root/components/rich-text-editor/RichTextEditor.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'components/rich-text-editor/RichTextEditor.tsx')
-rw-r--r--components/rich-text-editor/RichTextEditor.tsx998
1 files changed, 998 insertions, 0 deletions
diff --git a/components/rich-text-editor/RichTextEditor.tsx b/components/rich-text-editor/RichTextEditor.tsx
new file mode 100644
index 00000000..ceb76665
--- /dev/null
+++ b/components/rich-text-editor/RichTextEditor.tsx
@@ -0,0 +1,998 @@
+'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 (
+ <TooltipProvider>
+ <div className="border border-input bg-transparent rounded-t-md">
+ <div className="flex flex-wrap gap-1 p-1">
+ {/* 텍스트 스타일 */}
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Toggle
+ size="sm"
+ pressed={toolbarState.bold}
+ onPressedChange={() =>
+ executeCommand(() => editor.chain().focus().toggleBold().run())
+ }
+ disabled={disabled}
+ >
+ <Bold className="h-4 w-4" />
+ </Toggle>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>굵게 (Ctrl+B)</p>
+ </TooltipContent>
+ </Tooltip>
+
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Toggle
+ size="sm"
+ pressed={toolbarState.italic}
+ onPressedChange={() =>
+ executeCommand(() => editor.chain().focus().toggleItalic().run())
+ }
+ disabled={disabled}
+ >
+ <Italic className="h-4 w-4" />
+ </Toggle>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>기울임 (Ctrl+I)</p>
+ </TooltipContent>
+ </Tooltip>
+
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Toggle
+ size="sm"
+ pressed={toolbarState.underline}
+ onPressedChange={() =>
+ executeCommand(() => editor.chain().focus().toggleUnderline().run())
+ }
+ disabled={disabled}
+ >
+ <UnderlineIcon className="h-4 w-4" />
+ </Toggle>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>밑줄</p>
+ </TooltipContent>
+ </Tooltip>
+
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Toggle
+ size="sm"
+ pressed={toolbarState.strike}
+ onPressedChange={() =>
+ executeCommand(() => editor.chain().focus().toggleStrike().run())
+ }
+ disabled={disabled}
+ >
+ <Strikethrough className="h-4 w-4" />
+ </Toggle>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>취소선</p>
+ </TooltipContent>
+ </Tooltip>
+
+ <Separator orientation="vertical" className="h-6" />
+
+ {/* 제목 및 단락 */}
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Toggle size="sm" pressed={toolbarState.heading} disabled={disabled}>
+ <Type className="h-4 w-4" />
+ </Toggle>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="start">
+ {([1, 2, 3] as Array<1 | 2 | 3>).map((level) => (
+ <DropdownMenuItem
+ key={level}
+ onClick={() =>
+ executeCommand(() =>
+ editor.chain().focus().toggleHeading({ level }).run()
+ )
+ }
+ className="flex items-center"
+ >
+ <span
+ className={`font-bold ${level === 1 ? 'text-xl' : level === 2 ? 'text-lg' : 'text-base'
+ }`}
+ >
+ 제목 {level}
+ </span>
+ </DropdownMenuItem>
+ ))}
+ <DropdownMenuItem
+ onClick={() =>
+ executeCommand(() => editor.chain().focus().setParagraph().run())
+ }
+ className="flex items-center"
+ >
+ <span>본문</span>
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+
+ {/* 글자 크기 - 동적 width 적용 */}
+ <div className="flex items-center space-x-1">
+ <Input
+ type="number"
+ min="8"
+ max="72"
+ value={fontSize}
+ onChange={(e) => {
+ 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}
+ />
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="outline" size="sm" className="h-8 px-2" disabled={disabled}>
+ <Type className="h-3 w-3" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="start">
+ {[8, 9, 10, 11, 12, 14, 16, 18, 20, 24, 28, 32, 36, 48, 72].map((size) => (
+ <DropdownMenuItem
+ key={size}
+ onClick={() => {
+ setFontSize(size.toString())
+ executeCommand(() =>
+ editor
+ .chain()
+ .focus()
+ .setMark('textStyle', { fontSize: `${size}px` })
+ .run()
+ )
+ }}
+ className="flex items-center"
+ >
+ <span style={{ fontSize: `${Math.min(size, 16)}px` }}>{size}px</span>
+ </DropdownMenuItem>
+ ))}
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
+
+ <Separator orientation="vertical" className="h-6" />
+
+ {/* 리스트 */}
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Toggle
+ size="sm"
+ pressed={toolbarState.bulletList}
+ onPressedChange={() =>
+ executeCommand(() => editor.chain().focus().toggleBulletList().run())
+ }
+ disabled={disabled}
+ >
+ <List className="h-4 w-4" />
+ </Toggle>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>글머리 기호</p>
+ </TooltipContent>
+ </Tooltip>
+
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Toggle
+ size="sm"
+ pressed={toolbarState.orderedList}
+ onPressedChange={() =>
+ executeCommand(() => editor.chain().focus().toggleOrderedList().run())
+ }
+ disabled={disabled}
+ >
+ <ListOrdered className="h-4 w-4" />
+ </Toggle>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>번호 매기기</p>
+ </TooltipContent>
+ </Tooltip>
+
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Toggle
+ size="sm"
+ pressed={toolbarState.blockquote}
+ onPressedChange={() =>
+ executeCommand(() => editor.chain().focus().toggleBlockquote().run())
+ }
+ disabled={disabled}
+ >
+ <Quote className="h-4 w-4" />
+ </Toggle>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>인용문</p>
+ </TooltipContent>
+ </Tooltip>
+
+ <Separator orientation="vertical" className="h-6" />
+
+ {/* 텍스트 정렬 */}
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Toggle size="sm" pressed={toolbarState.textAlign !== 'left'} disabled={disabled}>
+ {toolbarState.textAlign === 'center' ? (
+ <AlignCenter className="h-4 w-4" />
+ ) : toolbarState.textAlign === 'right' ? (
+ <AlignRight className="h-4 w-4" />
+ ) : toolbarState.textAlign === 'justify' ? (
+ <AlignJustify className="h-4 w-4" />
+ ) : (
+ <AlignLeft className="h-4 w-4" />
+ )}
+ </Toggle>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>텍스트 정렬</p>
+ </TooltipContent>
+ </Tooltip>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="start">
+ <DropdownMenuItem
+ onClick={() =>
+ executeCommand(() => editor.chain().focus().setTextAlign('left').run())
+ }
+ className="flex items-center"
+ >
+ <AlignLeft className="mr-2 h-4 w-4" />
+ <span>왼쪽 정렬</span>
+ </DropdownMenuItem>
+ <DropdownMenuItem
+ onClick={() =>
+ executeCommand(() => editor.chain().focus().setTextAlign('center').run())
+ }
+ className="flex items-center"
+ >
+ <AlignCenter className="mr-2 h-4 w-4" />
+ <span>가운데 정렬</span>
+ </DropdownMenuItem>
+ <DropdownMenuItem
+ onClick={() =>
+ executeCommand(() => editor.chain().focus().setTextAlign('right').run())
+ }
+ className="flex items-center"
+ >
+ <AlignRight className="mr-2 h-4 w-4" />
+ <span>오른쪽 정렬</span>
+ </DropdownMenuItem>
+ <DropdownMenuItem
+ onClick={() =>
+ executeCommand(() => editor.chain().focus().setTextAlign('justify').run())
+ }
+ className="flex items-center"
+ >
+ <AlignJustify className="mr-2 h-4 w-4" />
+ <span>양쪽 정렬</span>
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+
+ <Separator orientation="vertical" className="h-6" />
+
+ {/* 링크 */}
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Toggle
+ size="sm"
+ pressed={toolbarState.link}
+ onPressedChange={() => {
+ 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}
+ >
+ <LinkIcon className="h-4 w-4" />
+ </Toggle>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>링크 {toolbarState.link ? '제거' : '삽입'}</p>
+ </TooltipContent>
+ </Tooltip>
+
+ {/* 이미지 업로드 */}
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <div className="relative">
+ <input
+ type="file"
+ accept="image/*"
+ className="hidden"
+ id="image-upload-rt"
+ onChange={(e) => {
+ const file = e.target.files?.[0]
+ if (file) handleImageUpload(file)
+ }}
+ />
+ <Toggle
+ size="sm"
+ pressed={false}
+ onPressedChange={() => {
+ document.getElementById('image-upload-rt')?.click()
+ }}
+ disabled={disabled}
+ >
+ <ImageIcon className="h-4 w-4" />
+ </Toggle>
+ </div>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>이미지 삽입</p>
+ </TooltipContent>
+ </Tooltip>
+
+ <Separator orientation="vertical" className="h-6" />
+
+ {/* 첨자 */}
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Toggle
+ size="sm"
+ pressed={toolbarState.subscript}
+ onPressedChange={() =>
+ executeCommand(() => editor.chain().focus().toggleSubscript().run())
+ }
+ disabled={disabled}
+ >
+ <SubscriptIcon className="h-4 w-4" />
+ </Toggle>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>아래 첨자</p>
+ </TooltipContent>
+ </Tooltip>
+
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Toggle
+ size="sm"
+ pressed={toolbarState.superscript}
+ onPressedChange={() =>
+ executeCommand(() => editor.chain().focus().toggleSuperscript().run())
+ }
+ disabled={disabled}
+ >
+ <SuperscriptIcon className="h-4 w-4" />
+ </Toggle>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>위 첨자</p>
+ </TooltipContent>
+ </Tooltip>
+
+ <Separator orientation="vertical" className="h-6" />
+
+ {/* 하이라이트 */}
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Toggle
+ size="sm"
+ pressed={toolbarState.highlight}
+ onPressedChange={() =>
+ executeCommand(() => editor.chain().focus().toggleHighlight().run())
+ }
+ disabled={disabled}
+ >
+ <Highlighter className="h-4 w-4" />
+ </Toggle>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>하이라이트</p>
+ </TooltipContent>
+ </Tooltip>
+
+ {/* 체크리스트 */}
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Toggle
+ size="sm"
+ pressed={toolbarState.taskList}
+ onPressedChange={() =>
+ executeCommand(() => editor.chain().focus().toggleTaskList().run())
+ }
+ disabled={disabled}
+ >
+ <CheckSquare className="h-4 w-4" />
+ </Toggle>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>체크리스트</p>
+ </TooltipContent>
+ </Tooltip>
+
+ <Separator orientation="vertical" className="h-6" />
+
+ {/* 테이블 */}
+ {!toolbarState.table ? (
+ <Dialog open={isTableDialogOpen} onOpenChange={setIsTableDialogOpen}>
+ <DialogTrigger asChild>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Toggle
+ size="sm"
+ pressed={false}
+ onPressedChange={() => {
+ if (editor && editor.isActive('table')) {
+ alert('커서를 테이블 밖으로 이동시키세요')
+ return
+ }
+ setIsTableDialogOpen(true)
+ }}
+ disabled={disabled}
+ >
+ <TableIcon className="h-4 w-4" />
+ </Toggle>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>테이블 삽입</p>
+ </TooltipContent>
+ </Tooltip>
+ </DialogTrigger>
+ <DialogContent className="sm:max-w-md">
+ <DialogHeader>
+ <DialogTitle>테이블 크기 설정</DialogTitle>
+ <DialogDescription>
+ 생성할 테이블의 행과 열 수를 입력하세요 (1-20)
+ </DialogDescription>
+ </DialogHeader>
+ <div className="grid grid-cols-2 gap-4 py-4">
+ <div className="space-y-2">
+ <Label htmlFor="table-rows">행 수</Label>
+ <Input
+ id="table-rows"
+ type="number"
+ min="1"
+ max="20"
+ value={tableRows}
+ onChange={(e) => setTableRows(e.target.value)}
+ placeholder="3"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="table-cols">열 수</Label>
+ <Input
+ id="table-cols"
+ type="number"
+ min="1"
+ max="20"
+ value={tableCols}
+ onChange={(e) => setTableCols(e.target.value)}
+ placeholder="3"
+ />
+ </div>
+ </div>
+ <DialogFooter>
+ <Button variant="outline" onClick={() => setIsTableDialogOpen(false)}>
+ 취소
+ </Button>
+ <Button
+ onClick={() => {
+ const rows = parseInt(tableRows, 10)
+ const cols = parseInt(tableCols, 10)
+ if (rows >= 1 && rows <= 20 && cols >= 1 && cols <= 20) {
+ executeCommand(() =>
+ editor.chain().focus().insertTable({ rows, cols }).run()
+ )
+ setIsTableDialogOpen(false)
+ }
+ }}
+ disabled={
+ !tableRows ||
+ !tableCols ||
+ parseInt(tableRows, 10) < 1 ||
+ parseInt(tableRows, 10) > 20 ||
+ parseInt(tableCols, 10) < 1 ||
+ parseInt(tableCols, 10) > 20
+ }
+ >
+ 생성
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ ) : (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Toggle size="sm" pressed={true} disabled={disabled}>
+ <TableIcon className="h-4 w-4" />
+ </Toggle>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>테이블 편집</p>
+ </TooltipContent>
+ </Tooltip>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="start">
+ <DropdownMenuItem
+ onClick={() =>
+ executeCommand(() => editor.chain().focus().addRowBefore().run())
+ }
+ className="flex items-center"
+ >
+ <span>위에 행 추가</span>
+ </DropdownMenuItem>
+ <DropdownMenuItem
+ onClick={() =>
+ executeCommand(() => editor.chain().focus().addRowAfter().run())
+ }
+ className="flex items-center"
+ >
+ <span>아래에 행 추가</span>
+ </DropdownMenuItem>
+ <DropdownMenuItem
+ onClick={() =>
+ executeCommand(() => editor.chain().focus().addColumnBefore().run())
+ }
+ className="flex items-center"
+ >
+ <span>왼쪽에 열 추가</span>
+ </DropdownMenuItem>
+ <DropdownMenuItem
+ onClick={() =>
+ executeCommand(() => editor.chain().focus().addColumnAfter().run())
+ }
+ className="flex items-center"
+ >
+ <span>오른쪽에 열 추가</span>
+ </DropdownMenuItem>
+ <DropdownMenuItem
+ onClick={() =>
+ executeCommand(() => editor.chain().focus().deleteRow().run())
+ }
+ className="flex items-center"
+ >
+ <span>행 삭제</span>
+ </DropdownMenuItem>
+ <DropdownMenuItem
+ onClick={() =>
+ executeCommand(() => editor.chain().focus().deleteColumn().run())
+ }
+ className="flex items-center"
+ >
+ <span>열 삭제</span>
+ </DropdownMenuItem>
+ <DropdownMenuItem
+ onClick={() =>
+ executeCommand(() => editor.chain().focus().deleteTable().run())
+ }
+ className="flex items-center text-red-600"
+ >
+ <span>테이블 삭제</span>
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )}
+
+ <Separator orientation="vertical" className="h-6" />
+
+ {/* 실행 취소/다시 실행 */}
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Toggle
+ size="sm"
+ pressed={false}
+ onPressedChange={() =>
+ executeCommand(() => editor.chain().focus().undo().run())
+ }
+ disabled={!editor.can().undo() || disabled}
+ >
+ <Undo className="h-4 w-4" />
+ </Toggle>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>실행 취소 (Ctrl+Z)</p>
+ </TooltipContent>
+ </Tooltip>
+
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Toggle
+ size="sm"
+ pressed={false}
+ onPressedChange={() =>
+ executeCommand(() => editor.chain().focus().redo().run())
+ }
+ disabled={!editor.can().redo() || disabled}
+ >
+ <Redo className="h-4 w-4" />
+ </Toggle>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>다시 실행 (Ctrl+Y)</p>
+ </TooltipContent>
+ </Tooltip>
+ </div>
+ </div>
+ </TooltipProvider>
+ )
+ }
+
+ // ---------------------------------------------------------------------------
+ // Layout & rendering
+ // ---------------------------------------------------------------------------
+ const containerStyle = height === '100%' ? { height: '100%' } : { height }
+ const editorContentStyle =
+ height === '100%' ? { flex: 1, minHeight: 0 } : { height: `calc(${height} - 60px)` }
+
+ return (
+ <div
+ className={`border rounded-md bg-background ${height === '100%' ? 'flex flex-col h-full' : ''}`}
+ style={containerStyle}
+ >
+ <div className="flex-shrink-0 border-b">
+ <Toolbar editor={editor} disabled={disabled} />
+ </div>
+ <div className="overflow-y-auto" style={editorContentStyle}>
+ <EditorContent editor={editor} className="h-full" />
+ </div>
+ </div>
+ )
+} \ No newline at end of file