From 2acf5f8966a40c1c9a97680c8dc263ee3f1ad3d1 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Wed, 2 Jul 2025 00:45:49 +0000 Subject: (대표님/최겸) 20250702 변경사항 업데이트 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/qna/tiptap-editor.tsx | 287 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 287 insertions(+) create mode 100644 components/qna/tiptap-editor.tsx (limited to 'components/qna/tiptap-editor.tsx') 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 { + 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 ( +
+ {/* 툴바: 항상 고정 위치 */} +
+ { + const file = e.target.files?.[0]; + if (file) { + handleImageUpload(file); + } + }} + /> +
+ + {/* 에디터 내용: 스크롤 가능 영역 */} +
+ +
+ + {/* 전역 이미지 스타일 */} + +
+ ) +} \ No newline at end of file -- cgit v1.2.3