summaryrefslogtreecommitdiff
path: root/components/rich-text-editor
diff options
context:
space:
mode:
Diffstat (limited to 'components/rich-text-editor')
-rw-r--r--components/rich-text-editor/BlockquoteButton.tsx38
-rw-r--r--components/rich-text-editor/BulletListButton.tsx46
-rw-r--r--components/rich-text-editor/HistoryMenu.tsx43
-rw-r--r--components/rich-text-editor/InlineStyleMenu.tsx67
-rw-r--r--components/rich-text-editor/OrderedListButton.tsx38
-rw-r--r--components/rich-text-editor/RichTextEditor.tsx1050
-rw-r--r--components/rich-text-editor/StyleMenu.tsx65
-rw-r--r--components/rich-text-editor/TextAlignMenu.tsx46
-rw-r--r--components/rich-text-editor/Toolbar.tsx350
-rw-r--r--components/rich-text-editor/extensions/font-size.ts31
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}`,
+ }
+ },
+ },
+ },
+ },
+ ]
+ },
+})
+
+