- M02_MasterTemplete_Drawing.ts · .css — (칸, 문서, {onSave, readOnly, name}) → {getDoc(Promise), destroy}
- cad_host load 곁값 recoveryScope · readOnly
- CAD 앱 브리지 — 싣기 메시지의 recoveryScope(없으면 meta.drawingId) · readOnly(확정본과 같은 막힘)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PxvYb5ufV1kWdBvZbpDfu6
259 lines
10 KiB
TypeScript
259 lines
10 KiB
TypeScript
/* =============================================================================
|
|
* ui_template/cad_host/cad_host.ts
|
|
* 웹캐드(openwebcad) iframe 부모 쪽 한 벌 — B07 상세 설계 · M02 도면 양식이 함께 쓴다.
|
|
*
|
|
* iframe 띄우기 · ready 대기 · 시간초과 · 도면 싣기 · 저장 요청 · 토스트 중계를 맡는다.
|
|
* 페이지마다 다른 일(도면 넘기기 · 내보내기 · 미저장 표시)은 콜백으로 받는다.
|
|
* CAD 앱 쪽 짝은 `B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts`.
|
|
* ========================================================================== */
|
|
|
|
import "./cad_host.css";
|
|
import { showToast } from "@ui/ui_template_elements";
|
|
|
|
/** CAD 앱 정적 경로 (main.py 마운트, dev는 vite proxy 위임) */
|
|
const CAD_APP_URL = "/b07-cad/index.html";
|
|
|
|
const CAD_LOAD_MESSAGE = "aislo:b08:load-drawing";
|
|
const CAD_READY_MESSAGE = "aislo:b08:drawing-ready";
|
|
const CAD_LOADED_MESSAGE = "aislo:b08:drawing-loaded";
|
|
const CAD_ERROR_MESSAGE = "aislo:b08:drawing-error";
|
|
const CAD_CHANGED_MESSAGE = "aislo:b08:drawing-changed";
|
|
const CAD_SAVE_REQUEST_MESSAGE = "aislo:b08:save-request";
|
|
const CAD_SAVE_RESPONSE_MESSAGE = "aislo:b08:save-response";
|
|
const CAD_NAVIGATE_MESSAGE = "aislo:b08:navigate";
|
|
const CAD_EXPORT_MESSAGE = "aislo:b08:export-file";
|
|
/** CAD 앱 알림 — 프로젝트 공용 토스트로 띄운다(2026-08-30 사용자 지시).
|
|
* CAD 안 react-toastify는 모양·자리가 달라 한 화면에 두 종류가 섞여 보였다. */
|
|
const CAD_TOAST_MESSAGE = "aislo:b08:toast";
|
|
const CAD_TOAST_ACTION_MESSAGE = "aislo:b08:toast-action";
|
|
|
|
/** CAD 가 주고받는 도면 JSON — 도형·층 밖의 칸은 페이지마다 다르다. */
|
|
export interface CadHostDrawing {
|
|
entities: Record<string, unknown>[];
|
|
layers?: Record<string, unknown>[];
|
|
}
|
|
|
|
/** CAD 저장 응답 (도면 + 수량표 — 수량표는 B07 횡단도만). */
|
|
export interface CadSaveResult<D, Q> {
|
|
drawing: D;
|
|
quantityTable: Q | null;
|
|
}
|
|
|
|
export interface CadHostOptions {
|
|
/** iframe 제목(접근성). */
|
|
title: string;
|
|
/** CAD 편집 통지 — dirty 는 미저장 편집이 있는가. */
|
|
onChanged?: (dirty: boolean) => void;
|
|
/** CAD 리본의 이전·다음 도면 단추. */
|
|
onNavigate?: (direction: "prev" | "next") => void;
|
|
/** CAD 리본의 DXF·DWG 내보내기 단추. */
|
|
onExport?: (fileFormat: "dxf" | "dwg") => void;
|
|
}
|
|
|
|
/** 싣기 곁값 — 수량 패널(meta) 없이 싣는 M02 양식이 쓴다. */
|
|
export interface CadLoadExtra {
|
|
/** 자동백업 칸 이름 — 없으면 meta.drawingId. 칸이 겹치면 백업을 서로 덮는다. */
|
|
recoveryScope?: string;
|
|
/** 보기 전용 — 확정본처럼 그리기·수정이 막힌다. */
|
|
readOnly?: boolean;
|
|
}
|
|
|
|
export interface CadHost<D extends CadHostDrawing, M, Q> {
|
|
/** iframe 과 라이선스 글을 담은 칸 — 페이지 메인 칸에 붙인다. */
|
|
element: HTMLElement;
|
|
/** 「불러오는 중」을 켜고 시계를 건다. 끝은 CAD 의 loaded·error 통지가 알린다. */
|
|
beginLoading: () => void;
|
|
/** 불러오기 실패 — 시계를 끊고 실패 표시 · 토스트. */
|
|
fail: (detail: string) => void;
|
|
/** 도면을 싣는다 — CAD 가 아직 안 떴으면 ready 때 보낸다.
|
|
* frameEdit: 도각 편집으로 싣는 도면인가 — 캐드 안 자리표 패널을 이때만 띄운다
|
|
* (2026-09-06 사용자 지시로 패널을 캐드 안으로 옮김). */
|
|
load: (
|
|
drawing: D,
|
|
meta: M | null,
|
|
frameEdit?: boolean,
|
|
frameFields?: Record<string, string>,
|
|
extra?: CadLoadExtra,
|
|
) => void;
|
|
/** CAD 의 지금 편집본을 받는다. */
|
|
requestSave: () => Promise<CadSaveResult<D, Q>>;
|
|
/** 메시지 듣기를 멈춘다 — 페이지를 떠날 때. */
|
|
destroy: () => void;
|
|
}
|
|
|
|
export function createCadHost<D extends CadHostDrawing, M = unknown, Q = unknown>(
|
|
options: CadHostOptions,
|
|
): CadHost<D, M, Q> {
|
|
const element = document.createElement("div");
|
|
element.className = "cad-host";
|
|
const frame = document.createElement("iframe");
|
|
frame.className = "cad-host__frame";
|
|
frame.src = CAD_APP_URL;
|
|
frame.title = options.title;
|
|
const license = document.createElement("a");
|
|
license.className = "cad-host__license";
|
|
license.href = "/b07-cad/THIRD_PARTY_LICENSES.txt";
|
|
license.target = "_blank";
|
|
license.rel = "noreferrer";
|
|
license.textContent = "Drawing engine based on OpenWebCAD · MIT License";
|
|
element.append(frame, license);
|
|
|
|
let cadReady = false;
|
|
let pendingLoad:
|
|
| {
|
|
drawing: D;
|
|
meta: M | null;
|
|
frameEdit: boolean;
|
|
frameFields: Record<string, string>;
|
|
extra: CadLoadExtra;
|
|
}
|
|
| undefined;
|
|
let resolveSave: ((payload: CadSaveResult<D, Q>) => void) | undefined;
|
|
// ⚠ CAD 는 iframe 이라 **저쪽이 아무 말도 안 하면 화면이 영원히 「불러오는 중」에 머문다**
|
|
// (2026-09-09 사용자 보고 — 무한 로딩). 끝을 알리는 것은 `drawing-loaded` ·
|
|
// `drawing-error` 두 통지뿐이고, 그것이 안 오는 길이 둘 있다.
|
|
// ① iframe 이 아예 안 뜸 — `dist/` 가 없거나 스크립트가 죽음 ⇒ `ready` 가 안 옴
|
|
// ② 떴는데 도면을 여는 중에 멈춤 ⇒ `loaded` 도 `error` 도 안 옴
|
|
// 아래 시계가 그 자리를 끊는다. ⚠ **화면 표시만 끊는다** — 뒤늦게 응답이 오면
|
|
// 그대로 받아 정상으로 되돌아간다(요청을 취소하지 않는다).
|
|
const CAD_READY_TIMEOUT_MS = 20000;
|
|
const CAD_LOAD_TIMEOUT_MS = 15000;
|
|
let loadWatchdog: number | undefined;
|
|
|
|
const showFailure = (detail: string): void => {
|
|
element.dataset.loading = "false";
|
|
element.dataset.error = detail;
|
|
showToast(detail, "error");
|
|
};
|
|
|
|
const stopLoadWatchdog = (): void => {
|
|
if (loadWatchdog === undefined) return;
|
|
window.clearTimeout(loadWatchdog);
|
|
loadWatchdog = undefined;
|
|
};
|
|
|
|
const startLoadWatchdog = (): void => {
|
|
stopLoadWatchdog();
|
|
// 아직 `ready` 를 못 받았으면 iframe 이 뜨기를 기다리는 중이라 더 길게 준다.
|
|
const wait = cadReady ? CAD_LOAD_TIMEOUT_MS : CAD_READY_TIMEOUT_MS;
|
|
loadWatchdog = window.setTimeout(() => {
|
|
loadWatchdog = undefined;
|
|
if (element.dataset.loading !== "true") return;
|
|
showFailure(
|
|
cadReady
|
|
? "CAD 가 도면을 여는 데 너무 오래 걸립니다. 다시 눌러 보세요."
|
|
: "CAD 화면이 응답하지 않습니다. 새로고침해도 같으면 CAD 빌드(dist)를 확인하세요.",
|
|
);
|
|
}, wait);
|
|
};
|
|
|
|
// iframe 자체가 못 뜨는 경우 — 이때는 `ready` 가 영영 안 오므로 기다릴 것 없이 끊는다.
|
|
frame.addEventListener("error", () => {
|
|
stopLoadWatchdog();
|
|
showFailure("CAD 화면을 불러오지 못했습니다. CAD 빌드(dist)를 확인하세요.");
|
|
});
|
|
|
|
const post = (message: Record<string, unknown>): void => {
|
|
frame.contentWindow?.postMessage(message, window.location.origin);
|
|
};
|
|
|
|
const load: CadHost<D, M, Q>["load"] = (
|
|
drawing,
|
|
meta,
|
|
frameEdit = false,
|
|
frameFields = {},
|
|
extra = {},
|
|
) => {
|
|
pendingLoad = { drawing, meta, frameEdit, frameFields, extra };
|
|
if (!cadReady) return;
|
|
post({ type: CAD_LOAD_MESSAGE, drawing, meta, frameEdit, frameFields, ...extra });
|
|
pendingLoad = undefined;
|
|
};
|
|
|
|
const requestSave = (): Promise<CadSaveResult<D, Q>> =>
|
|
new Promise((resolve, reject) => {
|
|
resolveSave = resolve;
|
|
post({ type: CAD_SAVE_REQUEST_MESSAGE });
|
|
window.setTimeout(() => {
|
|
if (!resolveSave) return;
|
|
resolveSave = undefined;
|
|
reject(new Error("CAD 저장 응답 시간이 초과되었습니다."));
|
|
}, 5000);
|
|
});
|
|
|
|
const onMessage = (event: MessageEvent<unknown>): void => {
|
|
if (event.origin !== window.location.origin || event.source !== frame.contentWindow) return;
|
|
const message = event.data as {
|
|
type?: string;
|
|
detail?: string;
|
|
drawing?: D;
|
|
quantityTable?: Q | null;
|
|
direction?: "prev" | "next";
|
|
fileFormat?: "dxf" | "dwg";
|
|
dirty?: boolean;
|
|
kind?: string;
|
|
text?: string;
|
|
actionId?: string;
|
|
durationMs?: number;
|
|
};
|
|
if (message.type === CAD_TOAST_MESSAGE) {
|
|
const kind = (["info", "success", "warning", "error"] as const).find(
|
|
(item) => item === message.kind,
|
|
);
|
|
// autoClose:false로 온 안내(백업 되살리기)는 오래 띄운다 — 누를 시간을 준다.
|
|
const duration = message.durationMs === 0 ? 15000 : (message.durationMs ?? 3000);
|
|
const actionId = message.actionId;
|
|
showToast(
|
|
message.text ?? "",
|
|
kind ?? "info",
|
|
duration,
|
|
actionId ? () => post({ type: CAD_TOAST_ACTION_MESSAGE, actionId }) : undefined,
|
|
);
|
|
} else if (message.type === CAD_READY_MESSAGE) {
|
|
cadReady = true;
|
|
if (pendingLoad) {
|
|
const { drawing, meta, frameEdit, frameFields, extra } = pendingLoad;
|
|
load(drawing, meta, frameEdit, frameFields, extra);
|
|
// 기다리던 것이 「iframe 이 뜨기」에서 「도면이 열리기」로 바뀌었다 — 시계를 다시 건다.
|
|
if (element.dataset.loading === "true") startLoadWatchdog();
|
|
}
|
|
} else if (message.type === CAD_LOADED_MESSAGE) {
|
|
stopLoadWatchdog();
|
|
element.dataset.loading = "false";
|
|
} else if (message.type === CAD_ERROR_MESSAGE) {
|
|
stopLoadWatchdog();
|
|
showFailure(message.detail ?? "CAD 도면을 표시하지 못했습니다.");
|
|
} else if (message.type === CAD_CHANGED_MESSAGE) {
|
|
options.onChanged?.(message.dirty !== false);
|
|
} else if (message.type === CAD_NAVIGATE_MESSAGE && message.direction) {
|
|
options.onNavigate?.(message.direction);
|
|
} else if (message.type === CAD_EXPORT_MESSAGE && message.fileFormat) {
|
|
options.onExport?.(message.fileFormat);
|
|
} else if (message.type === CAD_SAVE_RESPONSE_MESSAGE && message.drawing && resolveSave) {
|
|
const resolve = resolveSave;
|
|
resolveSave = undefined;
|
|
resolve({ drawing: message.drawing, quantityTable: message.quantityTable ?? null });
|
|
}
|
|
};
|
|
window.addEventListener("message", onMessage);
|
|
|
|
return {
|
|
element,
|
|
beginLoading: () => {
|
|
element.dataset.loading = "true";
|
|
element.dataset.error = ""; // 앞선 실패 표시를 지운다
|
|
startLoadWatchdog(); // 저쪽이 말이 없으면 여기서 끊는다
|
|
},
|
|
fail: (detail) => {
|
|
stopLoadWatchdog(); // 여기서 이미 끝났다 — 시계를 두면 늦게 또 오류를 띄운다
|
|
showFailure(detail);
|
|
},
|
|
load,
|
|
requestSave,
|
|
destroy: () => {
|
|
stopLoadWatchdog();
|
|
window.removeEventListener("message", onMessage);
|
|
},
|
|
};
|
|
}
|