diff options
Diffstat (limited to 'components/rich-text-editor/RichTextEditor.tsx')
| -rw-r--r-- | components/rich-text-editor/RichTextEditor.tsx | 998 |
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 |
