diff options
Diffstat (limited to 'components/rich-text-editor/Toolbar.tsx')
| -rw-r--r-- | components/rich-text-editor/Toolbar.tsx | 350 |
1 files changed, 350 insertions, 0 deletions
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> + ) +} + + |
