summaryrefslogtreecommitdiff
path: root/components/rich-text-editor/Toolbar.tsx
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-08-11 09:34:40 +0000
committerjoonhoekim <26rote@gmail.com>2025-08-11 09:34:40 +0000
commitbcd462d6e60871b86008e072f4b914138fc5c328 (patch)
treec22876fd6c6e7e48254587848b9dff50cdb8b032 /components/rich-text-editor/Toolbar.tsx
parentcbb4c7fe0b94459162ad5e998bc05cd293e0ff96 (diff)
(김준회) 리치텍스트에디터 (결재템플릿을 위한 공통컴포넌트), command-menu 에러 수정, 결재 템플릿 관리, 결재선 관리, ECC RFQ+PR Item 수신시 비즈니스테이블(ProcurementRFQ) 데이터 적재, WSDL 오류 수정
Diffstat (limited to 'components/rich-text-editor/Toolbar.tsx')
-rw-r--r--components/rich-text-editor/Toolbar.tsx350
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>
+ )
+}
+
+