diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts index 4fda6b0b..e40721b1 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts @@ -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; /** 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 { : "도면 목록을 불러오지 못했습니다."; } - 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; - } - | 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({ + 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 { 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 { hasNext: index < drawings.length - 1, }); - // frameEdit: 도각 편집으로 싣는 도면인가 — 캐드 안 자리표 패널을 이때만 띄운다 - // (2026-09-06 사용자 지시로 패널을 캐드 안으로 옮김). - const sendLoad = ( - drawing: CadDrawing, - meta: DesignMeta | null, - frameEdit = false, - frameFields: Record = {}, - ) => { - 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 { 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 { 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 loadDrawing(drawings[target], target); }; - const requestCadDrawing = (): Promise => - 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 => cad.requestSave(); async function confirmCurrentDrawing(): Promise { if (!projectId || !currentDrawing) return; @@ -501,9 +402,15 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { } } - const frameEditor = createFrameTemplateEditor({ - projectId: projectId as string, - sendLoad, + const frameEditor = createFrameTemplateEditor({ + 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 { currentDrawing ? { label: currentDrawing.label, number: String(currentIndex + 1) } : null, }); - window.addEventListener("message", (event: MessageEvent) => { - 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 { 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 { root.replaceChildren(layout.root); // 페이지 진입 시 첫 도면(종단도)을 자동 선택 — 빈 CAD 화면 방지. - // CAD가 아직 준비 전이면 sendLoad가 pendingLoad로 대기했다가 ready 시 전송한다. + // CAD가 아직 준비 전이면 cad.load 가 붙들고 있다가 ready 때 보낸다. if (drawings.length > 0) { void loadDrawing(drawings[0], 0).then(() => { // 첫 장을 띄운 뒤에 나머지를 받는다 — 진입 속도를 뺏지 않는다. diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Style.css b/B07_DesignDetail/B07_DesignDetail_UI_Style.css index 994febee..3af80ed7 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Style.css +++ b/B07_DesignDetail/B07_DesignDetail_UI_Style.css @@ -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; diff --git a/ui_template/cad_host/cad_host.css b/ui_template/cad_host/cad_host.css new file mode 100644 index 00000000..a7900805 --- /dev/null +++ b/ui_template/cad_host/cad_host.css @@ -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; +} diff --git a/ui_template/cad_host/cad_host.ts b/ui_template/cad_host/cad_host.ts new file mode 100644 index 00000000..5fde060a --- /dev/null +++ b/ui_template/cad_host/cad_host.ts @@ -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[]; + 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; +} + +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, + ) => 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 } + | 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 = {}) => { + pendingLoad = { drawing, meta, frameEdit, frameFields }; + if (!cadReady) return; + post({ type: CAD_LOAD_MESSAGE, drawing, meta, frameEdit, frameFields }); + 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) { + 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); + }, + }; +} diff --git a/B07_DesignDetail/B07_DesignDetail_UI_FrameEdit.ts b/ui_template/cad_host/cad_host_frame_edit.ts similarity index 81% rename from B07_DesignDetail/B07_DesignDetail_UI_FrameEdit.ts rename to ui_template/cad_host/cad_host_frame_edit.ts index 04ab6ee2..3e74ff2f 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_FrameEdit.ts +++ b/ui_template/cad_host/cad_host_frame_edit.ts @@ -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 { + /** 편집할 도각 한 장 · 고친 도각인가 · 자리표에 보여 줄 실제 값. */ + fetch: () => Promise<{ drawing: D; customized: boolean; fields?: Record }>; + save: (drawing: D) => Promise; + /** 외부 도각 파일(DXF·DWG)을 읽어 편집 화면에 실을 도면으로 받는다 — 아직 저장하지 않는다. */ + importFile: (file: File) => Promise<{ drawing: D; entity_count: number }>; + /** 고친 도각을 지우고 기본 도각으로 되돌린다. */ + reset: () => Promise; +} export interface FrameTemplateEditor { /** 도면 목록 아래에 놓는 「도각 편집」 버튼. */ @@ -26,18 +32,18 @@ export interface FrameTemplateEditor { isEditing: () => boolean; } -interface Options { - projectId: string; +interface Options { + api: FrameTemplateApi; /** CAD에 도면을 싣는다 (meta null이면 수량 패널을 숨긴다). * frameEdit 을 켜면 캐드 안 자리표 패널이 함께 뜬다. */ sendLoad: ( - drawing: CadDrawing, + drawing: D, meta: null, frameEdit?: boolean, frameFields?: Record, ) => void; /** CAD에서 현재 편집본을 받아온다. */ - requestCadDrawing: () => Promise; + requestCadDrawing: () => Promise; /** 편집을 마친 뒤 보던 도면으로 돌아간다. */ restoreDrawing: () => void; /** 도각이 바뀌었으니 받아 둔 도면 캐시를 버린다 — 안 버리면 옛 도각이 그대로 보인다. */ @@ -46,16 +52,18 @@ interface Options { currentDrawingInfo: () => { label: string; number: string } | null; } -export function createFrameTemplateEditor(options: Options): FrameTemplateEditor { +export function createFrameTemplateEditor( + options: Options, +): FrameTemplateEditor { let editing = false; // 자리표에 보여 줄 실제 값 — 도각을 열 때 서버에서 받아 캐드에 함께 넘긴다. let frameFields: Record = {}; 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 { 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();