diff --git a/apps/as/app/(main)/(with-base-layout)/hangulgiangi/page.tsx b/apps/as/app/(main)/(with-base-layout)/hangulgiangi/page.tsx
new file mode 100644
index 000000000..95c057a58
--- /dev/null
+++ b/apps/as/app/(main)/(with-base-layout)/hangulgiangi/page.tsx
@@ -0,0 +1,3 @@
+import { HangulGiangiPreviewPage } from "@/pages/hangulgiangi";
+
+export default HangulGiangiPreviewPage;
diff --git a/apps/as/next.config.ts b/apps/as/next.config.ts
index 606194380..e000661fb 100644
--- a/apps/as/next.config.ts
+++ b/apps/as/next.config.ts
@@ -2,6 +2,12 @@ import type { NextConfig } from "next";
const subDomain = process.env.NEXT_PUBLIC_SUB_DOMAIN || "http://localhost:13000";
+const webhwpCtrlBase = (
+ process.env.NEXT_PUBLIC_WEBHYP_CTRL_URL || "http://hwp.onasys.co.kr/webhwpctrl"
+).replace(/\/$/, "");
+
+const webhwpAssetPrefixes = ["js", "hwpsdk", "dist", "commonFrame", "wasm"] as const;
+
const nextConfig: NextConfig = {
output: "standalone",
reactStrictMode: true,
@@ -30,6 +36,10 @@ const nextConfig: NextConfig = {
source: "/cms/:path*",
destination: `${subDomain}/cms/:path*`,
},
+ ...webhwpAssetPrefixes.map((prefix) => ({
+ source: `/webhwpctrl/${prefix}/:path*`,
+ destination: `${webhwpCtrlBase}/${prefix}/:path*`,
+ })),
],
fallback: [],
};
diff --git a/apps/as/public/hangulgiangi/demo/결재_표.hwpx b/apps/as/public/hangulgiangi/demo/결재_표.hwpx
new file mode 100644
index 000000000..3ace9b97c
Binary files /dev/null and b/apps/as/public/hangulgiangi/demo/결재_표.hwpx differ
diff --git a/apps/as/public/hangulgiangi/demo/결재_표2.hwpx b/apps/as/public/hangulgiangi/demo/결재_표2.hwpx
new file mode 100644
index 000000000..d5eaf795b
Binary files /dev/null and b/apps/as/public/hangulgiangi/demo/결재_표2.hwpx differ
diff --git a/apps/as/public/hangulgiangi/demo/보고서.hwpx b/apps/as/public/hangulgiangi/demo/보고서.hwpx
new file mode 100644
index 000000000..d6617699e
Binary files /dev/null and b/apps/as/public/hangulgiangi/demo/보고서.hwpx differ
diff --git a/apps/as/public/webhwpctrl/hwpsdkmain.html b/apps/as/public/webhwpctrl/hwpsdkmain.html
new file mode 100644
index 000000000..d24c7eee7
--- /dev/null
+++ b/apps/as/public/webhwpctrl/hwpsdkmain.html
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+ Hwp SDK Wasm Sample
+
+
+
+
+
+
+
+
+
+
+
+ 구성 중 ... 0%
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/as/public/webhwpctrl/index.html b/apps/as/public/webhwpctrl/index.html
new file mode 100644
index 000000000..8dc0fc313
--- /dev/null
+++ b/apps/as/public/webhwpctrl/index.html
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+ 웹한글 기안기 브리지
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/as/src/pages/hangulgiangi/index.ts b/apps/as/src/pages/hangulgiangi/index.ts
new file mode 100644
index 000000000..f66bdbbb3
--- /dev/null
+++ b/apps/as/src/pages/hangulgiangi/index.ts
@@ -0,0 +1 @@
+export { HangulGiangiPreviewPage } from "./ui/hangul-giangi-preview-page";
diff --git a/apps/as/src/pages/hangulgiangi/ui/hangul-giangi-preview-page.tsx b/apps/as/src/pages/hangulgiangi/ui/hangul-giangi-preview-page.tsx
new file mode 100644
index 000000000..0989b99ee
--- /dev/null
+++ b/apps/as/src/pages/hangulgiangi/ui/hangul-giangi-preview-page.tsx
@@ -0,0 +1,32 @@
+"use client";
+
+import { HangulGiangi } from "@/widgets/hangulgiangi";
+
+function HangulGiangiPreviewPage() {
+ return (
+
+
+
웹한글 기안기 미리보기
+
+ 샘플 툴바·BuildWebHwpCtrl 브리지 연동 검증용 페이지입니다. 메뉴에는 등록하지 않습니다.
+
+
+ Demo 파일(
+ 보고서.hwpx,{" "}
+ 결재_표.hwpx 등)은{" "}
+ public/hangulgiangi/demo/에 배치하세요.
+
+
+ iframe은 로컬{" "}
+ /webhwpctrl/index.html 브리지를 로드하고,{" "}
+ BuildWebHwpCtrl로 같은 origin에서 HwpCtrl을
+ 노출합니다. 초기화 스크립트는{" "}
+ hwp.onasys.co.kr/webhwpctrl에서 가져옵니다.
+
+
+
+
+ );
+}
+
+export { HangulGiangiPreviewPage };
diff --git a/apps/as/src/shared/config/env.ts b/apps/as/src/shared/config/env.ts
index f06c95d2b..542d546ad 100644
--- a/apps/as/src/shared/config/env.ts
+++ b/apps/as/src/shared/config/env.ts
@@ -10,3 +10,7 @@ export const REFRESH_TOKEN = process.env.NEXT_PUBLIC_REFRESH_TOKEN || "refresh-t
export const AUTH_USER_ID = process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id";
export const COOKIE_DOMAIN = process.env.NEXT_PUBLIC_COOKIE_DOMAIN;
export const AS_DASHBOARD_BBS_ID = process.env.NEXT_PUBLIC_AS_DASHBOARD_BBS_ID;
+
+/** 웹한글 기안기(WebHWP) 컨트롤 URL */
+export const WEBHYP_CTRL_URL =
+ process.env.NEXT_PUBLIC_WEBHYP_CTRL_URL || "http://hwp.onasys.co.kr/webhwpctrl";
diff --git a/apps/as/src/widgets/hangulgiangi/index.ts b/apps/as/src/widgets/hangulgiangi/index.ts
new file mode 100644
index 000000000..e61f86007
--- /dev/null
+++ b/apps/as/src/widgets/hangulgiangi/index.ts
@@ -0,0 +1,23 @@
+export { getHwpCtrl } from "./lib/get-hwp-ctrl";
+export {
+ APPROVAL_FIELD_NAME,
+ DEFAULT_BRIDGE_URL,
+ DEFAULT_CTRL_URL,
+ DEFAULT_DEMO_BASE_URL,
+ DEFAULT_IFRAME_HEIGHT_CLASS,
+ DEMO_FILES,
+ HANGULGIANGI_READY_MESSAGE_TYPE,
+ HWP_CTRL_IFRAME_NAME,
+ SIGN_FIELD_NAME,
+} from "./lib/hangul-giangi-constants";
+export type {
+ HwpCtrl,
+ HwpCtrlCallback,
+ HwpCtrlCallbackResult,
+ HwpCtrlFrameWindow,
+} from "./model/hwp-ctrl";
+export { HangulGiangi, type HangulGiangiProps } from "./ui/hangul-giangi";
+export {
+ HangulGiangiToolbar,
+ type HangulGiangiToolbarProps,
+} from "./ui/hangul-giangi-toolbar";
diff --git a/apps/as/src/widgets/hangulgiangi/lib/fetch-demo-blob.ts b/apps/as/src/widgets/hangulgiangi/lib/fetch-demo-blob.ts
new file mode 100644
index 000000000..3ccbb16b8
--- /dev/null
+++ b/apps/as/src/widgets/hangulgiangi/lib/fetch-demo-blob.ts
@@ -0,0 +1,21 @@
+/**
+ * Demo 정적 파일을 부모 origin에서 fetch해 Blob으로 반환한다.
+ * iframe origin에서 URL을 직접 Open하면 CORS로 실패할 수 있어 Blob Open을 우선한다.
+ */
+async function fetchDemoBlob(demoBaseUrl: string, fileName: string): Promise {
+ const base = demoBaseUrl.endsWith("/") ? demoBaseUrl : `${demoBaseUrl}/`;
+ const url = `${base}${encodeURIComponent(fileName)}`;
+ const response = await fetch(url);
+
+ if (!response.ok)
+ throw new Error(`Demo 파일을 불러오지 못했습니다: ${fileName} (${response.status})`);
+
+ return response.blob();
+}
+
+function getFormatFromFileName(fileName: string): string {
+ const ext = fileName.split(".").pop()?.toUpperCase();
+ return ext ?? "";
+}
+
+export { fetchDemoBlob, getFormatFromFileName };
diff --git a/apps/as/src/widgets/hangulgiangi/lib/get-hwp-ctrl.ts b/apps/as/src/widgets/hangulgiangi/lib/get-hwp-ctrl.ts
new file mode 100644
index 000000000..58757ee70
--- /dev/null
+++ b/apps/as/src/widgets/hangulgiangi/lib/get-hwp-ctrl.ts
@@ -0,0 +1,29 @@
+import type { HwpCtrl, HwpCtrlFrameWindow } from "../model/hwp-ctrl";
+
+import { HWP_CTRL_IFRAME_NAME } from "./hangul-giangi-constants";
+
+interface WindowWithNamedHwpFrame extends Window {
+ Document_HwpCtrl?: Window & HwpCtrlFrameWindow;
+}
+
+/**
+ * iframe ref 우선, 없으면 named frame 폴백으로 HwpCtrl을 반환한다.
+ * 로컬 `/webhwpctrl` 브리지는 same-origin이라 contentWindow 접근이 가능하다.
+ */
+function getHwpCtrl(iframe: HTMLIFrameElement | null): HwpCtrl | null {
+ if (typeof window === "undefined") return null;
+
+ try {
+ const fromRef = iframe?.contentWindow as HwpCtrlFrameWindow | null | undefined;
+ if (fromRef?.HwpCtrl) return fromRef.HwpCtrl;
+
+ const namedFrame = (window as WindowWithNamedHwpFrame)[HWP_CTRL_IFRAME_NAME];
+ if (namedFrame?.HwpCtrl) return namedFrame.HwpCtrl;
+
+ return null;
+ } catch {
+ return null;
+ }
+}
+
+export { getHwpCtrl };
diff --git a/apps/as/src/widgets/hangulgiangi/lib/hangul-giangi-constants.ts b/apps/as/src/widgets/hangulgiangi/lib/hangul-giangi-constants.ts
new file mode 100644
index 000000000..15a330f81
--- /dev/null
+++ b/apps/as/src/widgets/hangulgiangi/lib/hangul-giangi-constants.ts
@@ -0,0 +1,25 @@
+import { WEBHYP_CTRL_URL } from "@/shared/config";
+
+/** iframe name — 한컴 named-iframe / 브리지 contentWindow 접근용 */
+export const HWP_CTRL_IFRAME_NAME = "Document_HwpCtrl";
+
+/** 로컬 BuildWebHwpCtrl 브리지 (same-origin) */
+export const DEFAULT_BRIDGE_URL = "/webhwpctrl/index.html";
+
+/** 기안기 서버 base URL (브리지 BuildWebHwpCtrl baseurl / rewrite destination) */
+export const DEFAULT_CTRL_URL = WEBHYP_CTRL_URL;
+
+/** Demo 정적 파일 base path (`apps/as/public/hangulgiangi/demo/`) */
+export const DEFAULT_DEMO_BASE_URL = "/hangulgiangi/demo/";
+
+export const DEMO_FILES = {
+ report: "보고서.hwpx",
+ approvalTable1: "결재_표.hwpx",
+ approvalTable2: "결재_표2.hwpx",
+ signImage: "sign.png",
+} as const;
+
+export const APPROVAL_FIELD_NAME = "결재영역";
+export const SIGN_FIELD_NAME = "결재1";
+
+export const HANGULGIANGI_READY_MESSAGE_TYPE = "hangulgiangi-ready";
diff --git a/apps/as/src/widgets/hangulgiangi/model/hwp-ctrl.ts b/apps/as/src/widgets/hangulgiangi/model/hwp-ctrl.ts
new file mode 100644
index 000000000..435e4fa0d
--- /dev/null
+++ b/apps/as/src/widgets/hangulgiangi/model/hwp-ctrl.ts
@@ -0,0 +1,58 @@
+/** 한컴 웹한글 기안기 HwpCtrl 콜백 결과 (문서화되지 않은 필드 허용) */
+export interface HwpCtrlCallbackResult {
+ result?: boolean | string | number;
+ message?: string;
+ [key: string]: unknown;
+}
+
+export type HwpCtrlCallback = (res: HwpCtrlCallbackResult) => void;
+
+/**
+ * 샘플(v3_sample)에서 사용하는 HwpCtrl 메서드만 선언.
+ * 실제 런타임 객체는 iframe contentWindow에 존재한다.
+ */
+export interface HwpCtrl {
+ Open: (
+ source: string | Blob | File,
+ format: string,
+ option: string,
+ callback?: HwpCtrlCallback,
+ extra?: Record,
+ ) => void;
+ Insert: (
+ source: string | Blob | File,
+ format: string,
+ option: string,
+ callback?: HwpCtrlCallback,
+ ) => void;
+ InsertPicture: (
+ source: string | Blob | File,
+ embedded: boolean,
+ sizeOption: number,
+ reverse: boolean,
+ watermark: boolean,
+ effect: number,
+ width: number,
+ height: number,
+ callback?: HwpCtrlCallback,
+ ) => void;
+ MoveToField: (
+ fieldName: string,
+ text: boolean,
+ start: boolean,
+ select: boolean,
+ ) => void;
+ Run: (action: string) => void;
+ SaveAs: (
+ fileName: string,
+ format: string,
+ option: string,
+ callback?: HwpCtrlCallback,
+ extra?: Record,
+ ) => void;
+ Clear: () => void;
+}
+
+export interface HwpCtrlFrameWindow {
+ HwpCtrl?: HwpCtrl;
+}
diff --git a/apps/as/src/widgets/hangulgiangi/ui/hangul-giangi-toolbar.tsx b/apps/as/src/widgets/hangulgiangi/ui/hangul-giangi-toolbar.tsx
new file mode 100644
index 000000000..a28836a02
--- /dev/null
+++ b/apps/as/src/widgets/hangulgiangi/ui/hangul-giangi-toolbar.tsx
@@ -0,0 +1,116 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+
+import { Button, cn } from "@common/components";
+
+interface HangulGiangiToolbarProps {
+ isApprovalMenuOpen: boolean;
+ isBusy?: boolean;
+ onOpen: () => void;
+ onToggleApprovalMenu: () => void;
+ onCloseApprovalMenu: () => void;
+ onApproval1: () => void;
+ onApproval2: () => void;
+ onSign: () => void;
+ onSave: () => void;
+ onClear: () => void;
+ onPickLocalFile: () => void;
+}
+
+function HangulGiangiToolbar({
+ isApprovalMenuOpen,
+ isBusy = false,
+ onOpen,
+ onToggleApprovalMenu,
+ onCloseApprovalMenu,
+ onApproval1,
+ onApproval2,
+ onSign,
+ onSave,
+ onClear,
+ onPickLocalFile,
+}: HangulGiangiToolbarProps) {
+ const approvalMenuRef = useRef(null);
+
+ useEffect(() => {
+ if (!isApprovalMenuOpen) return;
+
+ function handlePointerDown(event: MouseEvent) {
+ const target = event.target as Node;
+ if (approvalMenuRef.current?.contains(target)) return;
+ onCloseApprovalMenu();
+ }
+
+ document.addEventListener("mousedown", handlePointerDown);
+ return () => document.removeEventListener("mousedown", handlePointerDown);
+ }, [isApprovalMenuOpen, onCloseApprovalMenu]);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export { HangulGiangiToolbar };
+export type { HangulGiangiToolbarProps };
diff --git a/apps/as/src/widgets/hangulgiangi/ui/hangul-giangi.tsx b/apps/as/src/widgets/hangulgiangi/ui/hangul-giangi.tsx
new file mode 100644
index 000000000..7f2a7516e
--- /dev/null
+++ b/apps/as/src/widgets/hangulgiangi/ui/hangul-giangi.tsx
@@ -0,0 +1,321 @@
+"use client";
+
+import { type ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
+
+import { WEBHYP_CTRL_URL } from "@/shared/config";
+import { cn } from "@common/components";
+
+import { fetchDemoBlob } from "../lib/fetch-demo-blob";
+import { getHwpCtrl } from "../lib/get-hwp-ctrl";
+import {
+ APPROVAL_FIELD_NAME,
+ DEFAULT_BRIDGE_URL,
+ DEFAULT_DEMO_BASE_URL,
+ DEMO_FILES,
+ HANGULGIANGI_READY_MESSAGE_TYPE,
+ HWP_CTRL_IFRAME_NAME,
+ SIGN_FIELD_NAME,
+} from "../lib/hangul-giangi-constants";
+import type { HwpCtrl } from "../model/hwp-ctrl";
+
+import { HangulGiangiToolbar } from "./hangul-giangi-toolbar";
+
+interface HangulGiangiProps {
+ /** 로컬 BuildWebHwpCtrl 브리지 URL (기본 `/webhwpctrl/index.html`) */
+ bridgeUrl?: string;
+ /** @deprecated bridgeUrl 사용. 하위 호환용 alias */
+ ctrlUrl?: string;
+ /** 기안기 서버 base URL (브리지 `?baseurl=` 로 전달) */
+ ctrlBaseUrl?: string;
+ demoBaseUrl?: string;
+ className?: string;
+ onReady?: () => void;
+}
+
+function resolveHwpCtrl(iframe: HTMLIFrameElement | null, isCtrlReady: boolean): HwpCtrl | null {
+ const ctrl = getHwpCtrl(iframe);
+ if (!ctrl) {
+ alert(
+ isCtrlReady
+ ? "웹한글 기안기 컨트롤을 찾지 못했습니다. 브리지 페이지를 확인하세요."
+ : "웹한글 기안기가 아직 초기화되지 않았습니다. 잠시 후 다시 시도하세요.",
+ );
+ return null;
+ }
+ return ctrl;
+}
+
+function buildBridgeSrc(bridgeUrl: string, ctrlBaseUrl: string): string {
+ const base = ctrlBaseUrl.replace(/\/$/, "");
+ const url = new URL(
+ bridgeUrl,
+ typeof window !== "undefined" ? window.location.origin : "http://localhost",
+ );
+ url.searchParams.set("baseurl", base);
+ // URL()은 absolute path면 pathname만 쓰므로, 상대 경로 브리지는 pathname+search만 반환
+ if (bridgeUrl.startsWith("/")) return `${url.pathname}${url.search}`;
+ return url.toString();
+}
+
+function HangulGiangi({
+ bridgeUrl,
+ ctrlUrl,
+ ctrlBaseUrl = WEBHYP_CTRL_URL,
+ demoBaseUrl = DEFAULT_DEMO_BASE_URL,
+ className,
+ onReady,
+}: HangulGiangiProps) {
+ const resolvedBridgeUrl = bridgeUrl ?? ctrlUrl ?? DEFAULT_BRIDGE_URL;
+ const iframeRef = useRef(null);
+ const fileInputRef = useRef(null);
+
+ const [isCtrlReady, setIsCtrlReady] = useState(false);
+ const [isOpened, setIsOpened] = useState(false);
+ const [isApprovalAdded, setIsApprovalAdded] = useState(false);
+ const [isApprovalMenuOpen, setIsApprovalMenuOpen] = useState(false);
+ const [isBusy, setIsBusy] = useState(false);
+ const [statusMessage, setStatusMessage] = useState(null);
+
+ const iframeSrc = useMemo(
+ () => buildBridgeSrc(resolvedBridgeUrl, ctrlBaseUrl),
+ [resolvedBridgeUrl, ctrlBaseUrl],
+ );
+
+ useEffect(() => {
+ function handleMessage(event: MessageEvent) {
+ if (event.origin !== window.location.origin) return;
+ if (event.data?.type !== HANGULGIANGI_READY_MESSAGE_TYPE) return;
+ if (iframeRef.current && event.source !== iframeRef.current.contentWindow) return;
+
+ setIsCtrlReady(true);
+ setStatusMessage("기안기 초기화가 완료되었습니다.");
+ onReady?.();
+ }
+
+ window.addEventListener("message", handleMessage);
+ return () => window.removeEventListener("message", handleMessage);
+ }, [onReady]);
+
+ // postMessage 누락 대비: HwpCtrl 존재 poll
+ useEffect(() => {
+ if (isCtrlReady) return;
+
+ const startedAt = Date.now();
+ const timer = window.setInterval(() => {
+ if (getHwpCtrl(iframeRef.current)) {
+ setIsCtrlReady(true);
+ setStatusMessage("기안기 초기화가 완료되었습니다.");
+ onReady?.();
+ window.clearInterval(timer);
+ return;
+ }
+ if (Date.now() - startedAt > 60_000) window.clearInterval(timer);
+ }, 500);
+
+ return () => window.clearInterval(timer);
+ }, [isCtrlReady, onReady]);
+
+ const handleOpen = useCallback(async () => {
+ const ctrl = resolveHwpCtrl(iframeRef.current, isCtrlReady);
+ if (!ctrl) return;
+
+ setIsBusy(true);
+ setStatusMessage(null);
+ try {
+ const blob = await fetchDemoBlob(demoBaseUrl, DEMO_FILES.report);
+ ctrl.Open(blob, "HWPX", "lock:false", (res) => {
+ console.log("Open 보고서.hwpx:", res);
+ });
+ setIsOpened(true);
+ setIsApprovalAdded(false);
+ setStatusMessage("문서를 열었습니다.");
+ } catch (error) {
+ console.error(error);
+ alert(
+ error instanceof Error
+ ? error.message
+ : "문서 열기에 실패했습니다. Demo 파일 경로를 확인하세요.",
+ );
+ } finally {
+ setIsBusy(false);
+ }
+ }, [demoBaseUrl, isCtrlReady]);
+
+ const insertApprovalTable = useCallback(
+ async (fileName: string) => {
+ if (!isOpened) {
+ alert("먼저 '열기' 버튼을 눌러주세요.");
+ return;
+ }
+
+ const ctrl = resolveHwpCtrl(iframeRef.current, isCtrlReady);
+ if (!ctrl) return;
+
+ setIsBusy(true);
+ setStatusMessage(null);
+ try {
+ ctrl.MoveToField(APPROVAL_FIELD_NAME, true, true, false);
+ ctrl.Run("SelectAll");
+ ctrl.Run("Delete");
+ ctrl.MoveToField(APPROVAL_FIELD_NAME, true, true, false);
+
+ const blob = await fetchDemoBlob(demoBaseUrl, fileName);
+ ctrl.Insert(blob, "HWPX", "", (res) => {
+ console.log(`Insert ${fileName}:`, res);
+ });
+
+ setIsApprovalAdded(true);
+ setIsApprovalMenuOpen(false);
+ setStatusMessage(`${fileName} 결재 표를 삽입했습니다.`);
+ } catch (error) {
+ console.error(error);
+ alert(
+ error instanceof Error
+ ? error.message
+ : "결재 표 삽입에 실패했습니다. Demo 파일 경로를 확인하세요.",
+ );
+ } finally {
+ setIsBusy(false);
+ }
+ },
+ [demoBaseUrl, isCtrlReady, isOpened],
+ );
+
+ const handleSign = useCallback(async () => {
+ if (!isApprovalAdded) {
+ alert("먼저 '결재 추가' 버튼을 눌러주세요.");
+ return;
+ }
+
+ const ctrl = resolveHwpCtrl(iframeRef.current, isCtrlReady);
+ if (!ctrl) return;
+
+ setIsBusy(true);
+ setStatusMessage(null);
+ try {
+ ctrl.MoveToField(SIGN_FIELD_NAME, true, true, false);
+ const blob = await fetchDemoBlob(demoBaseUrl, DEMO_FILES.signImage);
+ ctrl.InsertPicture(blob, true, 1, false, false, 0, 90, 30, (res) => {
+ console.log("Insert sign.png:", res);
+ });
+ setStatusMessage("결재 이미지를 삽입했습니다.");
+ } catch (error) {
+ console.error(error);
+ alert(
+ error instanceof Error
+ ? error.message
+ : "결재 이미지 삽입에 실패했습니다. Demo 파일 경로를 확인하세요.",
+ );
+ } finally {
+ setIsBusy(false);
+ }
+ }, [demoBaseUrl, isApprovalAdded, isCtrlReady]);
+
+ const handleSave = useCallback(() => {
+ const ctrl = resolveHwpCtrl(iframeRef.current, isCtrlReady);
+ if (!ctrl) return;
+
+ ctrl.SaveAs(
+ "sample.hwp",
+ "HWP",
+ "download:true;",
+ (res) => {
+ console.log(res);
+ setStatusMessage("HWP 저장을 요청했습니다.");
+ },
+ { tempUrl: typeof window !== "undefined" ? window.location.href : "" },
+ );
+ }, [isCtrlReady]);
+
+ const handleClear = useCallback(() => {
+ const ctrl = resolveHwpCtrl(iframeRef.current, isCtrlReady);
+ if (!ctrl) return;
+
+ ctrl.Clear();
+ setIsOpened(false);
+ setIsApprovalAdded(false);
+ setIsApprovalMenuOpen(false);
+ setStatusMessage("문서를 클리어했습니다.");
+ }, [isCtrlReady]);
+
+ const handleLocalFileChange = useCallback(
+ (event: ChangeEvent) => {
+ const file = event.target.files?.[0];
+ if (!file) return;
+
+ const ctrl = resolveHwpCtrl(iframeRef.current, isCtrlReady);
+ if (!ctrl) {
+ event.target.value = "";
+ return;
+ }
+
+ const blobData = new Blob([file], { type: file.type });
+ ctrl.Open(blobData, "", "lock:false", (res) => {
+ ctrl.MoveToField(file.name, true, true, true);
+ console.log(res);
+ });
+
+ setIsOpened(true);
+ setIsApprovalAdded(false);
+ setStatusMessage(`${file.name} 파일을 열었습니다.`);
+ event.target.value = "";
+ },
+ [isCtrlReady],
+ );
+
+ const isToolbarBusy = isBusy || !isCtrlReady;
+
+ return (
+
+
setIsApprovalMenuOpen((prev) => !prev)}
+ onCloseApprovalMenu={() => setIsApprovalMenuOpen(false)}
+ onApproval1={() => void insertApprovalTable(DEMO_FILES.approvalTable1)}
+ onApproval2={() => void insertApprovalTable(DEMO_FILES.approvalTable2)}
+ onSign={() => void handleSign()}
+ onSave={handleSave}
+ onClear={handleClear}
+ onPickLocalFile={() => fileInputRef.current?.click()}
+ />
+
+ {statusMessage ? (
+
+ {statusMessage}
+
+ ) : !isCtrlReady ? (
+
+ 기안기 초기화 중…
+
+ ) : null}
+
+
+
+
+
+ );
+}
+
+export { HangulGiangi };
+export type { HangulGiangiProps };