[as] feat: 웹한글 기안기 데모 페이지 생성
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
import { HangulGiangiPreviewPage } from "@/pages/hangulgiangi";
|
||||
|
||||
export default HangulGiangiPreviewPage;
|
||||
@@ -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.
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 };
|
||||
@@ -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";
|
||||
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user