"use client" import React, { useState, useEffect, useRef, SetStateAction, Dispatch, } from "react" import { WebViewerInstance } from "@pdftron/webviewer" import { Loader2 } from "lucide-react" import { toast } from "sonner" import { type GtcClauseTreeView } from "@/db/schema/gtc" interface ClausePreviewViewerProps { clauses: GtcClauseTreeView[] document: any instance: WebViewerInstance | null setInstance: Dispatch> } export function ClausePreviewViewer({ clauses, document, instance, setInstance, }: ClausePreviewViewerProps) { const [fileLoading, setFileLoading] = useState(true) const viewer = useRef(null) const initialized = useRef(false) const isCancelled = useRef(false) // WebViewer ์ดˆ๊ธฐํ™” useEffect(() => { if (!initialized.current && viewer.current) { initialized.current = true isCancelled.current = false requestAnimationFrame(() => { if (viewer.current) { import("@pdftron/webviewer").then(({ default: WebViewer }) => { if (isCancelled.current) { console.log("๐Ÿ“› WebViewer ์ดˆ๊ธฐํ™” ์ทจ์†Œ๋จ") return } const viewerElement = viewer.current if (!viewerElement) return WebViewer( { path: "/pdftronWeb", licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY, fullAPI: true, enableOfficeEditing: true, l: "ko", // ๋ฏธ๋ฆฌ๋ณด๊ธฐ ๋ชจ๋“œ๋กœ ์„ค์ • enableReadOnlyMode: false, }, viewerElement ).then(async (instance: WebViewerInstance) => { setInstance(instance) try { const { disableElements, enableElements, setToolbarGroup } = instance.UI // ๋ฏธ๋ฆฌ๋ณด๊ธฐ์— ํ•„์š”ํ•œ ๋„๊ตฌ๋งŒ ํ™œ์„ฑํ™” enableElements([ "toolbarGroup-View", "zoomInButton", "zoomOutButton", "fitButton", "rotateCounterClockwiseButton", "rotateClockwiseButton", ]) // ํŽธ์ง‘ ๋„๊ตฌ๋Š” ๋น„ํ™œ์„ฑํ™” disableElements([ "toolbarGroup-Edit", "toolbarGroup-Insert", "toolbarGroup-Annotate", "toolbarGroup-Shapes", "toolbarGroup-Forms", ]) setToolbarGroup("toolbarGroup-View") // ์กฐํ•ญ ๋ฐ์ดํ„ฐ๋กœ ๋ฌธ์„œ ์ƒ์„ฑ await generateDocumentFromClauses(instance, clauses, document) } catch (uiError) { console.warn("โš ๏ธ UI ์„ค์ • ์ค‘ ์˜ค๋ฅ˜:", uiError) } finally { setFileLoading(false) } }).catch((error) => { console.error("โŒ WebViewer ์ดˆ๊ธฐํ™” ์‹คํŒจ:", error) setFileLoading(false) toast.error("๋ทฐ์–ด ์ดˆ๊ธฐํ™”์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.") }) }) } }) } return () => { if (instance) { instance.UI.dispose() } isCancelled.current = true } }, []) // ์กฐํ•ญ ๋ฐ์ดํ„ฐ๋กœ ์›Œ๋“œ ๋ฌธ์„œ ์ƒ์„ฑ const generateDocumentFromClauses = async ( instance: WebViewerInstance, clauses: GtcClauseTreeView[], document: any ) => { try { console.log("๐Ÿ“„ ์กฐํ•ญ ๊ธฐ๋ฐ˜ DOCX ๋ฌธ์„œ ์ƒ์„ฑ ์‹œ์ž‘:", clauses.length) // ํ™œ์„ฑํ™”๋œ ์กฐํ•ญ๋งŒ ํ•„ํ„ฐ๋งํ•˜๊ณ  ์ •๋ ฌ const activeClauses = clauses .filter(clause => clause.isActive !== false) .sort((a, b) => { // sortOrder ๋˜๋Š” itemNumber๋กœ ์ •๋ ฌ if (a.sortOrder && b.sortOrder) { return parseFloat(a.sortOrder) - parseFloat(b.sortOrder) } return a.itemNumber.localeCompare(b.itemNumber, undefined, { numeric: true }) }) // โœ… DOCX ๋ฌธ์„œ ์ƒ์„ฑ const docxBlob = await generateDocxDocument(activeClauses, document) // โœ… DOCX ํŒŒ์ผ๋กœ ๋ณ€ํ™˜ const docxFile = new File([docxBlob], `${document?.title || 'GTC๊ณ„์•ฝ์„œ'}_๋ฏธ๋ฆฌ๋ณด๊ธฐ.docx`, { type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' }) // โœ… PDFTron์—์„œ DOCX ๋ฌธ์„œ๋กœ ๋กœ๋“œ await instance.UI.loadDocument(docxFile, { filename: `${document?.title || 'GTC๊ณ„์•ฝ์„œ'}_๋ฏธ๋ฆฌ๋ณด๊ธฐ.docx`, enableOfficeEditing: true, // DOCX ํŽธ์ง‘ ๋ชจ๋“œ ํ™œ์„ฑํ™” }) console.log("โœ… DOCX ๊ธฐ๋ฐ˜ ๋ฌธ์„œ ์ƒ์„ฑ ์™„๋ฃŒ") toast.success("Word ๋ฌธ์„œ ๋ฏธ๋ฆฌ๋ณด๊ธฐ๊ฐ€ ์ƒ์„ฑ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.") } catch (err) { console.error("โŒ DOCX ๋ฌธ์„œ ์ƒ์„ฑ ์ค‘ ์˜ค๋ฅ˜:", err) toast.error(`๋ฌธ์„œ ์ƒ์„ฑ ์‹คํŒจ: ${err instanceof Error ? err.message : '์•Œ ์ˆ˜ ์—†๋Š” ์˜ค๋ฅ˜'}`) } } return (
{fileLoading && (

๋ฌธ์„œ ์ƒ์„ฑ ์ค‘...

{clauses.filter(c => c.isActive !== false).length}๊ฐœ ์กฐํ•ญ ์ฒ˜๋ฆฌ ์ค‘

)}
) } // ===== data URL ํŒ๋ณ„ ๋ฐ ๋””์ฝ”๋”ฉ ์œ ํ‹ธ ===== function isDataUrl(url: string) { return /^data:/.test(url); } function dataUrlToUint8Array(dataUrl: string): { bytes: Uint8Array; mime: string } { // ํ˜•์‹: data:;base64, const match = dataUrl.match(/^data:([^;]+);base64,(.*)$/); if (!match) { // base64๊ฐ€ ์•„๋‹Œ data URL๋„ ๊ฐ€๋Šฅํ•˜์ง€๋งŒ, ์—ฌ๊ธฐ์„œ๋Š” base64๋งŒ ์ง€์› throw new Error("์ง€์›ํ•˜์ง€ ์•Š๋Š” data URL ํ˜•์‹์ž…๋‹ˆ๋‹ค."); } const mime = match[1]; const base64 = match[2]; const binary = atob(base64); const len = binary.length; const bytes = new Uint8Array(len); for (let i = 0; i < len; i++) bytes[i] = binary.charCodeAt(i); return { bytes, mime }; } // ===== helper: ์ด๋ฏธ์ง€ ๋ถˆ๋Ÿฌ์˜ค๊ธฐ + ํฌ๊ธฐ ๊ณ„์‚ฐ (data:, http:, / ๊ฒฝ๋กœ ๋ชจ๋‘ ์ง€์›) ===== async function fetchImageData(url: string, maxWidthPx = 500) { let blob: Blob; let bytes: Uint8Array; if (isDataUrl(url)) { // data URL โ†’ Uint8Array, Blob const { bytes: arr, mime } = dataUrlToUint8Array(url); bytes = arr; blob = new Blob([bytes], { type: mime }); } else { // http(s) ๋˜๋Š” ์ƒ๋Œ€ ๊ฒฝ๋กœ const res = await fetch(url, { cache: "no-store" }); if (!res.ok) throw new Error(`์ด๋ฏธ์ง€ ๋‹ค์šด๋กœ๋“œ ์‹คํŒจ (${res.status})`); blob = await res.blob(); const arrayBuffer = await blob.arrayBuffer(); bytes = new Uint8Array(arrayBuffer); } // ์›๋ณธ ํฌ๊ธฐ ํŒŒ์•… (๊ณตํ†ต) const dims = await new Promise<{ width: number; height: number }>((resolve) => { const img = new Image(); const objectUrl = URL.createObjectURL(blob); img.onload = () => { const width = img.naturalWidth || 800; const height = img.naturalHeight || 600; URL.revokeObjectURL(objectUrl); resolve({ width, height }); }; img.onerror = () => { URL.revokeObjectURL(objectUrl); resolve({ width: 800, height: 600 }); // ์‹คํŒจ ์‹œ ๊ธฐ๋ณธ๊ฐ’ }; img.src = objectUrl; }); // ๋น„์œจ ์œ ์ง€ ์ถ•์†Œ const scale = Math.min(1, maxWidthPx / (dims.width || maxWidthPx)); const width = Math.round((dims.width || maxWidthPx) * scale); const height = Math.round((dims.height || Math.round(maxWidthPx * 0.6)) * scale); return { data: bytes, width, height }; } // DOCX ๋ฌธ์„œ ์ƒ์„ฑ (docx ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ์‚ฌ์šฉ) async function generateDocxDocument( clauses: GtcClauseTreeView[], document: any ): Promise { const { Document, Packer, Paragraph, TextRun, AlignmentType, ImageRun } = await import("docx"); function textToParagraphs(text: string, indentLeft: number) { const lines = text.split("\n"); return [ new Paragraph({ children: lines .map((line, i) => [ new TextRun({ text: line }), ...(i < lines.length - 1 ? [new TextRun({ break: 1 })] : []), ]) .flat(), indent: { left: indentLeft }, }), ]; } const IMG_TOKEN = /!\[([^\]]+)\]/g; // ์˜ˆ: ![image1753698566087] async function pushContentWithInlineImages( content: string, indentLeft: number, children: any[], imageMap: Map ) { let lastIndex = 0; for (const match of content.matchAll(IMG_TOKEN)) { const start = match.index ?? 0; const end = start + match[0].length; const imageId = match[1]; // ์•ž๋ถ€๋ถ„ ํ…์ŠคํŠธ if (start > lastIndex) { const txt = content.slice(lastIndex, start); children.push(...textToParagraphs(txt, indentLeft)); } // ์ด๋ฏธ์ง€ ์‚ฝ์ž… const imgMeta = imageMap.get(imageId); if (imgMeta?.url) { const { data, width, height } = await fetchImageData(imgMeta.url, 520); children.push( new Paragraph({ children: [ new ImageRun({ data, transformation: { width, height }, }), ], indent: { left: indentLeft }, }) ); // ์‚ฌ์šฉ๋œ ์ด๋ฏธ์ง€ ํ‘œ์‹œ(๋’ค์—์„œ ์ค‘๋ณต ์ถ”๊ฐ€ ๋ฐฉ์ง€) imageMap.delete(imageId); } // ๋งค์นญ ์‹คํŒจ ์‹œ: ์•„๋ฌด๊ฒƒ๋„ ๋„ฃ์ง€ ์•Š์Œ(ํ† ํฐ ์ œ๊ฑฐ) lastIndex = end; } // ๋‚จ์€ ๊ผฌ๋ฆฌ ํ…์ŠคํŠธ if (lastIndex < content.length) { const tail = content.slice(lastIndex); children.push(...textToParagraphs(tail, indentLeft)); } } const documentTitle = document?.title || "GTC ๊ณ„์•ฝ์„œ"; const currentDate = new Date().toLocaleDateString("ko-KR"); // depth ์ถ”์ •/์ •๋ ฌ const structuredClauses = organizeClausesByHierarchy(clauses); const children: any[] = [ new Paragraph({ alignment: AlignmentType.CENTER, children: [new TextRun({ text: documentTitle, bold: true, size: 32 })], }), new Paragraph({ alignment: AlignmentType.CENTER, children: [new TextRun({ text: `์ƒ์„ฑ์ผ: ${currentDate}`, size: 20, color: "666666" })], }), new Paragraph({ text: "" }), new Paragraph({ text: "" }), ]; for (const clause of structuredClauses) { const depth = Math.min(clause.estimatedDepth || 0, 3); const indentLeft = depth * 400; // ๋ฒˆํ˜ธ/์ œ๋ชฉ const indentContent = indentLeft + 200; // ๋ณธ๋ฌธ/์ด๋ฏธ์ง€ // ๋ฒˆํ˜ธ + ์ œ๋ชฉ children.push( new Paragraph({ children: [ new TextRun({ text: `${clause.itemNumber}${clause.subtitle ? "." : ""}`, bold: true, color: "2563eb" }), ...(clause.subtitle ? [new TextRun({ text: " " }), new TextRun({ text: clause.subtitle, bold: true })] : []), ], indent: { left: indentLeft }, }) ); const imageMap = new Map( Array.isArray((clause as any).images) ? (clause as any).images.map((im: any) => [String(im.id), im]) : [] ); // ๋‚ด์šฉ const hasContent = clause.content && clause.content.trim(); if (hasContent) { await pushContentWithInlineImages(clause.content!, indentContent, children, imageMap); } // else { // children.push( // new Paragraph({ // // children: [new TextRun({ text: "(์ƒ์„ธ ๋‚ด์šฉ ์—†์Œ)", italics: true, color: "6b7280", size: 20 })], // indent: { left: indentContent }, // }) // ); // } // ๋ณธ๋ฌธ์— ๋“ฑ์žฅํ•˜์ง€ ์•Š์€ ์ž”์—ฌ ์ด๋ฏธ์ง€(์„ ํƒ: ๋’ค์— ์ถ”๊ฐ€) for (const [, imgMeta] of imageMap) { try { const { data, width, height } = await fetchImageData(imgMeta.url, 520); children.push( new Paragraph({ children: [new ImageRun({ data, transformation: { width, height } })], indent: { left: indentContent }, }) ); } catch (e) { children.push( new Paragraph({ children: [new TextRun({ text: `์ด๋ฏธ์ง€ ๋กœ๋“œ ์‹คํŒจ: ${imgMeta.fileName || imgMeta.url}`, color: "b91c1c", size: 20 })], indent: { left: indentContent }, }) ); console.warn("์ด๋ฏธ์ง€ ๋กœ๋“œ ์‹คํŒจ(์ž”์—ฌ):", imgMeta, e); } } // ์กฐํ•ญ ๊ฐ„ ๊ฐ„๊ฒฉ children.push(new Paragraph({ text: "" })); } const doc = new Document({ sections: [{ properties: {}, children }], }); return await Packer.toBlob(doc); } // ์กฐํ•ญ๋“ค์„ ๊ณ„์ธต๊ตฌ์กฐ๋กœ ์ •๋ฆฌ function organizeClausesByHierarchy(clauses: GtcClauseTreeView[]) { // depth๊ฐ€ ์—†๋Š” ๊ฒฝ์šฐ itemNumber๋กœ depth ์ถ”์ • return clauses.map(clause => ({ ...clause, estimatedDepth: clause.depth ?? estimateDepthFromItemNumber(clause.itemNumber) })).sort((a, b) => { // itemNumber ๊ธฐ์ค€ ์ž์—ฐ ์ •๋ ฌ return a.itemNumber.localeCompare(b.itemNumber, undefined, { numeric: true, sensitivity: 'base' }) }) } // itemNumber๋กœ๋ถ€ํ„ฐ depth ์ถ”์ • function estimateDepthFromItemNumber(itemNumber: string): number { const parts = itemNumber.split('.') return Math.max(0, parts.length - 1) }