summaryrefslogtreecommitdiff
path: root/components/qna/tiptap-editor.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/tiptap-editor.tsx
parent6a9ca20deddcdcbe8495cf5a73ec7ea5f53f9b55 (diff)
(대표님/최겸) 20250702 변경사항 업데이트
Diffstat (limited to 'components/qna/tiptap-editor.tsx')
-rw-r--r--components/qna/tiptap-editor.tsx287
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