From bcd462d6e60871b86008e072f4b914138fc5c328 Mon Sep 17 00:00:00 2001
From: joonhoekim <26rote@gmail.com>
Date: Mon, 11 Aug 2025 09:34:40 +0000
Subject: (김준회) 리치텍스트에디터 (결재템플릿을 위한 공통컴포넌트),
 command-menu 에러 수정, 결재 템플릿 관리, 결재선 관리, ECC RFQ+PR Item 수신시
 비즈니스테이블(ProcurementRFQ) 데이터 적재, WSDL 오류 수정
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 components/rich-text-editor/Toolbar.tsx | 350 ++++++++++++++++++++++++++++++++
 1 file changed, 350 insertions(+)
 create mode 100644 components/rich-text-editor/Toolbar.tsx

(limited to 'components/rich-text-editor/Toolbar.tsx')

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>
+  )
+}
+
+
-- 
cgit v1.2.3

