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
This commit is contained in:
2026-09-25 09:34:54 +09:00
co-authored by Claude Opus 5.5
parent 93e1512387
commit bc73b28823
5 changed files with 401 additions and 321 deletions
+33 -197
View File
@@ -38,14 +38,20 @@ import {
exportDrawing,
fetchDesignDrawing,
fetchDesignDrawingList,
fetchFrameTemplate,
importFrameTemplate,
invalidateDesignDrawing,
resetFrameTemplate,
saveFrameTemplate,
type CadDrawing,
type DesignDrawingItem,
type DesignDrawingResponse,
type QuantityTable,
} from "./B07_DesignDetail_Api_Fetch";
import { appendStructureEntities } from "./B07_DesignDetail_UI_Cad_Structures";
import { createFrameTemplateEditor } from "./B07_DesignDetail_UI_FrameEdit";
// CAD iframe 부모 흐름 · 도각 편집은 M02 와 한 벌로 공용에 둔다(PLAN 10-3).
import { createCadHost, type CadSaveResult } from "@ui/cad_host/cad_host";
import { createFrameTemplateEditor } from "@ui/cad_host/cad_host_frame_edit";
/** CAD 앱 수량 패널로 넘기는 설계 컨텍스트 (openwebcad DesignMeta와 동일 형식). */
interface DesignMeta {
@@ -62,33 +68,13 @@ interface DesignMeta {
}
/** CAD 저장 응답 (도면 + 편집된 수량표). */
interface SaveResult {
drawing: CadDrawing;
quantityTable: QuantityTable | null;
}
/** B07 독립형 CAD 정적 경로 (main.py 마운트, dev는 vite proxy 위임) */
const B07_CAD_APP_URL = "/b07-cad/index.html";
type SaveResult = CadSaveResult<CadDrawing, QuantityTable>;
/** locale 헬퍼 */
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
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";
/* -----------------------------------------------------------------------------
* 화면 조립
* -------------------------------------------------------------------------- */
@@ -114,71 +100,16 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
: "도면 목록을 불러오지 못했습니다.";
}
const cadHost = document.createElement("div");
cadHost.className = "b07-cad-host";
const frame = document.createElement("iframe");
frame.className = "b07-cad-frame";
frame.src = B07_CAD_APP_URL;
frame.title = L("B07_Design_Title");
const license = document.createElement("a");
license.className = "b07-cad-license";
license.href = "/b07-cad/THIRD_PARTY_LICENSES.txt";
license.target = "_blank";
license.rel = "noreferrer";
license.textContent = "Drawing engine based on OpenWebCAD · MIT License";
cadHost.append(frame, license);
let cadReady = false;
let pendingLoad:
| {
drawing: CadDrawing;
meta: DesignMeta | null;
frameEdit: boolean;
frameFields: Record<string, string>;
}
| 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 failCad = (detail: string): void => {
cadHost.dataset.loading = "false";
cadHost.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 (cadHost.dataset.loading !== "true") return;
failCad(
cadReady
? "CAD 가 도면을 여는 데 너무 오래 걸립니다. 다시 눌러 보세요."
: "CAD 화면이 응답하지 않습니다. 새로고침해도 같으면 CAD 빌드(dist)를 확인하세요.",
);
}, wait);
};
// iframe 자체가 못 뜨는 경우 — 이때는 `ready` 가 영영 안 오므로 기다릴 것 없이 끊는다.
frame.addEventListener("error", () => {
stopLoadWatchdog();
failCad("CAD 화면을 불러오지 못했습니다. CAD 빌드(dist)를 확인하세요.");
const cad = createCadHost<CadDrawing, DesignMeta, QuantityTable>({
title: L("B07_Design_Title"),
// 편집 통지는 **미저장 표시**만 세운다. 확정을 푸는 것은 [수정] 하나뿐이다
// (2026-09-01 사용자 확정) — 예전에는 이 통지가 확정을 풀어, 되돌리기나 색
// 고르기 같은 곁가지 동작에도 확정이 조용히 날아갔다.
onChanged: (dirty) => {
if (!frameEditor.isEditing()) cadDirty = dirty;
},
onNavigate: (direction) => navigateDrawing(direction),
onExport: (fileFormat) => void exportDrawingFile(fileFormat),
});
let currentDrawing: DesignDrawingItem | undefined;
@@ -188,7 +119,6 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
const isCross = (item: DesignDrawingItem): boolean => item.kind === "cross";
let allDrawingsConfirmed =
drawings.some(isCross) && drawings.filter(isCross).every((item) => item.confirmed);
let resolveSave: ((payload: SaveResult) => void) | undefined;
let drawingListEl: HTMLElement | undefined;
const infoPanelHost = document.createElement("div");
infoPanelHost.className = "b07-info-host";
@@ -262,23 +192,6 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
hasNext: index < drawings.length - 1,
});
// frameEdit: 도각 편집으로 싣는 도면인가 — 캐드 안 자리표 패널을 이때만 띄운다
// (2026-09-06 사용자 지시로 패널을 캐드 안으로 옮김).
const sendLoad = (
drawing: CadDrawing,
meta: DesignMeta | null,
frameEdit = false,
frameFields: Record<string, string> = {},
) => {
pendingLoad = { drawing, meta, frameEdit, frameFields };
if (!cadReady) return;
frame.contentWindow?.postMessage(
{ type: CAD_LOAD_MESSAGE, drawing, meta, frameEdit, frameFields },
window.location.origin,
);
pendingLoad = undefined;
};
/**
* 도면 캐시 — 진입 직후 **목록의 모든 도면**을 배경에서 받아 둔다(2026-08-30 사용자 지시).
*
@@ -349,9 +262,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
highlightActive(drawing.id);
const button = findButton(drawing.id);
if (button) button.dataset.loading = "true";
cadHost.dataset.loading = "true";
cadHost.dataset.error = ""; // 앞선 실패 표시를 지운다
startLoadWatchdog(); // 저쪽이 말이 없으면 여기서 끊는다
cad.beginLoading(); // 저쪽이 말이 없으면 시계가 끊는다
try {
const response = await requestDrawing(drawing);
currentDrawing = drawing;
@@ -360,10 +271,9 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
cadDirty = false; // 새 도면을 실었다 — 미저장 편집은 이 도면 것이 아니다
applyConfirmButtonState();
updateInfoPanel(drawing, response);
sendLoad(response.drawing, buildMeta(drawing, response, index));
cad.load(response.drawing, buildMeta(drawing, response, index));
} catch (error) {
stopLoadWatchdog(); // 여기서 이미 끝났다 — 시계를 두면 늦게 또 오류를 띄운다
failCad(error instanceof Error ? error.message : "CAD 도면을 불러오지 못했습니다.");
cad.fail(error instanceof Error ? error.message : "CAD 도면을 불러오지 못했습니다.");
if (currentDrawing) highlightActive(currentDrawing.id);
} finally {
loadInFlight = false;
@@ -384,16 +294,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
void loadDrawing(drawings[target], target);
};
const requestCadDrawing = (): Promise<SaveResult> =>
new Promise((resolve, reject) => {
resolveSave = resolve;
frame.contentWindow?.postMessage({ type: CAD_SAVE_REQUEST_MESSAGE }, window.location.origin);
window.setTimeout(() => {
if (!resolveSave) return;
resolveSave = undefined;
reject(new Error("CAD 저장 응답 시간이 초과되었습니다."));
}, 5000);
});
const requestCadDrawing = (): Promise<SaveResult> => cad.requestSave();
async function confirmCurrentDrawing(): Promise<void> {
if (!projectId || !currentDrawing) return;
@@ -501,9 +402,15 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
}
}
const frameEditor = createFrameTemplateEditor({
projectId: projectId as string,
sendLoad,
const frameEditor = createFrameTemplateEditor<CadDrawing>({
api: {
fetch: () => fetchFrameTemplate(projectId as string),
save: (drawing) => saveFrameTemplate(projectId as string, drawing),
importFile: (file) => importFrameTemplate(projectId as string, file),
reset: () => resetFrameTemplate(projectId as string),
},
sendLoad: (drawing, meta, frameEdit, frameFields) =>
cad.load(drawing, meta, frameEdit, frameFields),
requestCadDrawing: async () => (await requestCadDrawing()).drawing,
restoreDrawing: () => {
if (currentDrawing) void loadDrawing(currentDrawing, currentIndex);
@@ -513,77 +420,6 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
currentDrawing ? { label: currentDrawing.label, number: String(currentIndex + 1) } : null,
});
window.addEventListener("message", (event: MessageEvent<unknown>) => {
if (event.origin !== window.location.origin || event.source !== frame.contentWindow) return;
const message = event.data as {
type?: string;
detail?: string;
drawing?: CadDrawing;
quantityTable?: QuantityTable | 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
? () =>
frame.contentWindow?.postMessage(
{ type: CAD_TOAST_ACTION_MESSAGE, actionId },
window.location.origin,
)
: undefined,
);
} else if (message.type === CAD_READY_MESSAGE) {
cadReady = true;
if (pendingLoad) {
sendLoad(
pendingLoad.drawing,
pendingLoad.meta,
pendingLoad.frameEdit,
pendingLoad.frameFields,
);
// 기다리던 것이 「iframe 이 뜨기」에서 「도면이 열리기」로 바뀌었다 — 시계를 다시 건다.
if (cadHost.dataset.loading === "true") startLoadWatchdog();
}
} else if (message.type === CAD_LOADED_MESSAGE) {
stopLoadWatchdog();
cadHost.dataset.loading = "false";
} else if (message.type === CAD_ERROR_MESSAGE) {
stopLoadWatchdog();
failCad(message.detail ?? "CAD 도면을 표시하지 못했습니다.");
} else if (message.type === CAD_CHANGED_MESSAGE) {
// 편집 통지는 **미저장 표시**만 세운다. 확정을 푸는 것은 [수정] 하나뿐이다
// (2026-09-01 사용자 확정) — 예전에는 이 통지가 확정을 풀어, 되돌리기나 색
// 고르기 같은 곁가지 동작에도 확정이 조용히 날아갔다.
if (!frameEditor.isEditing()) cadDirty = message.dirty !== false;
} else if (message.type === CAD_NAVIGATE_MESSAGE && message.direction) {
navigateDrawing(message.direction);
} else if (message.type === CAD_EXPORT_MESSAGE && message.fileFormat) {
void exportDrawingFile(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,
});
}
});
const drawingPanel = buildDrawingSidePanel(drawings, selectDrawing, drawingError, devBypass);
drawingListEl = drawingPanel;
const confirmActions = document.createElement("div");
@@ -604,7 +440,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
steps: workflowSteps(),
activeStep: 4,
leftPanel: drawingPanel,
mainContent: cadHost,
mainContent: cad.element,
stages: workflowState?.stages,
currentStage: workflowState?.current_stage,
routes: WORKFLOW_STEP_ROUTES,
@@ -624,7 +460,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
root.replaceChildren(layout.root);
// 페이지 진입 시 첫 도면(종단도)을 자동 선택 — 빈 CAD 화면 방지.
// CAD가 아직 준비 전이면 sendLoad가 pendingLoad로 대기했다가 ready 시 전송한다.
// CAD가 아직 준비 전이면 cad.load 가 붙들고 있다가 ready 때 보낸다.
if (drawings.length > 0) {
void loadDrawing(drawings[0], 0).then(() => {
// 첫 장을 띄운 뒤에 나머지를 받는다 — 진입 속도를 뺏지 않는다.
@@ -157,76 +157,6 @@
width: 100%;
}
/* CAD 뷰어 호스트 (상세 페이지 영역) */
.b07-cad-host {
position: relative;
width: 100%;
height: 100%;
min-height: 420px;
overflow: hidden;
border-radius: var(--radius-cards);
background-color: var(--color-surface);
}
/* 도면을 받는 동안 띄우는 표시. 이게 없으면 유역도처럼 2~3초 걸리는 도면에서
화면이 멎은 것처럼 보이고, 실패해도 이전 도면이 남아 사용자가 모른다
(2026-09-01 실측 — 코드는 data-loading을 붙이는데 받는 규칙이 없었다). */
.b07-cad-host[data-loading="true"]::after {
content: "도면을 불러오는 중…";
position: absolute;
z-index: 4;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
padding: 10px 18px;
border-radius: var(--radius-cards);
background-color: var(--color-surface);
box-shadow: 0 2px 10px rgb(0 0 0 / 18%);
color: var(--color-text);
font-size: 13px;
}
.b07-cad-host[data-error]:not([data-error=""])::before {
content: attr(data-error);
position: absolute;
z-index: 5;
top: 12px;
left: 50%;
transform: translateX(-50%);
max-width: 70%;
padding: 8px 16px;
border: 1px solid var(--color-danger, #d33);
border-radius: var(--radius-cards);
background-color: var(--color-surface);
color: var(--color-danger, #d33);
font-size: 12px;
}
/* B07 독립형 CAD 앱 임베드 */
.b07-cad-frame {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border: 0;
}
.b07-cad-license {
position: absolute;
z-index: 2;
right: 10px;
bottom: 7px;
color: color-mix(in srgb, var(--color-text-muted) 55%, transparent);
font-size: 9px;
line-height: 1;
text-decoration: none;
}
.b07-cad-license:hover {
color: var(--color-text-muted);
text-decoration: underline;
}
/* 선택 횡단도의 지반정보/계획정보 (잠정치) */
.b07-info-host:empty {
display: none;
@@ -294,40 +224,6 @@
color: var(--color-text-muted);
}
/* 도각 편집 모드 띠 — 도면 목록 하단 액션 칸의 1행. CAD 위에 떠 있던 배치는 리본과
겹쳐 문구가 접히고 버튼이 찌그러졌다(2026-09-02 사용자 지시로 사이드바로 옮김). */
.b07-frame-edit {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
background-color: var(--color-surface);
}
.b07-frame-edit[hidden] {
display: none;
}
.b07-frame-edit__label {
font-size: 0.78rem;
line-height: 1.4;
color: var(--color-text);
}
.b07-frame-edit__buttons {
display: flex;
gap: var(--spacing-8);
}
.b07-frame-edit__buttons > button {
flex: 1 1 0;
min-width: 0;
padding: 4px 8px;
font-size: 0.78rem;
}
/* 확정을 건너뛴 개발 상태 알림 — 눈에 띄되 도면 목록을 밀어내지 않게 한 줄만. */
.b07-drawing-list__bypass {
margin: var(--spacing-4) 0 0;
+104
View File
@@ -0,0 +1,104 @@
/* ui_template/cad_host — 웹캐드 iframe 칸 · 도각 편집 띠 (B07 · M02 공용). */
/* CAD 뷰어 호스트 (상세 페이지 영역) */
.cad-host {
position: relative;
width: 100%;
height: 100%;
min-height: 420px;
overflow: hidden;
border-radius: var(--radius-cards);
background-color: var(--color-surface);
}
/* 도면을 받는 동안 띄우는 표시. 이게 없으면 유역도처럼 2~3초 걸리는 도면에서
화면이 멎은 것처럼 보이고, 실패해도 이전 도면이 남아 사용자가 모른다
(2026-09-01 실측 — 코드는 data-loading을 붙이는데 받는 규칙이 없었다). */
.cad-host[data-loading="true"]::after {
content: "도면을 불러오는 중…";
position: absolute;
z-index: 4;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
padding: 10px 18px;
border-radius: var(--radius-cards);
background-color: var(--color-surface);
box-shadow: 0 2px 10px rgb(0 0 0 / 18%);
color: var(--color-text);
font-size: 13px;
}
.cad-host[data-error]:not([data-error=""])::before {
content: attr(data-error);
position: absolute;
z-index: 5;
top: 12px;
left: 50%;
transform: translateX(-50%);
max-width: 70%;
padding: 8px 16px;
border: 1px solid var(--color-danger, #d33);
border-radius: var(--radius-cards);
background-color: var(--color-surface);
color: var(--color-danger, #d33);
font-size: 12px;
}
/* B07 독립형 CAD 앱 임베드 */
.cad-host__frame {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border: 0;
}
.cad-host__license {
position: absolute;
z-index: 2;
right: 10px;
bottom: 7px;
color: color-mix(in srgb, var(--color-text-muted) 55%, transparent);
font-size: 9px;
line-height: 1;
text-decoration: none;
}
.cad-host__license:hover {
color: var(--color-text-muted);
text-decoration: underline;
}
/* 도각 편집 모드 띠 — 도면 목록 하단 액션 칸의 1행. CAD 위에 떠 있던 배치는 리본과
겹쳐 문구가 접히고 버튼이 찌그러졌다(2026-09-02 사용자 지시로 사이드바로 옮김). */
.cad-frame-edit {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
background-color: var(--color-surface);
}
.cad-frame-edit[hidden] {
display: none;
}
.cad-frame-edit__label {
font-size: 0.78rem;
line-height: 1.4;
color: var(--color-text);
}
.cad-frame-edit__buttons {
display: flex;
gap: var(--spacing-8);
}
.cad-frame-edit__buttons > button {
flex: 1 1 0;
min-width: 0;
padding: 4px 8px;
font-size: 0.78rem;
}
+236
View File
@@ -0,0 +1,236 @@
/* =============================================================================
* 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);
},
};
}
@@ -1,5 +1,6 @@
/**
* B07 도각 편집 모드 — 캐드 화면에서 도각(양식)만 따로 열어 고치고 [완료]로 한 번에 반영한다.
* 도각 편집 모드 — 캐드 화면에서 도각(양식)만 따로 열어 고치고 [완료]로 한 번에 반영한다.
* B07 에서 떼어 공용으로 둔다 — 도각을 읽고 쓰는 길(`api`)만 페이지가 넘긴다.
*
* 정본(`resources/master_template/drawing/00_template_A1.json`)은 프로그램 기본 도각이라
* 건드리지 않는다. 고친 도각은 **회사 도각**(`storage/{회사}/templates/`)으로 저장되고,
@@ -9,13 +10,18 @@
*/
import { createButton, showToast } from "@ui/ui_template_elements";
import {
type CadDrawing,
fetchFrameTemplate,
importFrameTemplate,
resetFrameTemplate,
saveFrameTemplate,
} from "./B07_DesignDetail_Api_Fetch";
import type { CadHostDrawing } from "./cad_host";
/** 도각을 읽고 쓰는 길 — 페이지가 자기 서버 길로 채운다. */
export interface FrameTemplateApi<D extends CadHostDrawing> {
/** 편집할 도각 한 장 · 고친 도각인가 · 자리표에 보여 줄 실제 값. */
fetch: () => Promise<{ drawing: D; customized: boolean; fields?: Record<string, string> }>;
save: (drawing: D) => Promise<void>;
/** 외부 도각 파일(DXF·DWG)을 읽어 편집 화면에 실을 도면으로 받는다 — 아직 저장하지 않는다. */
importFile: (file: File) => Promise<{ drawing: D; entity_count: number }>;
/** 고친 도각을 지우고 기본 도각으로 되돌린다. */
reset: () => Promise<void>;
}
export interface FrameTemplateEditor {
/** 도면 목록 아래에 놓는 「도각 편집」 버튼. */
@@ -26,18 +32,18 @@ export interface FrameTemplateEditor {
isEditing: () => boolean;
}
interface Options {
projectId: string;
interface Options<D extends CadHostDrawing> {
api: FrameTemplateApi<D>;
/** CAD에 도면을 싣는다 (meta null이면 수량 패널을 숨긴다).
* frameEdit 을 켜면 캐드 안 자리표 패널이 함께 뜬다. */
sendLoad: (
drawing: CadDrawing,
drawing: D,
meta: null,
frameEdit?: boolean,
frameFields?: Record<string, string>,
) => void;
/** CAD에서 현재 편집본을 받아온다. */
requestCadDrawing: () => Promise<CadDrawing>;
requestCadDrawing: () => Promise<D>;
/** 편집을 마친 뒤 보던 도면으로 돌아간다. */
restoreDrawing: () => void;
/** 도각이 바뀌었으니 받아 둔 도면 캐시를 버린다 — 안 버리면 옛 도각이 그대로 보인다. */
@@ -46,16 +52,18 @@ interface Options {
currentDrawingInfo: () => { label: string; number: string } | null;
}
export function createFrameTemplateEditor(options: Options): FrameTemplateEditor {
export function createFrameTemplateEditor<D extends CadHostDrawing>(
options: Options<D>,
): FrameTemplateEditor {
let editing = false;
// 자리표에 보여 줄 실제 값 — 도각을 열 때 서버에서 받아 캐드에 함께 넘긴다.
let frameFields: Record<string, string> = {};
const banner = document.createElement("div");
banner.className = "b07-frame-edit";
banner.className = "cad-frame-edit";
banner.hidden = true;
const label = document.createElement("span");
label.className = "b07-frame-edit__label";
label.className = "cad-frame-edit__label";
banner.append(label);
const finishButton = createButton({
@@ -95,7 +103,7 @@ export function createFrameTemplateEditor(options: Options): FrameTemplateEditor
onClick: () => leave(),
});
const bannerButtons = document.createElement("div");
bannerButtons.className = "b07-frame-edit__buttons";
bannerButtons.className = "cad-frame-edit__buttons";
bannerButtons.append(finishButton, importButton, resetButton, cancelButton);
banner.append(bannerButtons, fileInput);
@@ -118,7 +126,7 @@ export function createFrameTemplateEditor(options: Options): FrameTemplateEditor
if (!file) return;
importButton.disabled = true;
try {
const response = await importFrameTemplate(options.projectId, file);
const response = await options.api.importFile(file);
options.sendLoad(response.drawing, null, true, frameFields);
label.textContent = `${file.name} 을(를) 불러왔습니다 — 자리표를 놓고 [완료]를 누르십시오.`;
showToast(`도형 ${response.entity_count}개를 불러왔습니다.`, "success");
@@ -134,7 +142,7 @@ export function createFrameTemplateEditor(options: Options): FrameTemplateEditor
async function enter(): Promise<void> {
try {
const response = await fetchFrameTemplate(options.projectId);
const response = await options.api.fetch();
// 도면명·도면번호는 도면마다 달라 서버가 담지 않는다 — 보던 도면 값을 견본으로 얹는다.
const info = options.currentDrawingInfo();
frameFields = {
@@ -163,7 +171,7 @@ export function createFrameTemplateEditor(options: Options): FrameTemplateEditor
return;
resetButton.disabled = true;
try {
await resetFrameTemplate(options.projectId);
await options.api.reset();
options.onSaved();
showToast("기본 도각으로 되돌렸습니다.", "success");
leave();
@@ -182,7 +190,7 @@ export function createFrameTemplateEditor(options: Options): FrameTemplateEditor
finishButton.disabled = true;
try {
const drawing = await options.requestCadDrawing();
await saveFrameTemplate(options.projectId, drawing);
await options.api.save(drawing);
options.onSaved();
showToast("도각을 저장했습니다. 확정하지 않은 도면부터 새 도각으로 나옵니다.", "success");
leave();