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