'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} /> %
)}
) }