summaryrefslogtreecommitdiff
path: root/components/qna/richTextEditor.tsx
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-07-02 00:45:49 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-07-02 00:45:49 +0000
commit2acf5f8966a40c1c9a97680c8dc263ee3f1ad3d1 (patch)
treef406b5c86f563347c7fd088a85fd1a82284dc5ff /components/qna/richTextEditor.tsx
parent6a9ca20deddcdcbe8495cf5a73ec7ea5f53f9b55 (diff)
(대표님/최겸) 20250702 변경사항 업데이트
Diffstat (limited to 'components/qna/richTextEditor.tsx')
-rw-r--r--components/qna/richTextEditor.tsx676
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