From bcd462d6e60871b86008e072f4b914138fc5c328 Mon Sep 17 00:00:00 2001 From: joonhoekim <26rote@gmail.com> Date: Mon, 11 Aug 2025 09:34:40 +0000 Subject: (김준회) 리치텍스트에디터 (결재템플릿을 위한 공통컴포넌트), command-menu 에러 수정, 결재 템플릿 관리, 결재선 관리, ECC RFQ+PR Item 수신시 비즈니스테이블(ProcurementRFQ) 데이터 적재, WSDL 오류 수정 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/rich-text-editor/BlockquoteButton.tsx | 38 + components/rich-text-editor/BulletListButton.tsx | 46 + components/rich-text-editor/HistoryMenu.tsx | 43 + components/rich-text-editor/InlineStyleMenu.tsx | 67 ++ components/rich-text-editor/OrderedListButton.tsx | 38 + components/rich-text-editor/RichTextEditor.tsx | 1050 +++----------------- components/rich-text-editor/StyleMenu.tsx | 65 ++ components/rich-text-editor/TextAlignMenu.tsx | 46 + components/rich-text-editor/Toolbar.tsx | 350 +++++++ .../rich-text-editor/extensions/font-size.ts | 31 + 10 files changed, 863 insertions(+), 911 deletions(-) create mode 100644 components/rich-text-editor/BlockquoteButton.tsx create mode 100644 components/rich-text-editor/BulletListButton.tsx create mode 100644 components/rich-text-editor/HistoryMenu.tsx create mode 100644 components/rich-text-editor/InlineStyleMenu.tsx create mode 100644 components/rich-text-editor/OrderedListButton.tsx create mode 100644 components/rich-text-editor/StyleMenu.tsx create mode 100644 components/rich-text-editor/TextAlignMenu.tsx create mode 100644 components/rich-text-editor/Toolbar.tsx create mode 100644 components/rich-text-editor/extensions/font-size.ts (limited to 'components/rich-text-editor') diff --git a/components/rich-text-editor/BlockquoteButton.tsx b/components/rich-text-editor/BlockquoteButton.tsx new file mode 100644 index 00000000..be9a342b --- /dev/null +++ b/components/rich-text-editor/BlockquoteButton.tsx @@ -0,0 +1,38 @@ +'use client' + +import React from 'react' +import type { Editor } from '@tiptap/react' +import { Toggle } from '@/components/ui/toggle' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { Quote as QuoteIcon } from 'lucide-react' + +interface BlockquoteButtonProps { + editor: Editor | null + disabled?: boolean + isActive: boolean + executeCommand: (command: () => void) => void +} + +export function BlockquoteButton({ editor, disabled, isActive, executeCommand }: BlockquoteButtonProps) { + if (!editor) return null + return ( + + + e.preventDefault()} + onPressedChange={() => executeCommand(() => editor.chain().focus().toggleBlockquote().run())} + disabled={disabled} + > + + + + +

인용문

+
+
+ ) +} + + diff --git a/components/rich-text-editor/BulletListButton.tsx b/components/rich-text-editor/BulletListButton.tsx new file mode 100644 index 00000000..bf5b833c --- /dev/null +++ b/components/rich-text-editor/BulletListButton.tsx @@ -0,0 +1,46 @@ +'use client' + +import React from 'react' +import type { Editor } from '@tiptap/react' +import { Toggle } from '@/components/ui/toggle' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { List as ListIcon } from 'lucide-react' + +interface BulletListButtonProps { + editor: Editor | null + disabled?: boolean + isActive: boolean + executeCommand: (command: () => void) => void +} + +export function BulletListButton({ editor, disabled, isActive, executeCommand }: BulletListButtonProps) { + + + if (!editor) return null + + const handleToggleBulletList = () => { + console.log('toggleBulletList') + executeCommand(() => editor.chain().focus().toggleBulletList().run()) + } + + return ( + + + e.preventDefault()} + onPressedChange={handleToggleBulletList} + disabled={disabled} + > + + + + +

글머리 기호

+
+
+ ) +} + + diff --git a/components/rich-text-editor/HistoryMenu.tsx b/components/rich-text-editor/HistoryMenu.tsx new file mode 100644 index 00000000..e5bb819c --- /dev/null +++ b/components/rich-text-editor/HistoryMenu.tsx @@ -0,0 +1,43 @@ +'use client' + +import React from 'react' +import type { Editor } from '@tiptap/react' +import { Toggle } from '@/components/ui/toggle' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { Undo, Redo } from 'lucide-react' + +interface HistoryMenuProps { + editor: Editor | null + disabled?: boolean + executeCommand: (command: () => void) => void +} + +export function HistoryMenu({ editor, disabled, executeCommand }: HistoryMenuProps) { + if (!editor) return null + return ( + <> + + + executeCommand(() => editor.chain().focus().undo().run())} disabled={!editor.can().undo() || disabled}> + + + + +

실행 취소 (Ctrl+Z)

+
+
+ + + executeCommand(() => editor.chain().focus().redo().run())} disabled={!editor.can().redo() || disabled}> + + + + +

다시 실행 (Ctrl+Y)

+
+
+ + ) +} + + diff --git a/components/rich-text-editor/InlineStyleMenu.tsx b/components/rich-text-editor/InlineStyleMenu.tsx new file mode 100644 index 00000000..02eac252 --- /dev/null +++ b/components/rich-text-editor/InlineStyleMenu.tsx @@ -0,0 +1,67 @@ +'use client' + +import React from 'react' +import type { Editor } from '@tiptap/react' +import { Toggle } from '@/components/ui/toggle' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { Bold, Italic, Underline as UnderlineIcon, Strikethrough } from 'lucide-react' + +interface InlineStyleMenuProps { + editor: Editor | null + disabled?: boolean + isBold: boolean + isItalic: boolean + isUnderline: boolean + isStrike: boolean + executeCommand: (command: () => void) => void +} + +export function InlineStyleMenu({ editor, disabled, isBold, isItalic, isUnderline, isStrike, executeCommand }: InlineStyleMenuProps) { + if (!editor) return null + return ( + <> + + + executeCommand(() => editor.chain().focus().toggleBold().run())} disabled={disabled}> + + + + +

굵게 (Ctrl+B)

+
+
+ + + executeCommand(() => editor.chain().focus().toggleItalic().run())} disabled={disabled}> + + + + +

기울임 (Ctrl+I)

+
+
+ + + executeCommand(() => editor.chain().focus().toggleUnderline().run())} disabled={disabled}> + + + + +

밑줄

+
+
+ + + executeCommand(() => editor.chain().focus().toggleStrike().run())} disabled={disabled}> + + + + +

취소선

+
+
+ + ) +} + + diff --git a/components/rich-text-editor/OrderedListButton.tsx b/components/rich-text-editor/OrderedListButton.tsx new file mode 100644 index 00000000..f4f68729 --- /dev/null +++ b/components/rich-text-editor/OrderedListButton.tsx @@ -0,0 +1,38 @@ +'use client' + +import React from 'react' +import type { Editor } from '@tiptap/react' +import { Toggle } from '@/components/ui/toggle' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { ListOrdered as ListOrderedIcon } from 'lucide-react' + +interface OrderedListButtonProps { + editor: Editor | null + disabled?: boolean + isActive: boolean + executeCommand: (command: () => void) => void +} + +export function OrderedListButton({ editor, disabled, isActive, executeCommand }: OrderedListButtonProps) { + if (!editor) return null + return ( + + + e.preventDefault()} + onPressedChange={() => executeCommand(() => editor.chain().focus().toggleOrderedList().run())} + disabled={disabled} + > + + + + +

번호 매기기

+
+
+ ) +} + + diff --git a/components/rich-text-editor/RichTextEditor.tsx b/components/rich-text-editor/RichTextEditor.tsx index ceb76665..1360a5f8 100644 --- a/components/rich-text-editor/RichTextEditor.tsx +++ b/components/rich-text-editor/RichTextEditor.tsx @@ -1,17 +1,15 @@ 'use client' -import React, { useCallback, useRef, useState, useEffect } from 'react' +import React, { useRef, useEffect } from 'react' import { useEditor, EditorContent, type Editor } 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 TextStyle from '@tiptap/extension-text-style' import Subscript from '@tiptap/extension-subscript' import Superscript from '@tiptap/extension-superscript' -import { Extension } from '@tiptap/core' +import Placeholder from '@tiptap/extension-placeholder' import Highlight from '@tiptap/extension-highlight' import TaskList from '@tiptap/extension-task-list' import TaskItem from '@tiptap/extension-task-item' @@ -23,95 +21,23 @@ 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 { toast } from 'sonner' -// shadcn/ui & lucide -import { - Bold, - Italic, - Underline as UnderlineIcon, - Strikethrough, - ListOrdered, - List, - Quote, - Undo, - Redo, - Link as LinkIcon, - Image as ImageIcon, - AlignLeft, - AlignCenter, - AlignRight, - AlignJustify, - Subscript as SubscriptIcon, - Superscript as SuperscriptIcon, - Table as TableIcon, - Highlighter, - CheckSquare, - Type, -} from 'lucide-react' -import { Toggle } from '@/components/ui/toggle' -import { Separator } from '@/components/ui/separator' -import { Input } from '@/components/ui/input' -import { Button } from '@/components/ui/button' -import { Label } from '@/components/ui/label' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from '@/components/ui/dialog' -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from '@/components/ui/tooltip' +import { FontSize } from './extensions/font-size' +import ImageResize from 'tiptap-extension-resize-image' +import { Toolbar } from './Toolbar' -/* ------------------------------------------------------------------------------------------------- - * FontSize extension (wraps TextStyle) - * -------------------------------------------------------------------------------------------------*/ -const FontSize = Extension.create({ - name: 'fontSize', - addGlobalAttributes() { - return [ - { - types: ['textStyle'], - attributes: { - fontSize: { - default: null, - parseHTML: element => { - const size = element.style.fontSize - return size ? size.replace(/[^0-9]/g, '') : null - }, - renderHTML: attributes => { - if (!attributes.fontSize) return {} - return { - style: `font-size: ${attributes.fontSize}`, - } - }, - }, - }, - }, - ] - }, -}) - -/* ------------------------------------------------------------------------------------------------- - * Props & component - * -------------------------------------------------------------------------------------------------*/ interface RichTextEditorProps { value: string onChange: (val: string) => void disabled?: boolean - height?: string // e.g. "400px" or "100%" + height?: string + className?: string + placeholder?: string + debounceMs?: number + onReady?: (editor: Editor) => void + onFocus?: () => void + onBlur?: () => void } export default function RichTextEditor({ @@ -119,51 +45,75 @@ export default function RichTextEditor({ onChange, disabled, height = '300px', + className, + placeholder, + debounceMs = 200, + onReady, + onFocus, + onBlur, }: RichTextEditorProps) { - // --------------------------------------------------------------------------- - // Editor instance - // --------------------------------------------------------------------------- + const updateTimerRef = useRef(undefined) + + const computedExtensions: unknown[] = [ + StarterKit.configure({ + bulletList: false, + orderedList: false, + listItem: false, + blockquote: false, + codeBlock: false, + code: false, + heading: { levels: [1, 2, 3] }, + horizontalRule: false, + }), + Underline, + ImageResize, + TextAlign.configure({ + types: ['heading', 'paragraph'], + alignments: ['left', 'center', 'right', 'justify'], + defaultAlignment: 'left', + }), + Subscript, + Superscript, + TextStyle, + FontSize, + Table.configure({ resizable: true }), + TableRow, + TableCell, + TableHeader, + Highlight.configure({ multicolor: true }), + TaskList, + TaskItem.configure({ nested: true }), + BulletList.configure({ + HTMLAttributes: { + class: 'list-disc ml-5', + }, + }), + ListItem.configure({ + HTMLAttributes: { + class: 'list-item my-0.5', + }, + }), + OrderedList.configure({ + HTMLAttributes: { + class: 'list-decimal ml-5', + }, + }), + Blockquote.configure({ + HTMLAttributes: { + class: 'border-l-4 pl-4 my-3 italic', + }, + }), + ] + + if (placeholder) { + computedExtensions.push(Placeholder.configure({ placeholder })) + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const extensionsForEditor = computedExtensions as any + const editor = useEditor({ - extensions: [ - StarterKit.configure({ - bulletList: false, - orderedList: false, - listItem: false, - blockquote: false, - codeBlock: false, - code: false, - heading: { levels: [1, 2, 3] }, - horizontalRule: false, - }), - 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, - TextStyle, - FontSize, - Table.configure({ resizable: true }), - TableRow, - TableCell, - TableHeader, - Highlight.configure({ multicolor: true }), - TaskList, - TaskItem.configure({ nested: true }), - BulletList, - ListItem, - OrderedList, - Blockquote, - ], + extensions: extensionsForEditor, content: value, editable: !disabled, enablePasteRules: false, @@ -172,7 +122,7 @@ export default function RichTextEditor({ editorProps: { attributes: { class: - 'w-full h-full min-h-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', + 'w-full h-full min-h-full bg-background px-3 py-2 text-sm leading-[1.6] 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) { @@ -194,805 +144,83 @@ export default function RichTextEditor({ } return false }, + handleDOMEvents: { + focus: () => { + onFocus?.() + return false + }, + blur: () => { + onBlur?.() + return false + }, + }, }, onUpdate: ({ editor }) => { - onChange(editor.getHTML()) + if (updateTimerRef.current) window.clearTimeout(updateTimerRef.current) + updateTimerRef.current = window.setTimeout(() => { + onChange(editor.getHTML()) + }, debounceMs) as unknown as number }, }) - // --------------------------------------------------------------------------- - // Image handling (base64) - // --------------------------------------------------------------------------- + useEffect(() => { + if (!editor) return + const current = editor.getHTML() + if (value !== current) { + editor.commands.setContent(value, false) + } + }, [editor, value]) + + useEffect(() => { + if (!editor) return + editor.setEditable(!disabled) + }, [editor, disabled]) + + const readyCalledRef = useRef(false) + useEffect(() => { + if (!editor || readyCalledRef.current) return + readyCalledRef.current = true + onReady?.(editor) + }, [editor, onReady]) + + const readFileAsDataURL = (file: File): Promise => + new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = e => resolve(String(e.target?.result)) + reader.onerror = reject + reader.readAsDataURL(file) + }) + const handleImageUpload = async (file: File) => { if (file.size > 3 * 1024 * 1024) { - alert('이미지 크기는 3 MB 이하만 지원됩니다.') + toast.error('이미지 크기는 3 MB 이하만 지원됩니다.') return } if (!file.type.startsWith('image/')) { - alert('이미지 파일만 업로드 가능합니다.') + toast.error('이미지 파일만 업로드 가능합니다.') return } - const reader = new FileReader() - reader.onload = e => { - const base64 = e.target?.result as string - editor?.chain().focus().setImage({ src: base64, alt: file.name }).run() + try { + const dataUrl = await readFileAsDataURL(file) + editor?.chain().focus().setImage({ src: dataUrl, alt: file.name }).run() + } catch (error) { + console.error(error) + toast.error('이미지 읽기에 실패했습니다.') } - reader.onerror = () => alert('이미지 읽기에 실패했습니다.') - reader.readAsDataURL(file) } - // --------------------------------------------------------------------------- - // Toolbar (internal component) - // --------------------------------------------------------------------------- - const Toolbar: React.FC<{ editor: Editor | null; disabled?: boolean }> = ({ - editor, - disabled, - }) => { - const [fontSize, setFontSize] = useState('16') - const [isTableDialogOpen, setIsTableDialogOpen] = useState(false) - const [tableRows, setTableRows] = useState('3') - const [tableCols, setTableCols] = useState('3') - - // 간단한 툴바 상태 계산 - 실시간으로 계산하여 상태 동기화 문제 해결 - const getToolbarState = useCallback(() => { - if (!editor) return { - 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', - } - - const textAlign = editor.isActive({ textAlign: 'center' }) - ? 'center' - : editor.isActive({ textAlign: 'right' }) - ? 'right' - : editor.isActive({ textAlign: 'justify' }) - ? 'justify' - : 'left' - - return { - 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: [1, 2, 3, 4, 5, 6].some(l => editor.isActive('heading', { level: l })), - textAlign: textAlign as 'left' | 'center' | 'right' | 'justify', - } - }, [editor]) - - const toolbarState = getToolbarState() - - // 폰트 사이즈 업데이트 - 복잡한 timeout 로직 제거 - useEffect(() => { - if (!editor) return - - const updateFontSize = () => { - const currentFontSizeAttr = editor.getAttributes('textStyle').fontSize - if (typeof currentFontSizeAttr === 'string') { - const sizeValue = currentFontSizeAttr.replace('px', '') - setFontSize(sizeValue) - } else { - setFontSize('16') - } - } - - updateFontSize() - editor.on('selectionUpdate', updateFontSize) - editor.on('transaction', updateFontSize) - - return () => { - editor.off('selectionUpdate', updateFontSize) - editor.off('transaction', updateFontSize) - } - }, [editor]) - - // 개선된 executeCommand - 포커스 문제 해결 및 단순화 - const executeCommand = useCallback( - (command: () => void) => { - if (!editor || disabled) return - - // 명령 실행 전 포커스 확보 - if (!editor.isFocused) { - editor.commands.focus() - } - - // 명령 실행 - command() - - // 명령 실행 후 포커스 유지 - setTimeout(() => { - if (editor && !editor.isFocused) { - editor.commands.focus() - } - }, 10) - }, - [editor, disabled] - ) - - // 폰트 사이즈 입력 필드의 동적 width 계산 - const getFontSizeInputWidth = useCallback((size: string) => { - const length = size.length - return Math.max(length * 8 + 16, 40) // 최소 40px, 글자 수에 따라 증가 - }, []) - - if (!editor) return null - - // --- Render toolbar UI --- - return ( - -
-
- {/* 텍스트 스타일 */} - - - - executeCommand(() => editor.chain().focus().toggleBold().run()) - } - disabled={disabled} - > - - - - -

굵게 (Ctrl+B)

-
-
- - - - - executeCommand(() => editor.chain().focus().toggleItalic().run()) - } - disabled={disabled} - > - - - - -

기울임 (Ctrl+I)

-
-
- - - - - executeCommand(() => editor.chain().focus().toggleUnderline().run()) - } - disabled={disabled} - > - - - - -

밑줄

-
-
- - - - - executeCommand(() => editor.chain().focus().toggleStrike().run()) - } - disabled={disabled} - > - - - - -

취소선

-
-
- - - - {/* 제목 및 단락 */} - - - - - - - - {([1, 2, 3] as Array<1 | 2 | 3>).map((level) => ( - - executeCommand(() => - editor.chain().focus().toggleHeading({ level }).run() - ) - } - className="flex items-center" - > - - 제목 {level} - - - ))} - - executeCommand(() => editor.chain().focus().setParagraph().run()) - } - className="flex items-center" - > - 본문 - - - - - {/* 글자 크기 - 동적 width 적용 */} -
- { - const size = e.target.value - setFontSize(size) - if (size && parseInt(size) >= 8 && parseInt(size) <= 72) { - executeCommand(() => - editor - .chain() - .focus() - .setMark('textStyle', { fontSize: `${size}px` }) - .run() - ) - } - }} - style={{ width: `${getFontSizeInputWidth(fontSize)}px` }} - className="h-8 text-xs" - disabled={disabled} - /> - - - - - - {[8, 9, 10, 11, 12, 14, 16, 18, 20, 24, 28, 32, 36, 48, 72].map((size) => ( - { - setFontSize(size.toString()) - executeCommand(() => - editor - .chain() - .focus() - .setMark('textStyle', { fontSize: `${size}px` }) - .run() - ) - }} - className="flex items-center" - > - {size}px - - ))} - - -
- - - - {/* 리스트 */} - - - - executeCommand(() => editor.chain().focus().toggleBulletList().run()) - } - disabled={disabled} - > - - - - -

글머리 기호

-
-
- - - - - executeCommand(() => editor.chain().focus().toggleOrderedList().run()) - } - disabled={disabled} - > - - - - -

번호 매기기

-
-
- - - - - executeCommand(() => editor.chain().focus().toggleBlockquote().run()) - } - disabled={disabled} - > - - - - -

인용문

-
-
- - - - {/* 텍스트 정렬 */} - - - - - - {toolbarState.textAlign === 'center' ? ( - - ) : toolbarState.textAlign === 'right' ? ( - - ) : toolbarState.textAlign === 'justify' ? ( - - ) : ( - - )} - - - -

텍스트 정렬

-
-
-
- - - executeCommand(() => editor.chain().focus().setTextAlign('left').run()) - } - className="flex items-center" - > - - 왼쪽 정렬 - - - executeCommand(() => editor.chain().focus().setTextAlign('center').run()) - } - className="flex items-center" - > - - 가운데 정렬 - - - executeCommand(() => editor.chain().focus().setTextAlign('right').run()) - } - className="flex items-center" - > - - 오른쪽 정렬 - - - executeCommand(() => editor.chain().focus().setTextAlign('justify').run()) - } - className="flex items-center" - > - - 양쪽 정렬 - - -
- - - - {/* 링크 */} - - - { - 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} - > - - - - -

링크 {toolbarState.link ? '제거' : '삽입'}

-
-
- - {/* 이미지 업로드 */} - - -
- { - const file = e.target.files?.[0] - if (file) handleImageUpload(file) - }} - /> - { - document.getElementById('image-upload-rt')?.click() - }} - disabled={disabled} - > - - -
-
- -

이미지 삽입

-
-
- - - - {/* 첨자 */} - - - - executeCommand(() => editor.chain().focus().toggleSubscript().run()) - } - disabled={disabled} - > - - - - -

아래 첨자

-
-
- - - - - executeCommand(() => editor.chain().focus().toggleSuperscript().run()) - } - disabled={disabled} - > - - - - -

위 첨자

-
-
- - - - {/* 하이라이트 */} - - - - executeCommand(() => editor.chain().focus().toggleHighlight().run()) - } - disabled={disabled} - > - - - - -

하이라이트

-
-
- - {/* 체크리스트 */} - - - - executeCommand(() => editor.chain().focus().toggleTaskList().run()) - } - disabled={disabled} - > - - - - -

체크리스트

-
-
- - - - {/* 테이블 */} - {!toolbarState.table ? ( - - - - - { - if (editor && editor.isActive('table')) { - alert('커서를 테이블 밖으로 이동시키세요') - return - } - setIsTableDialogOpen(true) - }} - disabled={disabled} - > - - - - -

테이블 삽입

-
-
-
- - - 테이블 크기 설정 - - 생성할 테이블의 행과 열 수를 입력하세요 (1-20) - - -
-
- - setTableRows(e.target.value)} - placeholder="3" - /> -
-
- - setTableCols(e.target.value)} - placeholder="3" - /> -
-
- - - - -
-
- ) : ( - - - - - - - - - -

테이블 편집

-
-
-
- - - executeCommand(() => editor.chain().focus().addRowBefore().run()) - } - className="flex items-center" - > - 위에 행 추가 - - - executeCommand(() => editor.chain().focus().addRowAfter().run()) - } - className="flex items-center" - > - 아래에 행 추가 - - - executeCommand(() => editor.chain().focus().addColumnBefore().run()) - } - className="flex items-center" - > - 왼쪽에 열 추가 - - - executeCommand(() => editor.chain().focus().addColumnAfter().run()) - } - className="flex items-center" - > - 오른쪽에 열 추가 - - - executeCommand(() => editor.chain().focus().deleteRow().run()) - } - className="flex items-center" - > - 행 삭제 - - - executeCommand(() => editor.chain().focus().deleteColumn().run()) - } - className="flex items-center" - > - 열 삭제 - - - executeCommand(() => editor.chain().focus().deleteTable().run()) - } - className="flex items-center text-red-600" - > - 테이블 삭제 - - -
- )} - - - - {/* 실행 취소/다시 실행 */} - - - - executeCommand(() => editor.chain().focus().undo().run()) - } - disabled={!editor.can().undo() || disabled} - > - - - - -

실행 취소 (Ctrl+Z)

-
-
- - - - - executeCommand(() => editor.chain().focus().redo().run()) - } - disabled={!editor.can().redo() || disabled} - > - - - - -

다시 실행 (Ctrl+Y)

-
-
-
-
-
- ) - } - - // --------------------------------------------------------------------------- - // Layout & rendering - // --------------------------------------------------------------------------- - const containerStyle = height === '100%' ? { height: '100%' } : { height } - const editorContentStyle = - height === '100%' ? { flex: 1, minHeight: 0 } : { height: `calc(${height} - 60px)` } + const containerStyle = { height } return ( -
-
- +
+
+
-
+
) -} \ No newline at end of file +} + + diff --git a/components/rich-text-editor/StyleMenu.tsx b/components/rich-text-editor/StyleMenu.tsx new file mode 100644 index 00000000..a919e639 --- /dev/null +++ b/components/rich-text-editor/StyleMenu.tsx @@ -0,0 +1,65 @@ +'use client' + +import React from 'react' +import type { Editor } from '@tiptap/react' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' + +interface StyleMenuProps { + editor: Editor | null + disabled?: boolean + executeCommand: (command: () => void) => void +} + +export function StyleMenu({ editor, disabled, executeCommand }: StyleMenuProps) { + if (!editor) return null + return ( + + e.preventDefault()}> + + + + + executeCommand(() => + editor + .chain() + .focus() + .setMark('textStyle', { fontSize: '32px' }) + .setBold() + .run() + ) + } + > + 제목 (굵게 + 32pt) + + + executeCommand(() => + editor + .chain() + .focus() + .unsetBold() + .setParagraph() + .setMark('textStyle', { fontSize: null as unknown as string }) + .run() + ) + } + > + 본문 (기본) + + + + ) +} + + diff --git a/components/rich-text-editor/TextAlignMenu.tsx b/components/rich-text-editor/TextAlignMenu.tsx new file mode 100644 index 00000000..98cc0d4c --- /dev/null +++ b/components/rich-text-editor/TextAlignMenu.tsx @@ -0,0 +1,46 @@ +'use client' + +import React from 'react' +import type { Editor } from '@tiptap/react' +import { Button } from '@/components/ui/button' +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' +import { AlignCenter, AlignJustify, AlignLeft, AlignRight } from 'lucide-react' + +interface TextAlignMenuProps { + editor: Editor + disabled?: boolean + currentAlign?: 'left' | 'center' | 'right' | 'justify' + executeCommand: (command: () => void) => void +} + +export function TextAlignMenu({ editor, disabled, executeCommand }: TextAlignMenuProps) { + return ( + + e.preventDefault()}> + + + + executeCommand(() => editor.chain().focus().setTextAlign('left').run())} className="flex items-center"> + + 왼쪽 정렬 + + executeCommand(() => editor.chain().focus().setTextAlign('center').run())} className="flex items-center"> + + 가운데 정렬 + + executeCommand(() => editor.chain().focus().setTextAlign('right').run())} className="flex items-center"> + + 오른쪽 정렬 + + executeCommand(() => editor.chain().focus().setTextAlign('justify').run())} className="flex items-center"> + + 양쪽 정렬 + + + + ) +} + + diff --git a/components/rich-text-editor/Toolbar.tsx b/components/rich-text-editor/Toolbar.tsx new file mode 100644 index 00000000..13e31c24 --- /dev/null +++ b/components/rich-text-editor/Toolbar.tsx @@ -0,0 +1,350 @@ +'use client' + +import React, { useCallback, useEffect, useId, useReducer, useState } from 'react' +import type { Editor } from '@tiptap/react' + +import { Image as ImageIcon, Type } from 'lucide-react' + +import { Toggle } from '@/components/ui/toggle' +import { Separator } from '@/components/ui/separator' +import { Input } from '@/components/ui/input' +import { Button } from '@/components/ui/button' +import { Label } from '@/components/ui/label' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip' +import { TextAlignMenu } from './TextAlignMenu' +import { StyleMenu } from './StyleMenu' +import { InlineStyleMenu } from './InlineStyleMenu' +import { BulletListButton } from './BulletListButton' +import { OrderedListButton } from './OrderedListButton' +import { BlockquoteButton } from './BlockquoteButton' +import { HistoryMenu } from './HistoryMenu' + +interface ToolbarProps { + editor: Editor | null + disabled?: boolean + onSelectImageFile?: (file: File) => void +} + +export function Toolbar({ editor, disabled, onSelectImageFile }: ToolbarProps) { + const [fontSize, setFontSize] = useState('16') + const [imageWidthPct, setImageWidthPct] = useState('100') + + const imageInputId = useId() + + const getToolbarState = useCallback(() => { + if (!editor) + return { + bold: false, + italic: false, + underline: false, + strike: false, + bulletList: false, + orderedList: false, + blockquote: false, + highlight: false, + taskList: false, + table: false, + subscript: false, + superscript: false, + heading: false, + textAlign: 'left' as 'left' | 'center' | 'right' | 'justify', + } + + const textAlign = editor.isActive({ textAlign: 'center' }) + ? 'center' + : editor.isActive({ textAlign: 'right' }) + ? 'right' + : editor.isActive({ textAlign: 'justify' }) + ? 'justify' + : 'left' + + return { + 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'), + highlight: editor.isActive('highlight'), + taskList: editor.isActive('taskList'), + table: editor.isActive('table'), + subscript: editor.isActive('subscript'), + superscript: editor.isActive('superscript'), + heading: [1, 2, 3, 4, 5, 6].some(l => editor.isActive('heading', { level: l })), + textAlign: textAlign as 'left' | 'center' | 'right' | 'justify', + } + }, [editor]) + + const toolbarState = getToolbarState() + + useEffect(() => { + if (!editor) return + const updateFontSize = () => { + const currentFontSizeAttr = editor.getAttributes('textStyle').fontSize + if (typeof currentFontSizeAttr === 'string') { + const sizeValue = currentFontSizeAttr.replace('px', '') + setFontSize(sizeValue) + } else { + try { + const from = editor.state.selection.from + const dom = editor.view.domAtPos(from).node as HTMLElement + const el = dom.nodeType === 3 ? (dom.parentElement as HTMLElement) : (dom as HTMLElement) + const computed = window.getComputedStyle(el) + const val = computed.fontSize.replace('px', '') + setFontSize(val || '16') + } catch { + setFontSize('16') + } + } + } + updateFontSize() + editor.on('selectionUpdate', updateFontSize) + editor.on('transaction', updateFontSize) + return () => { + editor.off('selectionUpdate', updateFontSize) + editor.off('transaction', updateFontSize) + } + }, [editor]) + + const [, forceRender] = useReducer((x: number) => x + 1, 0) + const executeCommand = useCallback( + (command: () => void) => { + if (!editor || disabled) return + if (!editor.isFocused) editor.commands.focus() + command() + forceRender() + setTimeout(() => { + if (editor && !editor.isFocused) editor.commands.focus() + forceRender() + }, 0) + }, + [editor, disabled] + ) + + useEffect(() => { + if (!editor) return + const updateImageWidth = () => { + if (editor.isActive('image')) { + const width = editor.getAttributes('image').width as string | undefined + if (typeof width === 'string') { + const pct = width.endsWith('%') ? width.replace('%', '') : width.replace('px', '') + setImageWidthPct(pct) + } else { + setImageWidthPct('100') + } + } + } + updateImageWidth() + editor.on('selectionUpdate', updateImageWidth) + editor.on('transaction', updateImageWidth) + return () => { + editor.off('selectionUpdate', updateImageWidth) + editor.off('transaction', updateImageWidth) + } + }, [editor]) + + if (!editor) return null + + return ( + +
+
+ + + + + + + + + + + + + + {([1, 2, 3] as Array<1 | 2 | 3>).map(level => ( + executeCommand(() => editor.chain().focus().toggleHeading({ level }).run())} + className="flex items-center" + > + + 제목 {level} + + + ))} + executeCommand(() => editor.chain().focus().setParagraph().run())} className="flex items-center"> + 본문 + + + + +
+ { + const size = e.target.value + setFontSize(size) + if (size && parseInt(size) >= 8 && parseInt(size) <= 72) { + executeCommand(() => editor.chain().focus().setMark('textStyle', { fontSize: `${size}px` }).run()) + } + }} + style={{ width: `64px` }} + className="h-8 text-xs text-right" + disabled={disabled} + /> +
+ + +
+ + + + + + {[8, 9, 10, 11, 12, 14, 16, 18, 20, 24, 28, 32, 36, 48, 72].map(size => ( + { + setFontSize(size.toString()) + executeCommand(() => editor.chain().focus().setMark('textStyle', { fontSize: `${size}px` }).run()) + }} + className="flex items-center" + > + {size}px + + ))} + + +
+ + + +
+ + + +
+ + + + + + + + + +
+ { + const file = e.target.files?.[0] + if (file) onSelectImageFile?.(file) + }} + /> + { + const el = document.getElementById(`image-upload-rt-${imageInputId}`) as HTMLInputElement | null + el?.click() + }} + disabled={disabled} + aria-label="이미지 삽입" + > + + +
+
+ +

이미지 삽입

+
+
+ + {editor.isActive('image') && ( +
+ + { + const val = e.target.value + setImageWidthPct(val) + const pct = Math.min(100, Math.max(5, parseInt(val || '0', 10))) + executeCommand(() => editor.chain().focus().updateAttributes('image', { width: `${pct}%` }).run()) + }} + className="h-8 w-16 text-xs" + disabled={disabled} + /> + % +
+ )} + + + + +
+
+
+ ) +} + + diff --git a/components/rich-text-editor/extensions/font-size.ts b/components/rich-text-editor/extensions/font-size.ts new file mode 100644 index 00000000..1b7e2700 --- /dev/null +++ b/components/rich-text-editor/extensions/font-size.ts @@ -0,0 +1,31 @@ +import { Extension } from '@tiptap/core' + +export const FontSize = Extension.create({ + name: 'fontSize', + addGlobalAttributes() { + return [ + { + types: ['textStyle'], + attributes: { + fontSize: { + default: null, + parseHTML: element => { + const sizeWithUnit = (element as HTMLElement).style.fontSize + return sizeWithUnit || null + }, + renderHTML: attributes => { + if (!attributes.fontSize) return {} + const value = String(attributes.fontSize) + const withUnit = /(px|em|rem|%)$/i.test(value) ? value : `${value}px` + return { + style: `font-size: ${withUnit}`, + } + }, + }, + }, + }, + ] + }, +}) + + -- cgit v1.2.3