diff options
Diffstat (limited to 'components/qna/tiptap-editor.tsx')
| -rw-r--r-- | components/qna/tiptap-editor.tsx | 287 |
1 files changed, 287 insertions, 0 deletions
diff --git a/components/qna/tiptap-editor.tsx b/components/qna/tiptap-editor.tsx new file mode 100644 index 00000000..4ab7f097 --- /dev/null +++ b/components/qna/tiptap-editor.tsx @@ -0,0 +1,287 @@ +import { useEditor, EditorContent } 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 Subscript from '@tiptap/extension-subscript' +import Superscript from '@tiptap/extension-superscript' +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 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 { Toolbar } from './richTextEditor' + +interface TiptapEditorProps { + content: string; + setContent: (content: string) => void; + disabled?: boolean; + height?: string; // 높이 prop 추가 +} + +export default function TiptapEditor({ content, setContent, disabled, height = "300px" }: TiptapEditorProps) { + const editor = useEditor({ + extensions: [ + StarterKit.configure({ + bulletList: false, + orderedList: false, + listItem: false, + blockquote: false, + codeBlock: false, + code: false, + heading: { + levels: [1, 2, 3] + } + }), + 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, + Table.configure({ + resizable: true, + HTMLAttributes: { + class: 'border-collapse table-auto w-full', + }, + }), + TableRow.configure({ + HTMLAttributes: { + class: 'border-b border-gray-200', + }, + }), + TableCell.configure({ + HTMLAttributes: { + class: 'border border-gray-200 p-2', + }, + }), + TableHeader.configure({ + HTMLAttributes: { + class: 'border border-gray-200 p-2 bg-gray-100 font-bold', + }, + }), + Highlight.configure({ + multicolor: true, + }), + TaskList, + TaskItem.configure({ + nested: true, + }), + BulletList.configure({ + HTMLAttributes: { + class: 'list-disc ml-4', + }, + }), + ListItem, + OrderedList.configure({ + HTMLAttributes: { + class: 'list-decimal ml-4', + }, + }), + Blockquote.configure({ + HTMLAttributes: { + class: 'border-l-4 border-gray-300 pl-4 my-4 italic bg-gray-50 py-2', + }, + }), + ], + content: content, + editable: !disabled, + editorProps: { + attributes: { + class: 'w-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 }) => { + setContent(editor.getHTML()); + }, + }) + + // 이미지 크기 확인 함수 + const getImageDimensions = (src: string): Promise<{ width: number; height: number }> => + new Promise((resolve, reject) => { + // 1. 올바른 생성자 + const img = new Image(); + + // 2. 로딩 완료 시 naturalWidth/Height 사용 + img.onload = () => { + resolve({ width: img.naturalWidth, height: img.naturalHeight }); + }; + + img.onerror = () => reject(new Error('이미지 로드 실패')); + img.src = src; // 3. 마지막에 src 지정 + }); + + + async function uploadImageToServer(file: File): Promise<string> { + const formData = new FormData(); + formData.append('file', file); + + const res = await fetch('/api/richtext', { + method: 'POST', + body: formData, + }); + + if (!res.ok) { + const { error } = await res.json(); + throw new Error(error ?? 'upload failed'); + } + + const { url } = (await res.json()) as { url: string }; + return url; + } + +// Base64 → 서버 업로드 방식으로 교체 +const handleImageUpload = async (file: File) => { + try { + if (file.size > 3 * 1024 * 1024) { + alert('이미지 크기는 3 MB 이하만 지원됩니다.'); + return; + } + if (!file.type.startsWith('image/')) { + alert('이미지 파일만 업로드 가능합니다.'); + return; + } + + const url = await uploadImageToServer(file); // ← 업로드 & URL 획득 + + // 이미지 크기(너비)에 따라 style 조정 + const { width, height } = await getImageDimensions(url); + const maxWidth = 600; + const newW = width > maxWidth ? maxWidth : width; + const newH = width > maxWidth ? (height * maxWidth) / width : height; + + editor + ?.chain() + .focus() + .setImage({ + src: url, + style: `width:${newW}px;height:${newH}px;max-width:100%;`, + }) + .run(); + } catch (e) { + console.error(e); + alert('이미지 업로드에 실패했습니다.'); + } +}; + + // 높이 계산 (100%인 경우 flex 사용, 아니면 구체적 높이) + const containerStyle = height === "100%" + ? { height: "100%" } + : { height }; + + const editorContentStyle = height === "100%" + ? { flex: 1, minHeight: 0 } + : { height: `calc(${height} - 60px)` }; // 툴바 높이(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} + onImageUpload={(e) => { + const file = e.target.files?.[0]; + if (file) { + handleImageUpload(file); + } + }} + /> + </div> + + {/* 에디터 내용: 스크롤 가능 영역 */} + <div + className="overflow-y-auto" + style={editorContentStyle} + > + <EditorContent + editor={editor} + className="h-full" + /> + </div> + + {/* 전역 이미지 스타일 */} + <style jsx>{` + .ProseMirror { + min-height: 150px; + padding: 12px; + outline: none; + } + + .ProseMirror img { + max-width: 600px !important; + height: auto !important; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + cursor: pointer; + } + + .ProseMirror img:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + transition: box-shadow 0.2s ease; + } + + /* 스크롤바 스타일링 */ + .overflow-y-auto::-webkit-scrollbar { + width: 6px; + } + + .overflow-y-auto::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 3px; + } + + .overflow-y-auto::-webkit-scrollbar-thumb { + background: #c1c1c1; + border-radius: 3px; + } + + .overflow-y-auto::-webkit-scrollbar-thumb:hover { + background: #a8a8a8; + } + `}</style> + </div> + ) +}
\ No newline at end of file |
