Files
Aislo/ui_template/cad_host/cad_host.ts
T
eomsangdonandClaude Opus 5.5 bc73b28823 refactor(M02): 웹캐드 iframe 부모 흐름 · 도각 편집을 ui_template/cad_host/ 로 뗌 · B07 이 그것을 씀 (PLAN 10-3)
- cad_host.ts — iframe 띄우기 · ready 대기 · 시간초과 · 도면 싣기 · 저장 요청 · 토스트 중계 · 콜백(편집 · 넘기기 · 내보내기)
- cad_host_frame_edit.ts — B07_UI_FrameEdit 옮김 · 도각 읽고 쓰는 길을 api 로 받음
- cad_host.css — B07 CSS 의 CAD 칸 · 도각 편집 띠 규칙 옮김(값 그대로 · 클래스 이름만 cad-host · cad-frame-edit)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PxvYb5ufV1kWdBvZbpDfu6
2026-09-25 09:34:54 +09:00

237 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;
}
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>,
) => 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> }
| 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 = {}) => {
pendingLoad = { drawing, meta, frameEdit, frameFields };
if (!cadReady) return;
post({ type: CAD_LOAD_MESSAGE, drawing, meta, frameEdit, frameFields });
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) {
load(pendingLoad.drawing, pendingLoad.meta, pendingLoad.frameEdit, pendingLoad.frameFields);
// 기다리던 것이 「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);
},
};
}