diff options
Diffstat (limited to 'components/rich-text-editor/RichTextEditor.tsx')
| -rw-r--r-- | components/rich-text-editor/RichTextEditor.tsx | 1050 |
1 files changed, 139 insertions, 911 deletions
diff --git a/components/rich-text-editor/RichTextEditor.tsx b/components/rich-text-editor/RichTextEditor.tsx index ceb76665..1360a5f8 100644 --- a/components/rich-text-editor/RichTextEditor.tsx +++ b/components/rich-text-editor/RichTextEditor.tsx @@ -1,17 +1,15 @@ 'use client' -import React, { useCallback, useRef, useState, useEffect } from 'react' +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 { 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 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' @@ -23,95 +21,23 @@ 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' -// 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' +import { FontSize } from './extensions/font-size' +import ImageResize from 'tiptap-extension-resize-image' +import { Toolbar } from './Toolbar' -/* ------------------------------------------------------------------------------------------------- - * 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%" + height?: string + className?: string + placeholder?: string + debounceMs?: number + onReady?: (editor: Editor) => void + onFocus?: () => void + onBlur?: () => void } export default function RichTextEditor({ @@ -119,51 +45,75 @@ export default function RichTextEditor({ onChange, disabled, height = '300px', + className, + placeholder, + debounceMs = 200, + onReady, + onFocus, + onBlur, }: RichTextEditorProps) { - // --------------------------------------------------------------------------- - // Editor instance - // --------------------------------------------------------------------------- + const updateTimerRef = useRef<number | undefined>(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: [ - 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, - ], + extensions: extensionsForEditor, content: value, editable: !disabled, enablePasteRules: false, @@ -172,7 +122,7 @@ export default function RichTextEditor({ 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', + '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) { @@ -194,805 +144,83 @@ export default function RichTextEditor({ } return false }, + handleDOMEvents: { + focus: () => { + onFocus?.() + return false + }, + blur: () => { + onBlur?.() + return false + }, + }, }, onUpdate: ({ editor }) => { - onChange(editor.getHTML()) + if (updateTimerRef.current) window.clearTimeout(updateTimerRef.current) + updateTimerRef.current = window.setTimeout(() => { + onChange(editor.getHTML()) + }, debounceMs) as unknown as number }, }) - // --------------------------------------------------------------------------- - // Image handling (base64) - // --------------------------------------------------------------------------- + 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<string> => + 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) { - alert('이미지 크기는 3 MB 이하만 지원됩니다.') + toast.error('이미지 크기는 3 MB 이하만 지원됩니다.') return } if (!file.type.startsWith('image/')) { - alert('이미지 파일만 업로드 가능합니다.') + toast.error('이미지 파일만 업로드 가능합니다.') 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() + try { + const dataUrl = await readFileAsDataURL(file) + editor?.chain().focus().setImage({ src: dataUrl, alt: file.name }).run() + } catch (error) { + console.error(error) + toast.error('이미지 읽기에 실패했습니다.') } - 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)` } + const containerStyle = { height } 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 className={`border rounded-md bg-background flex flex-col ${className ?? ''}`} style={containerStyle}> + <div className="flex-none border-b"> + <Toolbar editor={editor} disabled={disabled} onSelectImageFile={handleImageUpload} /> </div> - <div className="overflow-y-auto" style={editorContentStyle}> + <div className="flex-1 min-h-0 overflow-y-auto"> <EditorContent editor={editor} className="h-full" /> </div> </div> ) -}
\ No newline at end of file +} + + |
