[as] feat: 웹한글 기안기 데모 페이지 생성

This commit is contained in:
2026-09-07 15:24:06 +09:00
parent 94af73a810
commit dbea497cc5
17 changed files with 749 additions and 0 deletions
@@ -0,0 +1,3 @@
import { HangulGiangiPreviewPage } from "@/pages/hangulgiangi";
export default HangulGiangiPreviewPage;
+10
View File
@@ -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: [],
};
Binary file not shown.
Binary file not shown.
Binary file not shown.
+41
View File
@@ -0,0 +1,41 @@
<!--
~ Copyright 2026 Hancom Inc. All rights reserved.
~
~ https://www.hancom.com/
-->
<!doctype html>
<html lang="en-us">
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<title>Hwp SDK Wasm Sample</title>
<script type="text/javascript" data-type="css/app" data-framework="aori|lab" src="js/loadVersion.js"></script>
<script type="text/javascript" data-type="css/app" data-framework="aori|lab" src="js/loadHead.js"></script>
<script data-main="js/main" data-bundle="dist/main_bundle" src="js/loadMain.js"></script>
</head>
<body>
<div id="status" style="display:none;">
<div class="sppiner-content">
<div class="spinner" id='spinner'></div>
<div class="emscripten" id="statustext"></div>
</div>
</div>
<div id="status_downloading">
<div class="status-content">
<div class="emscriptenDownloading">
<span id="statusDownloadingText">구성 중 ... 0% </span>
<span id="statusDownloadingPercent" class="emscriptenDownloadingPercent"></span>
</div>
<div class="emscriptenDownloadingInfo"></div>
<progress id="download_progress" value="0" max="100"></progress>
</div>
</div>
<iframe id="download_iframe" style="width:0px;height:0px;border:0;display:none;"></iframe>
<script type="text/javascript" src="hwpsdk/loadHwpsdk.js"></script>
</body>
</html>
+65
View File
@@ -0,0 +1,65 @@
<!DOCTYPE html>
<html lang="ko" style="height: 100%">
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>웹한글 기안기 브리지</title>
<script
type="text/javascript"
src="http://hwp.onasys.co.kr/webhwpctrl/js/hwpctrlapp/utils/util.js"
></script>
<script
type="text/javascript"
src="http://hwp.onasys.co.kr/webhwpctrl/js/hwpctrlapp/hwpCtrlApp.js"
></script>
<script
type="text/javascript"
src="http://hwp.onasys.co.kr/webhwpctrl/js/webhwpctrl.js"
></script>
<style>
html,
body,
#hwpctrl {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
border: 0;
}
</style>
</head>
<body>
<div id="hwpctrl"></div>
<script type="text/javascript">
(function () {
var params = new URLSearchParams(window.location.search);
var baseUrl =
params.get("baseurl") || "http://hwp.onasys.co.kr/webhwpctrl";
if (baseUrl.charAt(baseUrl.length - 1) !== "/") {
baseUrl += "/";
}
var HwpCtrl = BuildWebHwpCtrl(
"hwpctrl",
baseUrl,
function () {
try {
window.parent.postMessage(
{ type: "hangulgiangi-ready" },
"*",
);
} catch (e) {
console.warn(e);
}
},
2,
);
// 부모(HangulGiangi)가 contentWindow.HwpCtrl 로 접근
window.HwpCtrl = HwpCtrl;
})();
</script>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
export { HangulGiangiPreviewPage } from "./ui/hangul-giangi-preview-page";
@@ -0,0 +1,32 @@
"use client";
import { HangulGiangi } from "@/widgets/hangulgiangi";
function HangulGiangiPreviewPage() {
return (
<div className="flex flex-col gap-4 p-6">
<div>
<h1 className="text-xl font-semibold">웹한글 기안기 미리보기</h1>
<p className="mt-1 text-sm text-muted-foreground">
샘플 툴바·BuildWebHwpCtrl 브리지 연동 검증용 페이지입니다. 메뉴에는 등록하지 않습니다.
</p>
<p className="mt-1 text-sm text-muted-foreground">
Demo 파일(
<code className="rounded bg-muted px-1">보고서.hwpx</code>,{" "}
<code className="rounded bg-muted px-1">결재_표.hwpx</code> 등)은{" "}
<code className="rounded bg-muted px-1">public/hangulgiangi/demo/</code>에 배치하세요.
</p>
<p className="mt-1 text-sm text-muted-foreground">
iframe은 로컬{" "}
<code className="rounded bg-muted px-1">/webhwpctrl/index.html</code> 브리지를 로드하고,{" "}
<code className="rounded bg-muted px-1">BuildWebHwpCtrl</code>로 같은 origin에서 HwpCtrl을
노출합니다. 초기화 스크립트는{" "}
<code className="rounded bg-muted px-1">hwp.onasys.co.kr/webhwpctrl</code>에서 가져옵니다.
</p>
</div>
<HangulGiangi />
</div>
);
}
export { HangulGiangiPreviewPage };
+4
View File
@@ -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";
+23
View File
@@ -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";
@@ -0,0 +1,21 @@
/**
* Demo 정적 파일을 부모 origin에서 fetch해 Blob으로 반환한다.
* iframe origin에서 URL을 직접 Open하면 CORS로 실패할 수 있어 Blob Open을 우선한다.
*/
async function fetchDemoBlob(demoBaseUrl: string, fileName: string): Promise<Blob> {
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 };
@@ -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 };
@@ -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";
@@ -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<string, unknown>,
) => 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<string, unknown>,
) => void;
Clear: () => void;
}
export interface HwpCtrlFrameWindow {
HwpCtrl?: HwpCtrl;
}
@@ -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<HTMLDivElement>(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 (
<div className="flex w-full flex-nowrap items-center gap-1 border-b border-border bg-background p-3">
<Button type="button" color="white" size="sm" disabled={isBusy} onClick={onOpen}>
열기
</Button>
<div ref={approvalMenuRef} className="relative">
<Button
type="button"
color="white"
size="sm"
disabled={isBusy}
onClick={(e) => {
e.stopPropagation();
onToggleApprovalMenu();
}}
>
결재 추가
</Button>
<div
className={cn(
"absolute left-0 top-full z-20 mt-0.5 min-w-full rounded-sm border border-border bg-background shadow-md",
isApprovalMenuOpen ? "block" : "hidden",
)}
>
<button
type="button"
color="white"
className="block w-full whitespace-nowrap px-3.5 py-1.5 text-left text-xs hover:bg-muted"
onClick={(e) => {
e.stopPropagation();
onApproval1();
}}
>
1단
</button>
<button
type="button"
color="white"
className="block w-full whitespace-nowrap px-3.5 py-1.5 text-left text-xs hover:bg-muted"
onClick={(e) => {
e.stopPropagation();
onApproval2();
}}
>
2단
</button>
</div>
</div>
<Button type="button" color="white" size="sm" disabled={isBusy} onClick={onSign}>
결재
</Button>
<Button type="button" color="white" size="sm" disabled={isBusy} onClick={onSave}>
HWP저장
</Button>
<Button type="button" color="white" size="sm" disabled={isBusy} onClick={onClear}>
문서 클리어
</Button>
<Button type="button" color="white" size="sm" disabled={isBusy} onClick={onPickLocalFile}>
로컬 파일 열기
</Button>
</div>
);
}
export { HangulGiangiToolbar };
export type { HangulGiangiToolbarProps };
@@ -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<HTMLIFrameElement>(null);
const fileInputRef = useRef<HTMLInputElement>(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<string | null>(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<HTMLInputElement>) => {
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 (
<div
className={cn(
"flex w-full flex-col overflow-hidden rounded-md border border-border",
className,
)}
>
<HangulGiangiToolbar
isApprovalMenuOpen={isApprovalMenuOpen}
isBusy={isToolbarBusy}
onOpen={handleOpen}
onToggleApprovalMenu={() => 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 ? (
<p className="bg-muted/40 border-b border-border px-3 py-1.5 text-xs text-muted-foreground">
{statusMessage}
</p>
) : !isCtrlReady ? (
<p className="bg-muted/40 border-b border-border px-3 py-1.5 text-xs text-muted-foreground">
기안기 초기화 중…
</p>
) : null}
<iframe
ref={iframeRef}
id={HWP_CTRL_IFRAME_NAME}
name={HWP_CTRL_IFRAME_NAME}
title="웹한글 기안기"
src={iframeSrc}
style={{ height: "700px", width: "calc(100vw-250px)" }}
className="border-0"
/>
<input
ref={fileInputRef}
type="file"
accept=".hwp,.hwpx,.hwt,.hwtx,.doc,.docx,.odt,.txt,.html"
className="invisible h-px w-px"
onChange={handleLocalFileChange}
/>
</div>
);
}
export { HangulGiangi };
export type { HangulGiangiProps };