feat(M02): 도면 양식 편집 부품 mountDrawingTemplate — 도각 편집 모드 · 자리표 키 · 작도 영역 · 자동백업 칸 M02 용 (PLAN 10-3)

- M02_MasterTemplete_Drawing.ts · .css — (칸, 문서, {onSave, readOnly, name}) → {getDoc(Promise), destroy}
- cad_host load 곁값 recoveryScope · readOnly
- CAD 앱 브리지 — 싣기 메시지의 recoveryScope(없으면 meta.drawingId) · readOnly(확정본과 같은 막힘)

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:40:04 +09:00
co-authored by Claude Opus 5.5
parent 1778dbb505
commit 1244ce1d05
5 changed files with 328 additions and 7 deletions
@@ -20,6 +20,7 @@ import {
setDesignMeta,
setEntities,
setFrameEditMode,
setHostReadOnly,
setLayers,
} from '../state.ts';
import { toast } from 'react-toastify';
@@ -45,6 +46,10 @@ interface DrawingLoadMessage {
frameEdit?: boolean;
/** 자리표에 보여 줄 실제 값 (편집 화면 전용 — 저장값은 토큰 그대로). */
frameFields?: Record<string, string>;
/** 자동백업 칸 이름 — 없으면 meta.drawingId. 수량 패널 없이 싣는 M02 양식이 쓴다. */
recoveryScope?: string;
/** 보기 전용으로 싣는가 — 확정본처럼 그리기·수정을 막는다. */
readOnly?: boolean;
}
interface DrawingSaveRequestMessage {
@@ -187,6 +192,7 @@ export function registerAisloDrawingBridge() {
// 설계 컨텍스트(제목·측점정보·확정상태·수량표)를 수량 패널에 반영
setDesignMeta(event.data.meta ?? null);
setFrameEditMode(event.data.frameEdit === true, event.data.frameFields ?? {});
setHostReadOnly(event.data.readOnly === true);
if (event.data.frameEdit) applyFramePreview();
// 앞 도면에서 켜 둔 그리기 도구를 내린다. 안 내리면 **확정한 도면 위에도**
// 그 도구가 계속 그린다 — 읽기 전용은 새 명령만 막기 때문이다(2026-09-01 실측:
@@ -194,7 +200,7 @@ export function registerAisloDrawingBridge() {
// 상태를 이어 갈 이유도 없다.
runCommandInput('SELECT');
// 자동백업을 이 도면 칸으로 옮긴다 — 안 옮기면 백업 한 칸을 서로 덮는다.
setRecoveryScope(event.data.meta?.drawingId ?? null);
setRecoveryScope(event.data.recoveryScope ?? event.data.meta?.drawingId ?? null);
getScreenCanvasDrawController().zoomToFitScreen();
notifyParent(AISLO_DRAWING_LOADED_MESSAGE);
} catch (error) {
+8 -1
View File
@@ -190,6 +190,8 @@ let designMeta: DesignMeta | null = null;
let frameEditMode = false;
/** 자리표에 보여 줄 실제 값 — `{{공사명}}` → 공사명, `{{회사로고}}` → 그림 주소. */
let frameFields: Record<string, string> = {};
/** 부모가 보기 전용으로 실었는가 — M02 에서 고칠 권한이 없는 양식을 볼 때. */
let hostReadOnly = false;
/**
* 실은 뒤로 실제 편집이 있었는가. 도면을 바꾸기 전에 부모가 물어보는 근거다 —
@@ -267,7 +269,7 @@ export const isDrawingDirty = () => drawingDirty;
* 확정한 도면은 읽기 전용이다 — 그리기·수정·값 편집이 모두 막힌다(2026-09-01 사용자
* 확정). 보기(확대·이동·도면층 켜기끄기)는 그대로 두고, 푸는 길은 부모의 [수정]뿐이다.
*/
export const isDrawingReadOnly = (): boolean => designMeta?.confirmed === true;
export const isDrawingReadOnly = (): boolean => designMeta?.confirmed === true || hostReadOnly;
// setters
export const setCanvas = (newCanvas: HTMLCanvasElement) => {
@@ -493,6 +495,11 @@ export const setDesignMeta = (newMeta: DesignMeta | null) => {
triggerReactUpdate(StateVariable.designMeta);
};
/** 도각 편집 모드 켜고 끄기 — 자리표 패널의 표시 여부를 가른다 (2026-09-06 사용자 지시). */
/** 부모가 실은 도면을 보기 전용으로 둔다 — 확정본과 같은 막힘(도면을 실을 때마다 새로 정함). */
export const setHostReadOnly = (readOnly: boolean) => {
hostReadOnly = readOnly;
notifyWindow(HtmlEvent.UPDATE_STATE);
};
export const setFrameEditMode = (enabled: boolean, fields: Record<string, string> = {}) => {
frameEditMode = enabled;
frameFields = enabled ? fields : {};
@@ -0,0 +1,66 @@
/* M02 도면 양식 편집 부품 — 위 도구 줄(작도 영역 · 자리표 · 단추) + 아래 웹캐드. */
.m02-drawing {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
width: 100%;
height: 100%;
min-height: 0;
}
.m02-drawing > .cad-host {
flex: 1 1 0;
}
.m02-drawing__toolbar {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: var(--spacing-8) var(--spacing-16, 16px);
}
.m02-drawing__title {
align-self: center;
font-size: 0.78rem;
font-weight: 600;
color: var(--color-text);
}
.m02-drawing__area {
display: flex;
align-items: flex-end;
gap: var(--spacing-8);
}
.m02-drawing__area .ui-field {
width: 5.5rem;
}
.m02-drawing__fields {
display: flex;
flex: 1 1 20rem;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-4, 4px);
}
.m02-drawing__field {
padding: 2px 8px;
border: 1px solid var(--color-border);
border-radius: 1440px;
background-color: var(--color-surface);
color: var(--color-text);
font-size: 0.72rem;
cursor: pointer;
}
.m02-drawing__field[data-source="도면"] {
color: var(--color-text-muted);
}
.m02-drawing__actions {
display: flex;
gap: var(--spacing-8);
margin-left: auto;
}
@@ -0,0 +1,220 @@
/* =============================================================================
* 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";
/** 도면 양식 문서 — 작도 영역은 [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 {
/** [저장] 을 누르면 편집본을 넘긴다. 없으면 [저장] 단추를 두지 않는다. */
onSave?: (doc: DrawingTemplateDoc) => void | Promise<void>;
/** 보기 전용 — CAD 그리기·수정 · 작도 영역 · 불러오기가 막힌다. */
readOnly?: boolean;
/** 양식 이름 — CAD 자동백업 칸을 양식마다 나눈다(B07 도면 백업과도 안 겹침). */
name?: string;
}
export interface DrawingTemplateHandle {
getDoc: () => Promise<DrawingTemplateDoc>;
destroy: () => void;
}
/** 작도 영역 칸이 없는 양식이 쓰는 값 — 서버 `Engine_Template._A1_INNER` 와 같다. */
const DEFAULT_AREA: [number, number, number, number] = [42, 47, 812, 567];
const AREA_LABELS = ["왼쪽 x", "아래 y", "오른쪽 x", "위 y"];
interface DrawingField {
key: string;
source: string;
label: string;
}
async function fetchDrawingFields(): Promise<DrawingField[]> {
const response = await fetch(`${API_BASE_URL}/m02/drawing-fields`, { credentials: "include" });
const payload = (await response.json()) as { fields?: DrawingField[]; message?: string };
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
return payload.fields ?? [];
}
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 = `m02:${options.name ?? "drawing"}`;
let base: DrawingTemplateDoc = doc;
const root = document.createElement("div");
root.className = "m02-drawing";
const toolbar = document.createElement("div");
toolbar.className = "m02-drawing__toolbar";
// 작도 영역 — 도면 내용이 이 칸 한가운데에 놓인다.
const area = document.createElement("div");
area.className = "m02-drawing__area";
const areaTitle = document.createElement("span");
areaTitle.className = "m02-drawing__title";
areaTitle.textContent = "작도 영역";
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;
return field;
});
area.append(areaTitle, ...areaInputs.map((field) => field.root));
const readArea = (): [number, number, number, number] | null => {
const values = areaInputs.map((field) => Number(field.input.value));
const valid = values.every(Number.isFinite) && values[0] < values[2] && values[1] < values[3];
areaInputs.forEach((field) => field.setError(valid ? undefined : "왼쪽<오른쪽 · 아래<위"));
return valid ? (values as [number, number, number, number]) : null;
};
// 자리표 키 — 누르면 `{{키}}` 를 복사한다. 도각 글자에 붙여 넣으면 그릴 때 값이 채워진다.
const fields = document.createElement("div");
fields.className = "m02-drawing__fields";
const fieldsTitle = document.createElement("span");
fieldsTitle.className = "m02-drawing__title";
fieldsTitle.textContent = "자리표";
fields.append(fieldsTitle);
void fetchDrawingFields()
.then((list) => {
for (const field of list) {
const chip = document.createElement("button");
chip.type = "button";
chip.className = "m02-drawing__field";
chip.dataset.source = field.source;
chip.textContent = `{{${field.key}}}`;
chip.title = `${field.label} (${field.source}) — 눌러 복사`;
chip.addEventListener("click", () => {
void navigator.clipboard
?.writeText(`{{${field.key}}}`)
.then(() => showToast(`{{${field.key}}} 를 복사했습니다.`, "success"));
});
fields.append(chip);
}
})
.catch((error) =>
showToast(error instanceof Error ? error.message : "자리표 목록을 받지 못했습니다.", "error"),
);
const cad = createCadHost<DrawingTemplateDoc>({ title: "도면 양식" });
const load = (drawing: DrawingTemplateDoc): void => {
cad.beginLoading();
cad.load(drawing, null, !readOnly, {}, { recoveryScope, readOnly });
};
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(),
});
actions.append(importButton, fileInput);
}
const getDoc = async (): Promise<DrawingTemplateDoc> => {
const { drawing } = await cad.requestSave();
const drawingArea = readArea();
if (!drawingArea) throw new Error("작도 영역 값이 올바르지 않습니다.");
base = { ...base, ...drawing, drawing_area: drawingArea };
return base;
};
if (options.onSave && !readOnly) {
const onSave = options.onSave;
const saveButton = createButton({
label: "저장",
variant: "filled",
onClick: () => {
saveButton.disabled = true;
getDoc()
.then((next) => onSave(next))
.catch((error) =>
showToast(
error instanceof Error ? error.message : "양식을 저장하지 못했습니다.",
"error",
),
)
.finally(() => (saveButton.disabled = false));
},
});
actions.append(saveButton);
}
toolbar.append(area, fields, actions);
root.append(toolbar, cad.element);
container.append(root);
load(doc);
return {
getDoc,
destroy: () => {
cad.destroy();
root.remove();
},
};
}
+27 -5
View File
@@ -50,6 +50,14 @@ export interface CadHostOptions {
onExport?: (fileFormat: "dxf" | "dwg") => void;
}
/** 싣기 곁값 — 수량 패널(meta) 없이 싣는 M02 양식이 쓴다. */
export interface CadLoadExtra {
/** 자동백업 칸 이름 — 없으면 meta.drawingId. 칸이 겹치면 백업을 서로 덮는다. */
recoveryScope?: string;
/** 보기 전용 — 확정본처럼 그리기·수정이 막힌다. */
readOnly?: boolean;
}
export interface CadHost<D extends CadHostDrawing, M, Q> {
/** iframe 과 라이선스 글을 담은 칸 — 페이지 메인 칸에 붙인다. */
element: HTMLElement;
@@ -65,6 +73,7 @@ export interface CadHost<D extends CadHostDrawing, M, Q> {
meta: M | null,
frameEdit?: boolean,
frameFields?: Record<string, string>,
extra?: CadLoadExtra,
) => void;
/** CAD 의 지금 편집본을 받는다. */
requestSave: () => Promise<CadSaveResult<D, Q>>;
@@ -91,7 +100,13 @@ export function createCadHost<D extends CadHostDrawing, M = unknown, Q = unknown
let cadReady = false;
let pendingLoad:
| { drawing: D; meta: M | null; frameEdit: boolean; frameFields: Record<string, string> }
| {
drawing: D;
meta: M | null;
frameEdit: boolean;
frameFields: Record<string, string>;
extra: CadLoadExtra;
}
| undefined;
let resolveSave: ((payload: CadSaveResult<D, Q>) => void) | undefined;
// ⚠ CAD 는 iframe 이라 **저쪽이 아무 말도 안 하면 화면이 영원히 「불러오는 중」에 머문다**
@@ -142,10 +157,16 @@ export function createCadHost<D extends CadHostDrawing, M = unknown, Q = unknown
frame.contentWindow?.postMessage(message, window.location.origin);
};
const load: CadHost<D, M, Q>["load"] = (drawing, meta, frameEdit = false, frameFields = {}) => {
pendingLoad = { drawing, meta, frameEdit, frameFields };
const load: CadHost<D, M, Q>["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 });
post({ type: CAD_LOAD_MESSAGE, drawing, meta, frameEdit, frameFields, ...extra });
pendingLoad = undefined;
};
@@ -191,7 +212,8 @@ export function createCadHost<D extends CadHostDrawing, M = unknown, Q = unknown
} else if (message.type === CAD_READY_MESSAGE) {
cadReady = true;
if (pendingLoad) {
load(pendingLoad.drawing, pendingLoad.meta, pendingLoad.frameEdit, pendingLoad.frameFields);
const { drawing, meta, frameEdit, frameFields, extra } = pendingLoad;
load(drawing, meta, frameEdit, frameFields, extra);
// 기다리던 것이 「iframe 이 뜨기」에서 「도면이 열리기」로 바뀌었다 — 시계를 다시 건다.
if (element.dataset.loading === "true") startLoadWatchdog();
}