diff options
Diffstat (limited to 'lib/gtc-contract/gtc-clauses/table/clause-preview-viewer.tsx')
| -rw-r--r-- | lib/gtc-contract/gtc-clauses/table/clause-preview-viewer.tsx | 435 |
1 files changed, 435 insertions, 0 deletions
diff --git a/lib/gtc-contract/gtc-clauses/table/clause-preview-viewer.tsx b/lib/gtc-contract/gtc-clauses/table/clause-preview-viewer.tsx new file mode 100644 index 00000000..30e369b4 --- /dev/null +++ b/lib/gtc-contract/gtc-clauses/table/clause-preview-viewer.tsx @@ -0,0 +1,435 @@ +"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<SetStateAction<WebViewerInstance | null>> +} + +export function ClausePreviewViewer({ + clauses, + document, + instance, + setInstance, +}: ClausePreviewViewerProps) { + const [fileLoading, setFileLoading] = useState<boolean>(true) + const viewer = useRef<HTMLDivElement>(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 ( + <div className="relative w-full h-full overflow-hidden"> + <div + ref={viewer} + className="w-full h-full" + style={{ + position: 'relative', + overflow: 'hidden', + contain: 'layout style paint', + }} + > + {fileLoading && ( + <div className="absolute inset-0 flex flex-col items-center justify-center bg-white bg-opacity-90 z-10"> + <Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" /> + <p className="text-sm text-muted-foreground">๋ฌธ์ ์์ฑ ์ค...</p> + <p className="text-xs text-muted-foreground mt-1"> + {clauses.filter(c => c.isActive !== false).length}๊ฐ ์กฐํญ ์ฒ๋ฆฌ ์ค + </p> + </div> + )} + </div> + </div> + ) +} + + + +// ===== data URL ํ๋ณ ๋ฐ ๋์ฝ๋ฉ ์ ํธ ===== +function isDataUrl(url: string) { + return /^data:/.test(url); + } + + function dataUrlToUint8Array(dataUrl: string): { bytes: Uint8Array; mime: string } { + // ํ์: data:<mime>;base64,<payload> + 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<Blob> { + 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<string, any> + ) { + 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) +} |
