/* ============================================================================= * 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"; const CAD_HOST_SAVE_MESSAGE = "aislo:b08:host-save"; /** 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[]; layers?: Record[]; } /** CAD 저장 응답 (도면 + 수량표 — 수량표는 B07 횡단도만). */ export interface CadSaveResult { 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; /** CAD 안 💾 · Ctrl+S — `hostSave` 로 실은 화면(M02 양식)만 온다. */ onHostSave?: () => void; } /** 싣기 곁값 — 수량 패널(meta) 없이 싣는 M02 양식이 쓴다. */ export interface CadLoadExtra { /** 자동백업 칸 이름 — 없으면 meta.drawingId. 칸이 겹치면 백업을 서로 덮는다. */ recoveryScope?: string; /** 보기 전용 — 확정본처럼 그리기·수정이 막힌다. */ readOnly?: boolean; /** CAD 제목표시줄 부제 — 없으면 「B07 상세 설계」. */ hostTitle?: string; /** 저장(💾 · Ctrl+S)을 부모 [저장]에 맡긴다 — 켜면 `onHostSave` 로 옴. */ hostSave?: boolean; /** 리본을 접힌 채로 연다 — 안 보내면 펼침 그대로. */ ribbonCollapsed?: boolean; } export interface CadHost { /** 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, extra?: CadLoadExtra, ) => void; /** CAD 의 지금 편집본을 받는다. */ requestSave: () => Promise>; /** 메시지 듣기를 멈춘다 — 페이지를 떠날 때. */ destroy: () => void; } export function createCadHost( options: CadHostOptions, ): CadHost { 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; extra: CadLoadExtra; } | undefined; let resolveSave: ((payload: CadSaveResult) => 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): void => { frame.contentWindow?.postMessage(message, window.location.origin); }; const load: CadHost["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> => 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): 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_HOST_SAVE_MESSAGE) { options.onHostSave?.(); } 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); }, }; }