diff options
Diffstat (limited to 'components/qna/richTextEditor.tsx')
| -rw-r--r-- | components/qna/richTextEditor.tsx | 676 |
1 files changed, 676 insertions, 0 deletions
diff --git a/components/qna/richTextEditor.tsx b/components/qna/richTextEditor.tsx new file mode 100644 index 00000000..37e07a89 --- /dev/null +++ b/components/qna/richTextEditor.tsx @@ -0,0 +1,676 @@ +import { type Editor } from '@tiptap/react' +import { useState, useEffect, useCallback, useRef } from 'react' +import { + Bold, + Italic, + Underline, + Strikethrough, + ListOrdered, + List, + Quote, + Undo, + Redo, + Link, + Image, + AlignLeft, + AlignCenter, + AlignRight, + AlignJustify, + Subscript, + Superscript, + Table, + Highlighter, + CheckSquare, + Type, +} from 'lucide-react' +import { Toggle } from '../ui/toggle' +import { Separator } from '../ui/separator' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" + +interface ToolbarProps { + editor: Editor | null; + disabled?: boolean; + onImageUpload: (e: React.ChangeEvent<HTMLInputElement>) => void; +} + +export function Toolbar({ editor, disabled, onImageUpload }: ToolbarProps) { + // 툴바 상태 관리 + const [toolbarState, setToolbarState] = useState({ + 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', + }); + + // 디바운스를 위한 ref + const updateTimeoutRef = useRef<NodeJS.Timeout | null>(null); + + // 툴바 상태 업데이트 함수 (디바운스 적용) + const updateToolbarState = useCallback(() => { + if (!editor) return; + + if (updateTimeoutRef.current) { + clearTimeout(updateTimeoutRef.current); + } + + updateTimeoutRef.current = setTimeout(() => { + try { + const newState = { + 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: editor.isActive('heading'), + textAlign: editor.isActive({ textAlign: 'center' }) ? 'center' : + editor.isActive({ textAlign: 'right' }) ? 'right' : + editor.isActive({ textAlign: 'justify' }) ? 'justify' : 'left', + }; + + setToolbarState(prevState => { + // 상태가 실제로 변경된 경우에만 업데이트 + const hasChanged = Object.keys(newState).some( + key => prevState[key as keyof typeof prevState] !== newState[key as keyof typeof newState] + ); + + if (hasChanged) { + console.log('Toolbar state updated:', newState); + return newState; + } + + return prevState; + }); + } catch (error) { + console.error('툴바 상태 업데이트 에러:', error); + } + }, 50); // 50ms 디바운스 + }, [editor]); + + // editor 이벤트 리스너 설정 + useEffect(() => { + if (!editor) return; + + // 초기 상태 설정 + updateToolbarState(); + + // 이벤트 리스너 등록 (selectionUpdate만 사용 - transaction은 너무 자주 발생) + editor.on('selectionUpdate', updateToolbarState); + editor.on('focus', updateToolbarState); + editor.on('blur', updateToolbarState); + + return () => { + if (updateTimeoutRef.current) { + clearTimeout(updateTimeoutRef.current); + } + editor.off('selectionUpdate', updateToolbarState); + editor.off('focus', updateToolbarState); + editor.off('blur', updateToolbarState); + }; + }, [editor, updateToolbarState]); + + // 명령 실행 헬퍼 함수 + const executeCommand = useCallback((command: () => void) => { + if (editor && !disabled) { + command(); + // 명령 실행 후 즉시 상태 업데이트 + updateToolbarState(); + } + }, [editor, disabled, updateToolbarState]); + + if (!editor) { + return null; + } + + + return ( + <TooltipProvider> + <div className="border border-input bg-transparent rounded-t-md"> + <div className="flex flex-wrap gap-1 p-1"> + {/* 텍스트 스타일 */} + <Tooltip> + <TooltipTrigger asChild> + <span + className="inline-flex" /* Trigger용 */ + tabIndex={0} /* 키보드 접근성 */ + > + <Toggle + size="sm" + pressed={toolbarState.bold} + onPressedChange={() => executeCommand(() => + editor.chain().focus().toggleBold().run() + )} + disabled={disabled} + > + <Bold className="h-4 w-4" /> + </Toggle> + </span> + </TooltipTrigger> + <TooltipContent> + <p>굵게 (Ctrl+B)</p> + </TooltipContent> + </Tooltip> + + <Tooltip> + <TooltipTrigger asChild> + <span + className="inline-flex" /* Trigger용 */ + tabIndex={0} /* 키보드 접근성 */ + > + <Toggle + size="sm" + pressed={toolbarState.italic} + onPressedChange={() => executeCommand(() => + editor.chain().focus().toggleItalic().run() + )} + disabled={disabled} + > + <Italic className="h-4 w-4" /> + </Toggle> + </span> + </TooltipTrigger> + <TooltipContent> + <p>기울임 (Ctrl+I)</p> + </TooltipContent> + </Tooltip> + + <Tooltip> + <TooltipTrigger asChild> + <span + className="inline-flex" /* Trigger용 */ + tabIndex={0} /* 키보드 접근성 */ + > + <Toggle + size="sm" + pressed={toolbarState.underline} + onPressedChange={() => executeCommand(() => + editor.chain().focus().toggleUnderline().run() + )} + disabled={disabled} + > + <Underline className="h-4 w-4" /> + </Toggle> + </span> + </TooltipTrigger> + <TooltipContent> + <p>밑줄</p> + </TooltipContent> + </Tooltip> + + <Tooltip> + <TooltipTrigger asChild> + <span + className="inline-flex" /* Trigger용 */ + tabIndex={0} /* 키보드 접근성 */ + > + <Toggle + size="sm" + pressed={toolbarState.strike} + onPressedChange={() => executeCommand(() => + editor.chain().focus().toggleStrike().run() + )} + disabled={disabled} + > + <Strikethrough className="h-4 w-4" /> + </Toggle> + </span> + </TooltipTrigger> + <TooltipContent> + <p>취소선</p> + </TooltipContent> + </Tooltip> + + <Separator orientation="vertical" className="h-6" /> + + {/* 제목 및 단락 */} + <DropdownMenu> + <DropdownMenuTrigger asChild> + <span + className="inline-flex" /* Trigger용 */ + tabIndex={0} /* 키보드 접근성 */ + > + <Toggle + size="sm" + pressed={toolbarState.heading} + disabled={disabled} + > + <Type className="h-4 w-4" /> + </Toggle> + </span> + </DropdownMenuTrigger> + <DropdownMenuContent align="start"> + <DropdownMenuItem + onClick={() => executeCommand(() => + editor.chain().focus().toggleHeading({ level: 1 }).run() + )} + className="flex items-center" + > + <span className="text-xl font-bold">제목 1</span> + </DropdownMenuItem> + <DropdownMenuItem + onClick={() => executeCommand(() => + editor.chain().focus().toggleHeading({ level: 2 }).run() + )} + className="flex items-center" + > + <span className="text-lg font-bold">제목 2</span> + </DropdownMenuItem> + <DropdownMenuItem + onClick={() => executeCommand(() => + editor.chain().focus().toggleHeading({ level: 3 }).run() + )} + className="flex items-center" + > + <span className="text-base font-bold">제목 3</span> + </DropdownMenuItem> + <DropdownMenuItem + onClick={() => executeCommand(() => + editor.chain().focus().setParagraph().run() + )} + className="flex items-center" + > + <span>본문</span> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + + <Separator orientation="vertical" className="h-6" /> + + {/* 리스트 */} + <Tooltip> + <TooltipTrigger asChild> + <span + className="inline-flex" /* Trigger용 */ + tabIndex={0} /* 키보드 접근성 */ + > + <Toggle + size="sm" + pressed={toolbarState.bulletList} + onPressedChange={() => executeCommand(() => + editor.chain().focus().toggleBulletList().run() + )} + disabled={disabled} + > + <List className="h-4 w-4" /> + </Toggle> + </span> + </TooltipTrigger> + <TooltipContent> + <p>글머리 기호</p> + </TooltipContent> + </Tooltip> + + <Tooltip> + <TooltipTrigger asChild> + <span + className="inline-flex" /* Trigger용 */ + tabIndex={0} /* 키보드 접근성 */ + > + <Toggle + size="sm" + pressed={toolbarState.orderedList} + onPressedChange={() => executeCommand(() => + editor.chain().focus().toggleOrderedList().run() + )} + disabled={disabled} + > + <ListOrdered className="h-4 w-4" /> + </Toggle> + </span> + </TooltipTrigger> + <TooltipContent> + <p>번호 매기기</p> + </TooltipContent> + </Tooltip> + + <Tooltip> + <TooltipTrigger asChild> + <span + className="inline-flex" /* Trigger용 */ + tabIndex={0} /* 키보드 접근성 */ + > + <Toggle + size="sm" + pressed={toolbarState.blockquote} + onPressedChange={() => executeCommand(() => + editor.chain().focus().toggleBlockquote().run() + )} + disabled={disabled} + > + <Quote className="h-4 w-4" /> + </Toggle> + + </span> + </TooltipTrigger> + <TooltipContent> + <p>인용문</p> + </TooltipContent> + </Tooltip> + + <Separator orientation="vertical" className="h-6" /> + + {/* 텍스트 정렬 */} + <DropdownMenu> + <DropdownMenuTrigger asChild> + <span className="inline-flex"> + <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> + </span> + </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} + > + <Link 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" + onChange={onImageUpload} + /> + <Toggle + size="sm" + pressed={false} + onPressedChange={() => { + document.getElementById('image-upload')?.click(); + }} + disabled={disabled} + > + <Image className="h-4 w-4" /> + </Toggle> + </div> + </TooltipTrigger> + <TooltipContent> + <p>이미지 삽입</p> + </TooltipContent> + </Tooltip> + + <Separator orientation="vertical" className="h-6" /> + + {/* 첨자 */} + <Tooltip> + <TooltipTrigger asChild> + <span + className="inline-flex" /* Trigger용 */ + tabIndex={0} /* 키보드 접근성 */ + > + <Toggle + size="sm" + pressed={toolbarState.subscript} + onPressedChange={() => executeCommand(() => + editor.chain().focus().toggleSubscript().run() + )} + disabled={disabled} + > + <Subscript className="h-4 w-4" /> + </Toggle> + </span> + </TooltipTrigger> + <TooltipContent> + <p>아래 첨자</p> + </TooltipContent> + </Tooltip> + + <Tooltip> + <TooltipTrigger asChild> + <span + className="inline-flex" /* Trigger용 */ + tabIndex={0} /* 키보드 접근성 */ + > + <Toggle + size="sm" + pressed={toolbarState.superscript} + onPressedChange={() => executeCommand(() => + editor.chain().focus().toggleSuperscript().run() + )} + disabled={disabled} + > + <Superscript className="h-4 w-4" /> + </Toggle> + </span> + </TooltipTrigger> + <TooltipContent> + <p>위 첨자</p> + </TooltipContent> + </Tooltip> + + <Separator orientation="vertical" className="h-6" /> + + {/* 하이라이트 */} + <Tooltip> + <TooltipTrigger asChild> + <span + className="inline-flex" /* Trigger용 */ + tabIndex={0} /* 키보드 접근성 */ + > + <Toggle + size="sm" + pressed={toolbarState.highlight} + onPressedChange={() => executeCommand(() => + editor.chain().focus().toggleHighlight().run() + )} + disabled={disabled} + > + <Highlighter className="h-4 w-4" /> + </Toggle> + </span> + </TooltipTrigger> + <TooltipContent> + <p>하이라이트</p> + </TooltipContent> + </Tooltip> + + {/* 체크리스트 */} + <Tooltip> + <TooltipTrigger asChild> + <span + className="inline-flex" /* Trigger용 */ + tabIndex={0} /* 키보드 접근성 */ + > + <Toggle + size="sm" + pressed={toolbarState.taskList} + onPressedChange={() => executeCommand(() => + editor.chain().focus().toggleTaskList().run() + )} + disabled={disabled} + > + <CheckSquare className="h-4 w-4" /> + </Toggle> + </span> + </TooltipTrigger> + <TooltipContent> + <p>체크리스트</p> + </TooltipContent> + </Tooltip> + + <Separator orientation="vertical" className="h-6" /> + + {/* 테이블 */} + <Tooltip> + <TooltipTrigger asChild> + <span + className="inline-flex" /* Trigger용 */ + tabIndex={0} /* 키보드 접근성 */ + > + <Toggle + size="sm" + pressed={toolbarState.table} + onPressedChange={() => { + if (toolbarState.table) { + executeCommand(() => editor.chain().focus().deleteTable().run()); + } else { + executeCommand(() => editor.chain().focus().insertTable({ rows: 3, cols: 3 }).run()); + } + }} + disabled={disabled} + > + <Table className="h-4 w-4" /> + </Toggle> + </span> + </TooltipTrigger> + <TooltipContent> + <p>{toolbarState.table ? '테이블 삭제' : '테이블 삽입'}</p> + </TooltipContent> + </Tooltip> + + <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> + ) +}
\ No newline at end of file |
