/* ============================================================================= * M02_MasterTemplete_Drawing.ts * M02 도면 양식 편집 부품 — 웹캐드를 도각 편집 모드로 띄워 양식을 만들고 고친다 (PLAN 10-3). * * 계약(`tmp/M02_분석/6_계약.md` 화면 부품) — `mountDrawingTemplate(칸, 문서, {onSave, readOnly})` * → `{getDoc, destroy}`. 페이지(sub1)가 메인 칸에 붙인다. * `getDoc()` 은 CAD iframe 에서 편집본을 받아 오므로 **Promise** 다 — `await` 로 받는다. * * 양식 문서 = openwebcad 도면 JSON(`entities` · `layers`) + 양식 칸(`format` · `source` · * `drawing_area`). CAD 는 양식 칸을 모르므로 돌려줄 때 원래 칸 위에 편집본을 얹는다. * ========================================================================== */ import "./M02_MasterTemplete_Drawing.css"; import { API_BASE_URL } from "@config/config_frontend"; import { createCadHost, type CadHostDrawing } from "@ui/cad_host/cad_host"; import { createButton, createInputField, showToast } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; /** 도면 양식 문서 — 작도 영역은 [x0, y0, x1, y1] (양식 좌표 mm). */ export interface DrawingTemplateDoc extends CadHostDrawing { format?: number; source?: string; drawing_area?: [number, number, number, number]; [key: string]: unknown; } export interface DrawingTemplateOptions { /** CAD 안 💾 · Ctrl+S 가 부른다 — 페이지 머리 [저장] 과 같은 일(판 · 409 흐름)을 하게 페이지가 넘긴다. */ onSave?: (doc: DrawingTemplateDoc) => void | Promise; /** 보기 전용 — CAD 그리기·수정 · 작도 영역 · 불러오기가 막힌다. */ readOnly?: boolean; /** 양식 이름 — CAD 자동백업 칸을 양식마다 나눈다(B07 도면 백업과도 안 겹침). */ name?: string; /** 양식 층 — 같은 이름도 층마다 백업 칸이 갈린다(`m02:층:이름`). */ layer?: string; /** 프로젝트 층이면 프로젝트 id — 칸이 `m02:project:프로젝트id:이름` 이 된다. */ projectId?: string | null; /** 저장 안 한 고침이 생기면 true — CAD 편집 · 작도 영역 값 바꿈. */ onChanged?: (dirty: boolean) => void; /** 페이지 제목 줄 안의 빈 칸 — 있으면 작도 영역 칸 · 파일 불러오기를 거기 한 줄로 놓는다. */ headerSlot?: HTMLElement; } const RECOVERY_PREFIX = "OPEN_WEB_CAD__RECOVERY__"; /** 자동백업 칸 이름 — 층(과 프로젝트)을 붙여 같은 이름의 양식끼리 안 겹치게 한다. */ function recoveryScopeOf(options: DrawingTemplateOptions): string { const name = options.name ?? "drawing"; if (!options.layer) return `m02:${name}`; const project = options.layer === "project" ? `:${options.projectId ?? ""}` : ""; return `m02:${options.layer}${project}:${name}`; } /** 층 없는 옛 칸(`m02:이름`)을 새 칸으로 한 번 옮기고 지운다 — 새 칸에 이미 있으면 그대로 두고 지움. */ function migrateOldRecovery(options: DrawingTemplateOptions, scope: string): void { const old = `${RECOVERY_PREFIX}m02:${options.name ?? "drawing"}`; if (!options.layer || old === `${RECOVERY_PREFIX}${scope}`) return; try { const value = localStorage.getItem(old); if (value === null) return; if (localStorage.getItem(`${RECOVERY_PREFIX}${scope}`) === null) { localStorage.setItem(`${RECOVERY_PREFIX}${scope}`, value); } localStorage.removeItem(old); } catch { // 저장소를 못 써도 편집은 됨 } } export interface DrawingTemplateHandle { getDoc: () => Promise; destroy: () => void; } /** 작도 영역 칸이 없는 양식이 쓰는 값 — 서버 `Engine_Template._A1_INNER` 와 같다. */ const DEFAULT_AREA: [number, number, number, number] = [42, 47, 812, 567]; const AREA_LABELS = ["왼쪽 x", "아래 y", "오른쪽 x", "위 y"]; async function importDrawingFile( file: File, ): Promise<{ drawing: DrawingTemplateDoc; entity_count: number }> { const form = new FormData(); form.append("file", file); const response = await fetch(`${API_BASE_URL}/m02/drawing-import`, { method: "POST", credentials: "include", body: form, }); const payload = (await response.json()) as { drawing: DrawingTemplateDoc; entity_count: number; message?: string; }; if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`); return payload; } export function mountDrawingTemplate( container: HTMLElement, doc: DrawingTemplateDoc, options: DrawingTemplateOptions = {}, ): DrawingTemplateHandle { const readOnly = options.readOnly === true; const recoveryScope = recoveryScopeOf(options); migrateOldRecovery(options, recoveryScope); let base: DrawingTemplateDoc = doc; const root = document.createElement("div"); root.className = "m02-drawing"; const toolbar = document.createElement("div"); toolbar.className = "m02-drawing__toolbar"; // 작도 영역 — 도면 내용이 이 칸 한가운데에 놓인다. 제목 줄에는 값이 다 보이는 단추 하나, // 누르면 칸 넷이 작은 창으로 펼쳐진다(칸은 창을 닫아도 그대로 살아 있음). const areaForm = document.createElement("div"); areaForm.className = "m02-drawing__areaform"; const start = doc.drawing_area ?? DEFAULT_AREA; const areaInputs = AREA_LABELS.map((label, index) => { const field = createInputField({ label, type: "number", value: String(start[index]) }); field.input.disabled = readOnly; field.input.addEventListener("input", () => { options.onChanged?.(true); readArea(); }); return field; }); areaForm.append(...areaInputs.map((field) => field.root)); const areaButton = createButton({ label: "", variant: "ghost" }); areaButton.classList.add("m02-drawing__areabtn"); areaButton.title = "작도 영역 — 도면 내용이 놓이는 칸(양식 좌표 mm) · 눌러서 고침"; let areaModal: { close: () => void } | null = null; const readArea = (): [number, number, number, number] | null => { const values = areaInputs.map((field) => Number(field.input.value)); const valid = areaInputs.every((field) => field.input.value.trim() !== "") && values.every(Number.isFinite) && values[0] < values[2] && values[1] < values[3]; areaInputs.forEach((field) => field.setError(valid ? undefined : "왼쪽<오른쪽 · 아래<위")); areaButton.textContent = `작도 영역 ${areaInputs.map((f) => f.input.value.trim() || "?").join(" · ")}`; areaButton.classList.toggle("is-invalid", !valid); return valid ? (values as [number, number, number, number]) : null; }; readArea(); areaButton.addEventListener("click", () => { areaModal?.close(); areaModal = openModal({ title: "작도 영역", closeLabel: "닫기", dialogClass: "m02-modal", backdropClass: "m02-pop", mount: (body) => body.append(areaForm), }); // 단추 바로 아래 · 오른쪽 맞춤 · 화면 밖으로 안 나가게 const dialog = document.querySelector(".m02-pop > .ui-modal"); if (!dialog) return; const at = areaButton.getBoundingClientRect(); const width = dialog.offsetWidth; dialog.style.top = `${at.bottom + 4}px`; dialog.style.left = `${Math.max(8, Math.min(at.right - width, innerWidth - width - 8))}px`; dialog.style.maxHeight = `${innerHeight - at.bottom - 12}px`; }); // CAD 안 💾 · Ctrl+S — 양식 파일은 페이지 [저장] 만 쓴다. 저장 중 또 눌러도 한 번만 부른다. let saving = false; const hostSave = (): void => { if (readOnly) return void showToast("이 양식은 볼 수만 있습니다.", "info"); if (saving || !options.onSave) return; saving = true; getDoc() .then((next) => options.onSave?.(next)) .catch((error) => showToast(error instanceof Error ? error.message : "양식을 저장하지 못했습니다.", "error"), ) .finally(() => (saving = false)); }; const cad = createCadHost({ title: "도면 양식", onChanged: (dirty) => options.onChanged?.(dirty), onHostSave: hostSave, }); const load = (drawing: DrawingTemplateDoc): void => { cad.beginLoading(); cad.load( drawing, null, !readOnly, {}, { recoveryScope, readOnly, hostTitle: "도면 양식 편집", hostSave: true }, ); }; const actions = document.createElement("div"); actions.className = "m02-drawing__actions"; if (!readOnly) { const fileInput = document.createElement("input"); fileInput.type = "file"; fileInput.accept = ".dxf,.dwg"; fileInput.hidden = true; fileInput.addEventListener("change", () => { const file = fileInput.files?.[0]; fileInput.value = ""; if (!file) return; importButton.disabled = true; importDrawingFile(file) .then((response) => { // 불러온 도각은 아직 저장하지 않는다 — 자리표를 놓고 [저장]을 눌러야 양식이 된다. load(response.drawing); showToast(`도형 ${response.entity_count}개를 불러왔습니다.`, "success"); }) .catch((error) => showToast( error instanceof Error ? error.message : "도각 파일을 불러오지 못했습니다.", "error", ), ) .finally(() => (importButton.disabled = false)); }); const importButton = createButton({ label: "📂", variant: "ghost", onClick: () => fileInput.click(), }); importButton.title = importButton.ariaLabel = "파일 불러오기 (DXF · DWG)"; actions.append(importButton, fileInput); } const getDoc = async (): Promise => { const { drawing } = await cad.requestSave(); const drawingArea = readArea(); if (!drawingArea) throw new Error("작도 영역 값이 올바르지 않습니다."); base = { ...base, ...drawing, drawing_area: drawingArea }; return base; }; // 한 줄 — 페이지가 머리 칸(headerSlot)을 주면 거기, 아니면 그림 칸 위에 작게. toolbar.append(areaButton, actions); if (options.headerSlot) { toolbar.classList.add("m02-drawing__toolbar--slot"); options.headerSlot.append(toolbar); root.append(cad.element); } else root.append(toolbar, cad.element); container.append(root); load(doc); return { getDoc, destroy: () => { areaModal?.close(); cad.destroy(); toolbar.remove(); root.remove(); }, }; }