"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> onSuccess?: () => void onError?: () => void } export function ClausePreviewViewer({ clauses, document, instance, setInstance, onSuccess, onError, }: ClausePreviewViewerProps) { const [fileLoading, setFileLoading] = useState(true) const [loadingStage, setLoadingStage] = useState("뷰어 준비 중...") 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 initializeViewerStepByStep() } return () => { if (instance) { try { instance.UI.dispose() } catch (error) { console.warn("뷰어 정리 중 오류:", error) } } isCancelled.current = true; setTimeout(() => cleanupHtmlStyle(), 500); } }, []) const initializeViewerStepByStep = async () => { try { setLoadingStage("라이브러리 로딩 중...") // 1단계: 라이브러리 동적 import (지연 추가) await new Promise(resolve => setTimeout(resolve, 300)) const { default: WebViewer } = await import("@pdftron/webviewer") if (isCancelled.current || !viewer.current) { console.log("📛 WebViewer 초기화 취소됨") return } setLoadingStage("뷰어 초기화 중...") // 2단계: WebViewer 인스턴스 생성 const webviewerInstance = await WebViewer( { path: "/pdftronWeb", licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY, fullAPI: true, enableOfficeEditing: true, l: "ko", enableReadOnlyMode: false, }, viewer.current ) if (isCancelled.current) { console.log("📛 WebViewer 초기화 취소됨") return } setInstance(webviewerInstance) setLoadingStage("UI 설정 중...") // 3단계: UI 설정 (약간의 지연 후) await new Promise(resolve => setTimeout(resolve, 500)) await configureViewerUI(webviewerInstance) setLoadingStage("문서 생성 중...") // 4단계: 문서 생성 (충분한 지연 후) await new Promise(resolve => setTimeout(resolve, 800)) await generateDocumentFromClauses(webviewerInstance, clauses, document) } catch (error) { console.error("❌ WebViewer 단계별 초기화 실패:", error) setFileLoading(false) onError?.() // 초기화 실패 콜백 호출 toast.error(`뷰어 초기화 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`) } } const configureViewerUI = async (webviewerInstance: WebViewerInstance) => { try { const { disableElements, enableElements, setToolbarGroup } = webviewerInstance.UI // 미리보기에 필요한 도구만 활성화 enableElements([ "toolbarGroup-View", "zoomInButton", "zoomOutButton", "fitButton", "rotateCounterClockwiseButton", "rotateClockwiseButton", ]) // 편집 도구는 비활성화 disableElements([ "toolbarGroup-Edit", "toolbarGroup-Insert", "toolbarGroup-Annotate", "toolbarGroup-Shapes", "toolbarGroup-Forms", ]) setToolbarGroup("toolbarGroup-View") console.log("✅ UI 설정 완료") } catch (uiError) { console.warn("⚠️ UI 설정 중 오류:", uiError) // UI 설정 실패해도 계속 진행 } } // 문서 생성 함수 (재시도 로직 포함) const generateDocumentFromClauses = async ( webviewerInstance: WebViewerInstance, clauses: GtcClauseTreeView[], document: any, retryCount = 0 ) => { const MAX_RETRIES = 3 try { console.log("📄 조항 기반 DOCX 문서 생성 시작:", clauses.length) // 활성화된 조항만 필터링하고 정렬 const activeClauses = clauses .filter(clause => clause.isActive !== false) .sort((a, b) => { if (a.sortOrder && b.sortOrder) { return parseFloat(a.sortOrder) - parseFloat(b.sortOrder) } return a.itemNumber.localeCompare(b.itemNumber, undefined, { numeric: true }) }) if (activeClauses.length === 0) { throw new Error("활성화된 조항이 없습니다.") } setLoadingStage(`문서 생성 중... (${activeClauses.length}개 조항 처리)`) // DOCX 문서 생성 (재시도 로직 포함) const docxBlob = await generateDocxDocumentWithRetry(activeClauses, document) // 파일 생성 const docxFile = new File([docxBlob], `${document?.title || 'GTC계약서'}_미리보기.docx`, { type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' }) setLoadingStage("문서 로딩 중...") // WebViewer가 완전히 준비된 상태인지 확인 await waitForViewerReady(webviewerInstance) // DOCX 문서 로드 (재시도 포함) await loadDocumentWithRetry(webviewerInstance, docxFile) console.log("✅ DOCX 기반 문서 생성 완료") toast.success("Word 문서 미리보기가 생성되었습니다.") setFileLoading(false) onSuccess?.() // 성공 콜백 호출 } catch (err) { console.error(`❌ DOCX 문서 생성 중 오류 (시도 ${retryCount + 1}/${MAX_RETRIES + 1}):`, err) if (retryCount < MAX_RETRIES) { console.log(`🔄 ${(retryCount + 1) * 1000}ms 후 재시도...`) setLoadingStage(`재시도 중... (${retryCount + 1}/${MAX_RETRIES})`) await new Promise(resolve => setTimeout(resolve, (retryCount + 1) * 1000)) if (!isCancelled.current) { return generateDocumentFromClauses(webviewerInstance, clauses, document, retryCount + 1) } } else { setFileLoading(false) onError?.() // 실패 콜백 호출 toast.error(`문서 생성 실패: ${err instanceof Error ? err.message : '알 수 없는 오류'}`) } } } // WebViewer 준비 상태 확인 const waitForViewerReady = async (webviewerInstance: WebViewerInstance, timeout = 5000) => { const startTime = Date.now() while (Date.now() - startTime < timeout) { try { // UI가 준비되었는지 확인 if (webviewerInstance.UI && webviewerInstance.Core) { console.log("✅ WebViewer 준비 완료") return } } catch (error) { // 아직 준비되지 않음 } await new Promise(resolve => setTimeout(resolve, 100)) } throw new Error("WebViewer 준비 시간 초과") } // 문서 로드 재시도 함수 const loadDocumentWithRetry = async ( webviewerInstance: WebViewerInstance, file: File, retryCount = 0 ) => { const MAX_LOAD_RETRIES = 2 try { await webviewerInstance.UI.loadDocument(file, { filename: file.name, enableOfficeEditing: true, }) console.log("✅ 문서 로드 성공") } catch (error) { console.error(`문서 로드 실패 (시도 ${retryCount + 1}):`, error) if (retryCount < MAX_LOAD_RETRIES) { await new Promise(resolve => setTimeout(resolve, 1000)) return loadDocumentWithRetry(webviewerInstance, file, retryCount + 1) } else { throw new Error(`문서 로드 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`) } } } // DOCX 생성 재시도 함수 const generateDocxDocumentWithRetry = async ( clauses: GtcClauseTreeView[], document: any, retryCount = 0 ): Promise => { try { return await generateDocxDocument(clauses, document) } catch (error) { console.error(`DOCX 생성 실패 (시도 ${retryCount + 1}):`, error) if (retryCount < 2) { await new Promise(resolve => setTimeout(resolve, 500)) return generateDocxDocumentWithRetry(clauses, document, retryCount + 1) } else { throw error } } } return (
{fileLoading && (

{loadingStage}

{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 }; } // 이미지 불러오기 + 크기 계산 (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) { try { 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); } catch (imgError) { console.warn("이미지 로드 실패:", imgMeta, imgError); // 이미지 로드 실패시 텍스트로 대체 children.push( new Paragraph({ children: [new TextRun({ text: `[이미지 로드 실패: ${imgMeta.fileName || imageId}]`, color: "999999" })], indent: { left: indentLeft }, }) ); } } // 매칭 실패 시: 아무것도 넣지 않음(토큰 제거) 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); } // 본문에 등장하지 않은 잔여 이미지(선택: 뒤에 추가) 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) } // WebViewer 정리 함수 const cleanupHtmlStyle = () => { // iframe 스타일 정리 (WebViewer가 추가한 스타일) const elements = document.querySelectorAll('.Document_container'); elements.forEach((elem) => { elem.remove(); }); };