diff options
| author | joonhoekim <26rote@gmail.com> | 2025-08-11 09:34:40 +0000 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-08-11 09:34:40 +0000 |
| commit | bcd462d6e60871b86008e072f4b914138fc5c328 (patch) | |
| tree | c22876fd6c6e7e48254587848b9dff50cdb8b032 /components/rich-text-editor | |
| parent | cbb4c7fe0b94459162ad5e998bc05cd293e0ff96 (diff) | |
(김준회) 리치텍스트에디터 (결재템플릿을 위한 공통컴포넌트), command-menu 에러 수정, 결재 템플릿 관리, 결재선 관리, ECC RFQ+PR Item 수신시 비즈니스테이블(ProcurementRFQ) 데이터 적재, WSDL 오류 수정
Diffstat (limited to 'components/rich-text-editor')
| -rw-r--r-- | components/rich-text-editor/BlockquoteButton.tsx | 38 | ||||
| -rw-r--r-- | components/rich-text-editor/BulletListButton.tsx | 46 | ||||
| -rw-r--r-- | components/rich-text-editor/HistoryMenu.tsx | 43 | ||||
| -rw-r--r-- | components/rich-text-editor/InlineStyleMenu.tsx | 67 | ||||
| -rw-r--r-- | components/rich-text-editor/OrderedListButton.tsx | 38 | ||||
| -rw-r--r-- | components/rich-text-editor/RichTextEditor.tsx | 1050 | ||||
| -rw-r--r-- | components/rich-text-editor/StyleMenu.tsx | 65 | ||||
| -rw-r--r-- | components/rich-text-editor/TextAlignMenu.tsx | 46 | ||||
| -rw-r--r-- | components/rich-text-editor/Toolbar.tsx | 350 | ||||
| -rw-r--r-- | components/rich-text-editor/extensions/font-size.ts | 31 |
10 files changed, 863 insertions, 911 deletions
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 ( + <Tooltip> + <TooltipTrigger asChild> + <Toggle + size="sm" + pressed={isActive} + onMouseDown={e => e.preventDefault()} + onPressedChange={() => executeCommand(() => editor.chain().focus().toggleBlockquote().run())} + disabled={disabled} + > + <QuoteIcon className="h-4 w-4" /> + </Toggle> + </TooltipTrigger> + <TooltipContent> + <p>인용문</p> + </TooltipContent> + </Tooltip> + ) +} + + 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 ( + <Tooltip> + <TooltipTrigger asChild> + <Toggle + size="sm" + pressed={isActive} + onMouseDown={e => e.preventDefault()} + onPressedChange={handleToggleBulletList} + disabled={disabled} + > + <ListIcon className="h-4 w-4" /> + </Toggle> + </TooltipTrigger> + <TooltipContent> + <p>글머리 기호</p> + </TooltipContent> + </Tooltip> + ) +} + + 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 ( + <> + <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> + </> + ) +} + + 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 ( + <> + <Tooltip> + <TooltipTrigger asChild> + <Toggle size="sm" pressed={isBold} onPressedChange={() => executeCommand(() => editor.chain().focus().toggleBold().run())} disabled={disabled}> + <Bold className="h-4 w-4" /> + </Toggle> + </TooltipTrigger> + <TooltipContent> + <p>굵게 (Ctrl+B)</p> + </TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger asChild> + <Toggle size="sm" pressed={isItalic} onPressedChange={() => executeCommand(() => editor.chain().focus().toggleItalic().run())} disabled={disabled}> + <Italic className="h-4 w-4" /> + </Toggle> + </TooltipTrigger> + <TooltipContent> + <p>기울임 (Ctrl+I)</p> + </TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger asChild> + <Toggle size="sm" pressed={isUnderline} onPressedChange={() => executeCommand(() => editor.chain().focus().toggleUnderline().run())} disabled={disabled}> + <UnderlineIcon className="h-4 w-4" /> + </Toggle> + </TooltipTrigger> + <TooltipContent> + <p>밑줄</p> + </TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger asChild> + <Toggle size="sm" pressed={isStrike} onPressedChange={() => executeCommand(() => editor.chain().focus().toggleStrike().run())} disabled={disabled}> + <Strikethrough className="h-4 w-4" /> + </Toggle> + </TooltipTrigger> + <TooltipContent> + <p>취소선</p> + </TooltipContent> + </Tooltip> + </> + ) +} + + 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 ( + <Tooltip> + <TooltipTrigger asChild> + <Toggle + size="sm" + pressed={isActive} + onMouseDown={e => e.preventDefault()} + onPressedChange={() => executeCommand(() => editor.chain().focus().toggleOrderedList().run())} + disabled={disabled} + > + <ListOrderedIcon className="h-4 w-4" /> + </Toggle> + </TooltipTrigger> + <TooltipContent> + <p>번호 매기기</p> + </TooltipContent> + </Tooltip> + ) +} + + 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<number | undefined>(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<string> => + 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 ( - <TooltipProvider> - <div className="border border-input bg-transparent rounded-t-md"> - <div className="flex flex-wrap gap-1 p-1"> - {/* 텍스트 스타일 */} - <Tooltip> - <TooltipTrigger asChild> - <Toggle - size="sm" - pressed={toolbarState.bold} - onPressedChange={() => - executeCommand(() => editor.chain().focus().toggleBold().run()) - } - disabled={disabled} - > - <Bold className="h-4 w-4" /> - </Toggle> - </TooltipTrigger> - <TooltipContent> - <p>굵게 (Ctrl+B)</p> - </TooltipContent> - </Tooltip> - - <Tooltip> - <TooltipTrigger asChild> - <Toggle - size="sm" - pressed={toolbarState.italic} - onPressedChange={() => - executeCommand(() => editor.chain().focus().toggleItalic().run()) - } - disabled={disabled} - > - <Italic className="h-4 w-4" /> - </Toggle> - </TooltipTrigger> - <TooltipContent> - <p>기울임 (Ctrl+I)</p> - </TooltipContent> - </Tooltip> - - <Tooltip> - <TooltipTrigger asChild> - <Toggle - size="sm" - pressed={toolbarState.underline} - onPressedChange={() => - executeCommand(() => editor.chain().focus().toggleUnderline().run()) - } - disabled={disabled} - > - <UnderlineIcon className="h-4 w-4" /> - </Toggle> - </TooltipTrigger> - <TooltipContent> - <p>밑줄</p> - </TooltipContent> - </Tooltip> - - <Tooltip> - <TooltipTrigger asChild> - <Toggle - size="sm" - pressed={toolbarState.strike} - onPressedChange={() => - executeCommand(() => editor.chain().focus().toggleStrike().run()) - } - disabled={disabled} - > - <Strikethrough className="h-4 w-4" /> - </Toggle> - </TooltipTrigger> - <TooltipContent> - <p>취소선</p> - </TooltipContent> - </Tooltip> - - <Separator orientation="vertical" className="h-6" /> - - {/* 제목 및 단락 */} - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Toggle size="sm" pressed={toolbarState.heading} disabled={disabled}> - <Type className="h-4 w-4" /> - </Toggle> - </DropdownMenuTrigger> - <DropdownMenuContent align="start"> - {([1, 2, 3] as Array<1 | 2 | 3>).map((level) => ( - <DropdownMenuItem - key={level} - onClick={() => - executeCommand(() => - editor.chain().focus().toggleHeading({ level }).run() - ) - } - className="flex items-center" - > - <span - className={`font-bold ${level === 1 ? 'text-xl' : level === 2 ? 'text-lg' : 'text-base' - }`} - > - 제목 {level} - </span> - </DropdownMenuItem> - ))} - <DropdownMenuItem - onClick={() => - executeCommand(() => editor.chain().focus().setParagraph().run()) - } - className="flex items-center" - > - <span>본문</span> - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> - - {/* 글자 크기 - 동적 width 적용 */} - <div className="flex items-center space-x-1"> - <Input - type="number" - min="8" - max="72" - value={fontSize} - onChange={(e) => { - 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} - /> - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button variant="outline" size="sm" className="h-8 px-2" disabled={disabled}> - <Type className="h-3 w-3" /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="start"> - {[8, 9, 10, 11, 12, 14, 16, 18, 20, 24, 28, 32, 36, 48, 72].map((size) => ( - <DropdownMenuItem - key={size} - onClick={() => { - setFontSize(size.toString()) - executeCommand(() => - editor - .chain() - .focus() - .setMark('textStyle', { fontSize: `${size}px` }) - .run() - ) - }} - className="flex items-center" - > - <span style={{ fontSize: `${Math.min(size, 16)}px` }}>{size}px</span> - </DropdownMenuItem> - ))} - </DropdownMenuContent> - </DropdownMenu> - </div> - - <Separator orientation="vertical" className="h-6" /> - - {/* 리스트 */} - <Tooltip> - <TooltipTrigger asChild> - <Toggle - size="sm" - pressed={toolbarState.bulletList} - onPressedChange={() => - executeCommand(() => editor.chain().focus().toggleBulletList().run()) - } - disabled={disabled} - > - <List className="h-4 w-4" /> - </Toggle> - </TooltipTrigger> - <TooltipContent> - <p>글머리 기호</p> - </TooltipContent> - </Tooltip> - - <Tooltip> - <TooltipTrigger asChild> - <Toggle - size="sm" - pressed={toolbarState.orderedList} - onPressedChange={() => - executeCommand(() => editor.chain().focus().toggleOrderedList().run()) - } - disabled={disabled} - > - <ListOrdered className="h-4 w-4" /> - </Toggle> - </TooltipTrigger> - <TooltipContent> - <p>번호 매기기</p> - </TooltipContent> - </Tooltip> - - <Tooltip> - <TooltipTrigger asChild> - <Toggle - size="sm" - pressed={toolbarState.blockquote} - onPressedChange={() => - executeCommand(() => editor.chain().focus().toggleBlockquote().run()) - } - disabled={disabled} - > - <Quote className="h-4 w-4" /> - </Toggle> - </TooltipTrigger> - <TooltipContent> - <p>인용문</p> - </TooltipContent> - </Tooltip> - - <Separator orientation="vertical" className="h-6" /> - - {/* 텍스트 정렬 */} - <DropdownMenu> - <DropdownMenuTrigger asChild> - <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> - </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} - > - <LinkIcon 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-rt" - onChange={(e) => { - const file = e.target.files?.[0] - if (file) handleImageUpload(file) - }} - /> - <Toggle - size="sm" - pressed={false} - onPressedChange={() => { - document.getElementById('image-upload-rt')?.click() - }} - disabled={disabled} - > - <ImageIcon className="h-4 w-4" /> - </Toggle> - </div> - </TooltipTrigger> - <TooltipContent> - <p>이미지 삽입</p> - </TooltipContent> - </Tooltip> - - <Separator orientation="vertical" className="h-6" /> - - {/* 첨자 */} - <Tooltip> - <TooltipTrigger asChild> - <Toggle - size="sm" - pressed={toolbarState.subscript} - onPressedChange={() => - executeCommand(() => editor.chain().focus().toggleSubscript().run()) - } - disabled={disabled} - > - <SubscriptIcon className="h-4 w-4" /> - </Toggle> - </TooltipTrigger> - <TooltipContent> - <p>아래 첨자</p> - </TooltipContent> - </Tooltip> - - <Tooltip> - <TooltipTrigger asChild> - <Toggle - size="sm" - pressed={toolbarState.superscript} - onPressedChange={() => - executeCommand(() => editor.chain().focus().toggleSuperscript().run()) - } - disabled={disabled} - > - <SuperscriptIcon className="h-4 w-4" /> - </Toggle> - </TooltipTrigger> - <TooltipContent> - <p>위 첨자</p> - </TooltipContent> - </Tooltip> - - <Separator orientation="vertical" className="h-6" /> - - {/* 하이라이트 */} - <Tooltip> - <TooltipTrigger asChild> - <Toggle - size="sm" - pressed={toolbarState.highlight} - onPressedChange={() => - executeCommand(() => editor.chain().focus().toggleHighlight().run()) - } - disabled={disabled} - > - <Highlighter className="h-4 w-4" /> - </Toggle> - </TooltipTrigger> - <TooltipContent> - <p>하이라이트</p> - </TooltipContent> - </Tooltip> - - {/* 체크리스트 */} - <Tooltip> - <TooltipTrigger asChild> - <Toggle - size="sm" - pressed={toolbarState.taskList} - onPressedChange={() => - executeCommand(() => editor.chain().focus().toggleTaskList().run()) - } - disabled={disabled} - > - <CheckSquare className="h-4 w-4" /> - </Toggle> - </TooltipTrigger> - <TooltipContent> - <p>체크리스트</p> - </TooltipContent> - </Tooltip> - - <Separator orientation="vertical" className="h-6" /> - - {/* 테이블 */} - {!toolbarState.table ? ( - <Dialog open={isTableDialogOpen} onOpenChange={setIsTableDialogOpen}> - <DialogTrigger asChild> - <Tooltip> - <TooltipTrigger asChild> - <Toggle - size="sm" - pressed={false} - onPressedChange={() => { - if (editor && editor.isActive('table')) { - alert('커서를 테이블 밖으로 이동시키세요') - return - } - setIsTableDialogOpen(true) - }} - disabled={disabled} - > - <TableIcon className="h-4 w-4" /> - </Toggle> - </TooltipTrigger> - <TooltipContent> - <p>테이블 삽입</p> - </TooltipContent> - </Tooltip> - </DialogTrigger> - <DialogContent className="sm:max-w-md"> - <DialogHeader> - <DialogTitle>테이블 크기 설정</DialogTitle> - <DialogDescription> - 생성할 테이블의 행과 열 수를 입력하세요 (1-20) - </DialogDescription> - </DialogHeader> - <div className="grid grid-cols-2 gap-4 py-4"> - <div className="space-y-2"> - <Label htmlFor="table-rows">행 수</Label> - <Input - id="table-rows" - type="number" - min="1" - max="20" - value={tableRows} - onChange={(e) => setTableRows(e.target.value)} - placeholder="3" - /> - </div> - <div className="space-y-2"> - <Label htmlFor="table-cols">열 수</Label> - <Input - id="table-cols" - type="number" - min="1" - max="20" - value={tableCols} - onChange={(e) => setTableCols(e.target.value)} - placeholder="3" - /> - </div> - </div> - <DialogFooter> - <Button variant="outline" onClick={() => setIsTableDialogOpen(false)}> - 취소 - </Button> - <Button - onClick={() => { - const rows = parseInt(tableRows, 10) - const cols = parseInt(tableCols, 10) - if (rows >= 1 && rows <= 20 && cols >= 1 && cols <= 20) { - executeCommand(() => - editor.chain().focus().insertTable({ rows, cols }).run() - ) - setIsTableDialogOpen(false) - } - }} - disabled={ - !tableRows || - !tableCols || - parseInt(tableRows, 10) < 1 || - parseInt(tableRows, 10) > 20 || - parseInt(tableCols, 10) < 1 || - parseInt(tableCols, 10) > 20 - } - > - 생성 - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - ) : ( - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Tooltip> - <TooltipTrigger asChild> - <Toggle size="sm" pressed={true} disabled={disabled}> - <TableIcon className="h-4 w-4" /> - </Toggle> - </TooltipTrigger> - <TooltipContent> - <p>테이블 편집</p> - </TooltipContent> - </Tooltip> - </DropdownMenuTrigger> - <DropdownMenuContent align="start"> - <DropdownMenuItem - onClick={() => - executeCommand(() => editor.chain().focus().addRowBefore().run()) - } - className="flex items-center" - > - <span>위에 행 추가</span> - </DropdownMenuItem> - <DropdownMenuItem - onClick={() => - executeCommand(() => editor.chain().focus().addRowAfter().run()) - } - className="flex items-center" - > - <span>아래에 행 추가</span> - </DropdownMenuItem> - <DropdownMenuItem - onClick={() => - executeCommand(() => editor.chain().focus().addColumnBefore().run()) - } - className="flex items-center" - > - <span>왼쪽에 열 추가</span> - </DropdownMenuItem> - <DropdownMenuItem - onClick={() => - executeCommand(() => editor.chain().focus().addColumnAfter().run()) - } - className="flex items-center" - > - <span>오른쪽에 열 추가</span> - </DropdownMenuItem> - <DropdownMenuItem - onClick={() => - executeCommand(() => editor.chain().focus().deleteRow().run()) - } - className="flex items-center" - > - <span>행 삭제</span> - </DropdownMenuItem> - <DropdownMenuItem - onClick={() => - executeCommand(() => editor.chain().focus().deleteColumn().run()) - } - className="flex items-center" - > - <span>열 삭제</span> - </DropdownMenuItem> - <DropdownMenuItem - onClick={() => - executeCommand(() => editor.chain().focus().deleteTable().run()) - } - className="flex items-center text-red-600" - > - <span>테이블 삭제</span> - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> - )} - - <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> - ) - } - - // --------------------------------------------------------------------------- - // 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 ( - <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} /> + <div className={`border rounded-md bg-background flex flex-col ${className ?? ''}`} style={containerStyle}> + <div className="flex-none border-b"> + <Toolbar editor={editor} disabled={disabled} onSelectImageFile={handleImageUpload} /> </div> - <div className="overflow-y-auto" style={editorContentStyle}> + <div className="flex-1 min-h-0 overflow-y-auto"> <EditorContent editor={editor} className="h-full" /> </div> </div> ) -}
\ 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 ( + <DropdownMenu> + <DropdownMenuTrigger asChild onMouseDown={e => e.preventDefault()}> + <Button variant="outline" size="sm" className="h-8 px-2" disabled={disabled}> + 스타일 + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="start"> + <DropdownMenuItem + className="flex items-center" + onSelect={() => + executeCommand(() => + editor + .chain() + .focus() + .setMark('textStyle', { fontSize: '32px' }) + .setBold() + .run() + ) + } + > + 제목 (굵게 + 32pt) + </DropdownMenuItem> + <DropdownMenuItem + className="flex items-center" + onSelect={() => + executeCommand(() => + editor + .chain() + .focus() + .unsetBold() + .setParagraph() + .setMark('textStyle', { fontSize: null as unknown as string }) + .run() + ) + } + > + 본문 (기본) + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ) +} + + 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 ( + <DropdownMenu> + <DropdownMenuTrigger asChild onMouseDown={e => e.preventDefault()}> + <Button variant="outline" size="sm" className="h-8 px-2" disabled={disabled}> + 정렬 + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="start"> + <DropdownMenuItem onSelect={() => executeCommand(() => editor.chain().focus().setTextAlign('left').run())} className="flex items-center"> + <AlignLeft className="mr-2 h-4 w-4" /> + <span>왼쪽 정렬</span> + </DropdownMenuItem> + <DropdownMenuItem onSelect={() => executeCommand(() => editor.chain().focus().setTextAlign('center').run())} className="flex items-center"> + <AlignCenter className="mr-2 h-4 w-4" /> + <span>가운데 정렬</span> + </DropdownMenuItem> + <DropdownMenuItem onSelect={() => executeCommand(() => editor.chain().focus().setTextAlign('right').run())} className="flex items-center"> + <AlignRight className="mr-2 h-4 w-4" /> + <span>오른쪽 정렬</span> + </DropdownMenuItem> + <DropdownMenuItem onSelect={() => executeCommand(() => editor.chain().focus().setTextAlign('justify').run())} className="flex items-center"> + <AlignJustify className="mr-2 h-4 w-4" /> + <span>양쪽 정렬</span> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ) +} + + 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<string>('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 ( + <TooltipProvider> + <div className="border border-input bg-transparent rounded-t-md"> + <div className="flex flex-wrap gap-1 p-1"> + <StyleMenu editor={editor} disabled={disabled} executeCommand={executeCommand} /> + + <InlineStyleMenu + editor={editor} + disabled={disabled} + isBold={toolbarState.bold} + isItalic={toolbarState.italic} + isUnderline={toolbarState.underline} + isStrike={toolbarState.strike} + executeCommand={executeCommand} + /> + + <Separator orientation="vertical" className="h-6" /> + + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Toggle size="sm" pressed={toolbarState.heading} disabled={disabled}> + <Type className="h-4 w-4" /> + </Toggle> + </DropdownMenuTrigger> + <DropdownMenuContent align="start"> + {([1, 2, 3] as Array<1 | 2 | 3>).map(level => ( + <DropdownMenuItem + key={level} + onSelect={() => executeCommand(() => editor.chain().focus().toggleHeading({ level }).run())} + className="flex items-center" + > + <span className={`font-bold ${level === 1 ? 'text-xl' : level === 2 ? 'text-lg' : 'text-base'}`}> + 제목 {level} + </span> + </DropdownMenuItem> + ))} + <DropdownMenuItem onSelect={() => executeCommand(() => editor.chain().focus().setParagraph().run())} className="flex items-center"> + <span>본문</span> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + + <div className="flex items-center gap-1"> + <Input + type="number" + min="8" + max="72" + value={fontSize} + onChange={e => { + 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} + /> + <div className="flex items-center gap-1"> + <Button + type="button" + size="sm" + variant="outline" + className="h-8 w-8 p-0" + onMouseDown={e => e.preventDefault()} + onClick={() => { + const next = Math.max(8, Math.min(72, parseInt(fontSize || '16', 10) - 1)) + setFontSize(String(next)) + executeCommand(() => editor.chain().focus().setMark('textStyle', { fontSize: `${next}px` }).run()) + }} + disabled={disabled} + > + - + </Button> + <Button + type="button" + size="sm" + variant="outline" + className="h-8 w-8 p-0" + onMouseDown={e => e.preventDefault()} + onClick={() => { + const next = Math.max(8, Math.min(72, parseInt(fontSize || '16', 10) + 1)) + setFontSize(String(next)) + executeCommand(() => editor.chain().focus().setMark('textStyle', { fontSize: `${next}px` }).run()) + }} + disabled={disabled} + > + + + </Button> + </div> + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="outline" size="sm" className="h-8 px-2" disabled={disabled}> + <Type className="h-3 w-3" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="start"> + {[8, 9, 10, 11, 12, 14, 16, 18, 20, 24, 28, 32, 36, 48, 72].map(size => ( + <DropdownMenuItem + key={size} + onSelect={() => { + setFontSize(size.toString()) + executeCommand(() => editor.chain().focus().setMark('textStyle', { fontSize: `${size}px` }).run()) + }} + className="flex items-center" + > + <span style={{ fontSize: `${Math.min(size, 16)}px` }}>{size}px</span> + </DropdownMenuItem> + ))} + </DropdownMenuContent> + </DropdownMenu> + </div> + + <Separator orientation="vertical" className="h-6" /> + + <div className="flex items-center gap-1"> + <BulletListButton editor={editor} disabled={disabled} isActive={toolbarState.bulletList} executeCommand={executeCommand} /> + <OrderedListButton editor={editor} disabled={disabled} isActive={toolbarState.orderedList} executeCommand={executeCommand} /> + <BlockquoteButton editor={editor} disabled={disabled} isActive={toolbarState.blockquote} executeCommand={executeCommand} /> + </div> + + <Separator orientation="vertical" className="h-6" /> + + <TextAlignMenu editor={editor} disabled={disabled} executeCommand={executeCommand} /> + + <Separator orientation="vertical" className="h-6" /> + + <Tooltip> + <TooltipTrigger asChild> + <div className="relative"> + <input + type="file" + accept="image/*" + className="hidden" + id={`image-upload-rt-${imageInputId}`} + onChange={e => { + const file = e.target.files?.[0] + if (file) onSelectImageFile?.(file) + }} + /> + <Toggle + size="sm" + pressed={false} + onPressedChange={() => { + const el = document.getElementById(`image-upload-rt-${imageInputId}`) as HTMLInputElement | null + el?.click() + }} + disabled={disabled} + aria-label="이미지 삽입" + > + <ImageIcon className="h-4 w-4" /> + </Toggle> + </div> + </TooltipTrigger> + <TooltipContent> + <p>이미지 삽입</p> + </TooltipContent> + </Tooltip> + + {editor.isActive('image') && ( + <div className="flex items-center gap-1 ml-1"> + <Label className="text-xs">너비</Label> + <Input + type="number" + min={5} + max={100} + value={imageWidthPct} + onChange={e => { + 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} + /> + <span className="text-xs">%</span> + </div> + )} + + <Separator orientation="vertical" className="h-6" /> + + <HistoryMenu editor={editor} disabled={disabled} executeCommand={executeCommand} /> + </div> + </div> + </TooltipProvider> + ) +} + + 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}`, + } + }, + }, + }, + }, + ] + }, +}) + + |
