summaryrefslogtreecommitdiff
path: root/lib/gtc-contract/gtc-clauses/table/clause-preview-viewer.tsx
diff options
context:
space:
mode:
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.tsx435
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)
+}