feat(M02): CAD 공용 탭 줄 · 구조물 도면 상세 여러 장 탭 · 카드 「장 n」(82-1 · 82-2)

- ui_template/cad_host/cad_host_tabs — 고르기 · [+] · 이름 · 지우기 · 끌어 순서 · 닫기(켬/끔) · 바꿀 때 지금 장 받아 두고 싣기
- mountStructureSheets — 1장 고정 + 더한장 · 백업 칸 장마다 · 장 도번 도각 · 안 실린 장 표 갈기
- 상세 열기 · 저장 = 도면 · 더한장 칸 · 카드 1장 + 장 수 · 도각 도번

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK
This commit is contained in:
2026-10-08 12:10:49 +09:00
co-authored by Claude Opus 5.5
parent 5b9c804ff5
commit caf38ccb3e
8 changed files with 885 additions and 27 deletions
@@ -86,3 +86,27 @@
max-width: calc(100vw - 16px);
box-shadow: 0 4px 16px rgb(0 0 0 / 25%);
}
/* 카드 미리보기 「장 n」(82-2) — 1장만 보이는 여러 장 도면 */
.m02-drawing:has(> .m02-drawing__sheets) {
position: relative;
}
.m02-drawing__sheets {
position: absolute;
z-index: 3;
top: 6px;
right: 8px;
padding: 1px 8px;
border: 1px solid var(--color-border);
border-radius: 10px;
background: var(--color-surface-raised);
color: var(--color-text-secondary);
font-size: 11px;
pointer-events: none;
}
/* 탭 줄(82-1) — 그림 칸에 붙임 */
.m02-drawing > .cad-tabs {
margin-bottom: calc(-1 * var(--spacing-8));
}
+196 -18
View File
@@ -13,6 +13,7 @@
import "./M02_MasterTemplete_Drawing.css";
import { API_BASE_URL } from "@config/config_frontend";
import { createCadHost, type CadHostDrawing } from "@ui/cad_host/cad_host";
import { createCadTabs, type CadTab, type CadTabs } from "@ui/cad_host/cad_host_tabs";
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
@@ -51,13 +52,19 @@ export interface DrawingTemplateOptions {
frame?: boolean;
/** 파일 불러오기(📂) 단추를 안 둠 — 집계표 카드 미리보기(불러오기는 도면 상세에서만). */
noImport?: boolean;
/** 장id(82-1) — 자동백업 칸을 장마다 나눔(1장 = 빈 값 · 옛 칸 그대로) */
sheet?: string;
/** 장 도번(82-2) — 얹은 도각 `{{도면번호}}` 자리 */
frameNumber?: string;
/** 탭 줄(82-1) — 그림 칸 바로 위에 놓음 */
tabs?: HTMLElement;
}
const RECOVERY_PREFIX = "OPEN_WEB_CAD__RECOVERY__";
/** 자동백업 칸 이름 — 층(과 프로젝트)을 붙여 같은 이름의 양식끼리 안 겹치게 한다. */
function recoveryScopeOf(options: DrawingTemplateOptions): string {
const name = options.name ?? "drawing";
function recoveryScopeOf(options: DrawingTemplateOptions, sheet = ""): string {
const name = `${options.name ?? "drawing"}${sheet ? `#${sheet}` : ""}`;
if (!options.layer) return `m02:${name}`;
const project = options.layer === "project" ? `:${options.projectId ?? ""}` : "";
return `m02:${options.layer}${project}:${name}`;
@@ -84,6 +91,8 @@ export interface DrawingTemplateHandle {
/** 표 놓기 · 칸 갈기(41장) — `cad_host` 그대로 */
placeTable: (entity: Record<string, unknown>, link: string) => void;
updateTable: (link: string, cells: unknown[][]) => Promise<number>;
/** 다른 장을 같은 CAD 에 실음(82-1) — 백업 칸 · 도각 도번도 그 장 것으로 */
loadSheet: (drawing: DrawingTemplateDoc, sheet: string, frameNumber?: string) => void;
destroy: () => void;
}
@@ -98,18 +107,22 @@ interface StructureFrame {
layer: Record<string, unknown> | null;
}
const BLANK_LAYER = { id: "기본", name: "기본", isVisible: true, isLocked: false };
let framePromise: Promise<StructureFrame | null> | null = null;
const framePromises = new Map<string, Promise<StructureFrame | null>>();
/** 구조물 도각 — 한 번만 받아 돌려씀 · 못 받으면 도각 없이 */
function fetchFrame(): Promise<StructureFrame | null> {
framePromise ??= fetch(`${API_BASE_URL}/m02/structure-frame`, { credentials: "include" })
.then((response) => (response.ok ? (response.json() as Promise<StructureFrame>) : null))
.catch(() => null)
.then((frame) => {
if (!frame?.layer) framePromise = null; // 다음에 다시 받음
return frame?.layer ? frame : null;
});
return framePromise;
/** 구조물 도각 — 장 도번마다 한 번만 받아 돌려씀(`{{도면번호}}` · 82-2) · 못 받으면 도각 없이 */
function fetchFrame(number = ""): Promise<StructureFrame | null> {
const query = number ? `?number=${encodeURIComponent(number)}` : "";
const got =
framePromises.get(number) ??
fetch(`${API_BASE_URL}/m02/structure-frame${query}`, { credentials: "include" })
.then((response) => (response.ok ? (response.json() as Promise<StructureFrame>) : null))
.catch(() => null)
.then((frame) => {
if (!frame?.layer) framePromises.delete(number); // 다음에 다시 받음
return frame?.layer ? frame : null;
});
framePromises.set(number, got);
return got;
}
/** 도각 층을 뺀 도면 — 구조물 엔티티 · 층만 남음 */
@@ -147,8 +160,9 @@ export function mountDrawingTemplate(
): DrawingTemplateHandle {
const readOnly = options.readOnly === true;
const noArea = options.noArea === true;
const recoveryScope = recoveryScopeOf(options);
migrateOldRecovery(options, recoveryScope);
let recoveryScope = recoveryScopeOf(options, options.sheet);
let frameNumber = options.frameNumber ?? "";
migrateOldRecovery(options, recoveryScopeOf(options));
let base: DrawingTemplateDoc = doc;
const root = document.createElement("div");
@@ -238,7 +252,7 @@ export function mountDrawingTemplate(
const load = async (drawing: DrawingTemplateDoc): Promise<void> => {
const mine = ++loadSeq;
cad.beginLoading();
const frame = options.frame ? await fetchFrame() : null;
const frame = options.frame ? await fetchFrame(frameNumber) : null;
if (mine !== loadSeq) return; // 그새 다른 도면을 실음
const own = withoutFrame(drawing);
const shown = frame?.layer
@@ -319,11 +333,12 @@ export function mountDrawingTemplate(
// 한 줄 — 페이지가 머리 칸(headerSlot)을 주면 거기, 아니면 그림 칸 위에 작게.
toolbar.append(...(noArea ? [] : [areaButton]), actions);
const tabs = options.tabs ? [options.tabs] : [];
if (options.headerSlot) {
toolbar.classList.add("m02-drawing__toolbar--slot");
options.headerSlot.append(toolbar);
root.append(cad.element);
} else root.append(...(noArea && !canImport ? [] : [toolbar]), cad.element);
root.append(...tabs, cad.element);
} else root.append(...(noArea && !canImport ? [] : [toolbar]), ...tabs, cad.element);
container.append(root);
void load(doc);
@@ -331,6 +346,12 @@ export function mountDrawingTemplate(
getDoc,
placeTable: cad.placeTable,
updateTable: cad.updateTable,
loadSheet: (drawing, sheet, number) => {
recoveryScope = recoveryScopeOf(options, sheet);
frameNumber = number ?? "";
base = drawing;
void load(drawing);
},
destroy: () => {
areaModal?.close();
cad.destroy();
@@ -339,3 +360,160 @@ export function mountDrawingTemplate(
},
};
}
/* --- 구조물 도면 여러 장(82-1 · 82-2) — 한 파일 `도면`(1장) + `더한장[]` · 탭 줄 = 공용 `cad_host_tabs` --- */
/** 구조물 도면 더한 장 — `M02_MasterTemplete_Api_Fetch` `StructureSheet` 와 같은 꼴 */
interface SheetDoc {
장id: string;
이름: string;
도면: DrawingTemplateDoc;
}
/** 구조물 도면 문서에서 쓰는 칸 */
interface SheetsDoc {
도번?: string;
도면: DrawingTemplateDoc;
더한장?: SheetDoc[];
}
const FIRST_TAB = "#1"; // 1장 탭 id — 장id 는 「#」 를 안 씀(저장은 빈 값)
const FIRST_NAME = "1장";
/** 장 도번 — 1장 = 도번 · n 째 장 = 도번-n(SD-000010-2) */
export const sheetNumber = (number: string | undefined, index: number): string =>
!number ? "" : index === 0 ? number : `${number}-${index + 1}`;
/** 표 칸 갈기 — 안 실린 장(받아 둔 편집본)에서 · 크기는 칸 수가 바뀔 때만(서버 `refresh_drawing` 과 같음) */
function updateStashedTables(
drawing: DrawingTemplateDoc,
link: string,
cells: unknown[][],
): number {
let count = 0;
for (const entity of drawing.entities) {
const shape = (entity as { type?: string; shapeData?: Record<string, unknown> }).shapeData;
const style = shape?.style as Record<string, unknown> | undefined;
if (entity.type !== "Table" || !shape || style?.연결 !== link) continue;
shape.cells = cells;
count += 1;
}
return count;
}
export interface StructureSheetsHandle extends Omit<DrawingTemplateHandle, "getDoc" | "loadSheet"> {
/** `{도면, 더한장?}` — 부르는 쪽이 읽은 문서에 얹음 */
getDoc: () => Promise<{ 도면: DrawingTemplateDoc; 더한장?: SheetDoc[] }>;
/** 지금 탭 장id(1장 = 빈 값) */
activeSheet: () => string;
}
/** 구조물 도면 상세 — 탭 줄 + 한 CAD · 탭 바꾸면 지금 장을 받아 두고 다음 장을 실음 */
export function mountStructureSheets(
container: HTMLElement,
doc: SheetsDoc,
options: DrawingTemplateOptions = {},
): StructureSheetsHandle {
const readOnly = options.readOnly === true;
const more = doc.더한장 ?? [];
let cadDirty = false;
let listDirty = false;
const report = (): void => options.onChanged?.(cadDirty || listDirty || tabs.anyDirty());
const numberOf = (id: string): string =>
sheetNumber(
doc.도번,
tabs.tabs().findIndex((tab) => tab.id === id),
);
const tabs: CadTabs<DrawingTemplateDoc> = createCadTabs<DrawingTemplateDoc>({
features: readOnly ? {} : { add: true, rename: true, remove: true, reorder: true },
capture: async () => ({ drawing: await drawing.getDoc(), dirty: cadDirty }),
show: (tab) => {
cadDirty = false;
drawing.loadSheet(
tab.drawing ?? { entities: [], layers: [BLANK_LAYER] },
tab.id === FIRST_TAB ? "" : tab.id,
numberOf(tab.id),
);
report();
},
create: (): CadTab<DrawingTemplateDoc> => {
const names = new Set(tabs.tabs().map((tab) => tab.name));
let n = tabs.tabs().length + 1;
while (names.has(`${n}장`)) n += 1;
const id = `s${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
return {
id,
name: `${n}장`,
drawing: { format: 6, entities: [], layers: [{ ...BLANK_LAYER }] },
};
},
onChange: () => {
listDirty = true;
retitle();
report();
},
});
const retitle = (): void => {
for (const tab of tabs.tabs()) tab.title = numberOf(tab.id);
};
tabs.set(
[
{ id: FIRST_TAB, name: FIRST_NAME, drawing: doc.도면, fixed: true },
...more.map((sheet) => ({ id: sheet.장id, name: sheet.이름, drawing: sheet.도면 })),
],
FIRST_TAB,
);
retitle();
tabs.set(tabs.tabs(), FIRST_TAB); // 풍선 글을 그림
if (readOnly && !more.length) tabs.element.hidden = true; // 볼 장이 하나 — 줄 없음
const drawing = mountDrawingTemplate(container, doc.도면, {
...options,
sheet: "",
frameNumber: doc.도번,
tabs: tabs.element,
onChanged: (dirty) => {
cadDirty = dirty;
report();
},
});
return {
getDoc: async () => {
const all = await tabs.collect();
const first = all.find((tab) => tab.id === FIRST_TAB);
const rest = all
.filter((tab) => tab.id !== FIRST_TAB)
.map((tab) => ({
장id: tab.id,
이름: tab.name,
도면: tab.drawing ?? { format: 6, entities: [], layers: [{ ...BLANK_LAYER }] },
}));
listDirty = false;
tabs.markClean(true);
return {
도면: first?.drawing ?? doc.도면,
...(rest.length || doc.더한장 ? { 더한장: rest } : {}),
};
},
activeSheet: () => {
const id = tabs.active()?.id ?? FIRST_TAB;
return id === FIRST_TAB ? "" : id;
},
placeTable: drawing.placeTable,
// 실린 장은 CAD · 안 실린 장은 받아 둔 편집본에서(82-3)
updateTable: async (link, cells) => {
const now = tabs.active();
let count = 0;
for (const tab of tabs.tabs()) {
if (tab === now || !tab.drawing) continue;
const changed = updateStashedTables(tab.drawing, link, cells);
if (changed) tab.dirty = true;
count += changed;
}
if (count) report();
return count + (await drawing.updateTable(link, cells).catch(() => 0));
},
destroy: drawing.destroy,
};
}
@@ -82,11 +82,21 @@ function mountStructure(
originMarker: true, // 좌표 0,0 기준점 십자
noArea: true, // 작도 영역 칸 없음
frame: true, // 도각 = 보여주기만 · 외곽 좌하단 (0,0)(56장)
frameNumber: doc.도번, // 도각 `{{도면번호}}` = 1장 도번(82-2)
noImport: true, // 파일 불러오기는 도면 상세에서만(47-2)
...hooks,
});
const frame = host.querySelector("iframe");
if (frame) hideChrome(frame, edit ? HIDE_EDIT : HIDE_VIEW);
// 여러 장(82-2) — 미리보기는 1장 · 장 수만 알림(다른 장은 도면 상세 탭에서)
const sheets = 1 + (doc.더한장?.length ?? 0);
if (sheets > 1) {
const badge = document.createElement("span");
badge.className = "m02-drawing__sheets";
badge.textContent = `장 ${sheets}`;
badge.title = `1장을 보는 중 · 모두 ${sheets}장 — 다른 장은 도면 상세에서`;
host.querySelector(".m02-drawing")?.append(badge);
}
return handle;
}
@@ -416,14 +416,13 @@ export function createMain(
// 열 때만 `import()` 로 받음(계약 `spreadsheet_types.ts` 머리)
return mountSheet(doc, at.kind, at, readOnly);
}
// 구조물 도면 = 여러 장 탭(82-1 · `도면` 1장 + `더한장`) — 도번은 안 건드림
const mountDrawing = await findExport<(h: HTMLElement, d: unknown, o: object) => EditorHandle>(
DRAWING_MODS,
"mountDrawingTemplate",
at.kind === "structure" ? "mountStructureSheets" : "mountDrawingTemplate",
);
if (mountDrawing) {
// 구조물 도면은 CAD 문서가 `도면` 칸에 들어 있음 — 도번은 안 건드림
const cad = at.kind === "structure" ? (doc as StructureDoc).도면 : doc;
editor = mountDrawing(host, cad, {
editor = mountDrawing(host, doc, {
onSave: () => void doSave(),
readOnly,
name: at.name,
@@ -504,13 +503,13 @@ export function createMain(
const at = sel;
try {
const edited = editor ? await editor.getDoc() : loaded;
// 구조물 도면 저장은 `도면` 칸만 갈아 끼움 — 도번은 읽은 그대로.
// 구조물 도면 저장은 `도면` · `더한장` 칸만 갈아 끼움 — 도번은 읽은 그대로.
// 면적산출은 스프레드시트 문서로 통째 갈아 끼우되 번호는 계약 밖(엔진이 모름)이라 옮겨 붙임.
const doc =
at.kind === "structure" && loaded
? toDrawing.withLinks({
...(loaded as StructureDoc),
도면: edited as StructureDoc["도면"],
...(edited as Pick<StructureDoc, "도면" | "더한장">),
})
: at.kind === "area" && loaded
? { ...(loaded as Record<string, unknown>), ...(edited as Record<string, unknown>) }
+221
View File
@@ -0,0 +1,221 @@
"""CAD 공용 탭 줄(PLAN 82-1 `cad_host_tabs.ts`) · M02 구조물 도면 여러 장(82-2 `mountStructureSheets`)
— Node 로 TS 를 돌려 판정(가짜 DOM · 가짜 CAD).
못박는 것
- 탭 바꿀 때 지금 장을 받아 두고(capture) 다음 장을 실음 · 탭 셋 왕복 뒤 장마다 엔티티 수 그대로.
- 고정 탭(1장) 앞으로는 못 끎 · 지우기 · 같은 이름 막음.
- 여러 장 문서 getDoc = `도면`(1장) + `더한장[{장id, 이름, 도면}]` · 새 장 · 장 도번 SD-1-2 · 백업 칸 장마다.
- 안 실린 장 표 갈기(updateTable) = 받아 둔 장에서.
"""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
SCRIPT = r"""
const fs = require("fs");
const root = process.argv[1];
const ts = require(root + "/config/node_modules/typescript");
const load = (file, stubs) => {
const src = fs.readFileSync(root + "/" + file, "utf8");
const js = ts.transpileModule(src, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
new Function("exports", "module", "require", js)(box.exports, box, (n) =>
n.endsWith(".css") ? {} : (stubs[n] ?? require(n)),
);
return box.exports;
};
// 가짜 DOM — 쓰는 것만
const node = (tag) => ({
tag, kids: [], dataset: {}, on: {}, attrs: {}, hidden: false, textContent: "", title: "", className: "",
classList: { add() {}, toggle() {} }, style: {},
append(...x) { this.kids.push(...x); }, prepend(...x) { this.kids.unshift(...x); },
replaceChildren(...x) { this.kids = x; }, replaceWith() {}, remove() {}, select() {}, focus() {},
setAttribute(k, v) { this.attrs[k] = v; }, addEventListener(k, f) { this.on[k] = f; },
getBoundingClientRect: () => ({ left: 0, width: 100 }), querySelector: () => null,
});
globalThis.document = { createElement: node };
const toasts = [];
const confirms = [];
const elements = {
showToast: (m, k) => toasts.push([k, m]),
showConfirmDialog: async (m) => (confirms.push(m), true),
createButton: () => node("button"),
createInputField: (o) => ({ root: node("div"), input: { value: o.value, disabled: false, addEventListener() {} }, setError() {} }),
};
const tabsMod = load("ui_template/cad_host/cad_host_tabs.ts", { "@ui/ui_template_elements": elements });
(async () => {
const out = {};
// ① 탭 셋 왕복 — CAD 하나에 장을 갈아 실음
let shown = { id: "a", n: 3 };
const log = [];
const tabs = tabsMod.createCadTabs({
features: { add: true, rename: true, remove: true, reorder: true },
capture: async () => ({ drawing: { ...shown, n: shown.n + (shown.id === "b" ? 1 : 0) }, dirty: shown.id === "b" }),
show: (tab) => (log.push(tab.id), (shown = tab.drawing)),
onChange: () => log.push("change"),
});
tabs.set([
{ id: "a", name: "1장", drawing: { id: "a", n: 3 }, fixed: true },
{ id: "b", name: "2장", drawing: { id: "b", n: 5 } },
{ id: "c", name: "3장", drawing: { id: "c", n: 0 } },
], "a");
for (const id of ["b", "c", "a", "b", "a"]) await tabs.select(id);
const all = await tabs.collect();
out.roundtrip = all.map((t) => [t.id, t.drawing.n, !!t.dirty]);
out.shownOrder = log.filter((x) => x !== "change");
// ② 끌기 — c 를 a(고정) 앞에 놓아도 a 뒤
const tabNode = (id) => tabs.element.kids.find((k) => k.dataset.tabId === id);
tabNode("c").on.dragstart({ dataTransfer: { setData() {} } });
tabNode("a").on.dragover({ preventDefault() {}, clientX: 10 });
tabNode("a").on.drop({ preventDefault() {} });
out.order = tabs.tabs().map((t) => t.id);
out.fixedDraggable = !!tabNode("a").draggable;
out.fixedX = tabNode("a").kids.length; // 고정 탭 = 글만(지우기 없음)
// ③ 지우기 — 물음 뒤 · 지금 탭이면 다음 탭을 실음
await tabs.select("c");
const xc = tabNode("c").kids.find((k) => k.className === "cad-tabs__x");
await xc.on.click({ stopPropagation() {} });
out.afterRemove = { ids: tabs.tabs().map((t) => t.id), active: tabs.active().id, confirms: confirms.length };
// ④ [+] — create 없으면 아무것도 안 함 · 닫기 기능(B07) = 미저장 탭만 물음
const b07 = [];
const closer = tabsMod.createCadTabs({
features: { close: true },
capture: async () => ({ drawing: "편집본", dirty: true }),
show: (tab) => b07.push(["show", tab.id, tab.drawing ?? null, !!tab.dirty]),
onClosed: (tab) => b07.push(["closed", tab.id]),
});
await closer.open({ id: "d1", name: "종단" });
await closer.open({ id: "d2", name: "횡단" });
await closer.open({ id: "d1", name: "종단" });
const before = confirms.length;
const d2 = closer.element.kids.find((k) => k.dataset.tabId === "d2");
await d2.kids.find((k) => k.className === "cad-tabs__x").on.click({ stopPropagation() {} });
out.b07 = { log: b07, asked: confirms.length - before, ids: closer.tabs().map((t) => t.id) };
// ⑤ M02 여러 장 — 가짜 CAD(requestSave = 지금 실린 도면 + 편집)
const cadLoads = [];
let inCad = null;
const fakeCad = {
element: node("div"),
beginLoading() {},
load: (drawing, meta, frameEdit, fields, extra) => (cadLoads.push(extra.recoveryScope), (inCad = drawing)),
requestSave: async () => ({ drawing: inCad, quantityTable: null }),
placeTable() {},
updateTable: async () => 1,
destroy() {},
};
const urls = [];
globalThis.fetch = async (url) => (urls.push(url), { ok: true, json: async () => ({ entities: [{ type: "Text", url, layerId: "m02-structure-frame" }], layer: { id: "m02-structure-frame" } }) });
const drawingMod = load("M02_MasterTemplete/M02_MasterTemplete_Drawing.ts", {
"@config/config_frontend": { API_BASE_URL: "/api" },
"@ui/cad_host/cad_host": { createCadHost: () => fakeCad },
"@ui/cad_host/cad_host_tabs": tabsMod,
"@ui/ui_template_elements": elements,
"@ui/ui_template_modal": { openModal: () => ({ close() {} }) },
});
const table = (link) => ({ type: "Table", shapeData: { cells: [[{ text: "옛" }]], style: { 연결: link } } });
const doc = {
도번: "SD-1",
도면: { format: 6, entities: [{ type: "Line" }, { type: "Line" }], layers: [] },
더한장: [{ 장id: "s2", 이름: "단면", 도면: { format: 6, entities: [table("T2")], layers: [] } }],
};
const dirt = [];
const host = node("div");
const sheets = drawingMod.mountStructureSheets(host, doc, {
name: "rv", layer: "system", noArea: true, frame: true, onChanged: (d) => dirt.push(d),
});
await new Promise((r) => setTimeout(r, 5));
const bar = host.kids[0].kids.find((k) => k.className === "cad-tabs");
const plus = bar.kids.find((k) => k.className === "cad-tabs__add");
await plus.on.click();
await new Promise((r) => setTimeout(r, 5));
inCad = { ...inCad, entities: [...inCad.entities, { type: "Circle" }] }; // 새 장에 하나 그림
const third = bar.kids.find((k) => k.dataset.tabId && k.dataset.active === "true").dataset.tabId;
await bar.kids.find((k) => k.dataset.tabId === "#1").on.click();
await new Promise((r) => setTimeout(r, 5));
out.updated = await sheets.updateTable("T2", [[{ text: "새" }]]);
const got = await sheets.getDoc();
out.m02 = {
first: got.도면.entities.length,
more: got.더한장.map((s) => [s.이름, s.도면.entities.length]),
t2: got.더한장[0].도면.entities[0].shapeData.cells[0][0].text,
newId: got.더한장[1].장id === third,
scopes: cadLoads,
active: sheets.activeSheet(),
dirty: dirt.at(-1),
number: drawingMod.sheetNumber("SD-1", 2),
frames: urls,
};
console.log(JSON.stringify(out));
})();
"""
@pytest.fixture(scope="module")
def got():
if shutil.which("node") is None:
pytest.skip("node 가 없음")
proc = subprocess.run(
["node", "-e", SCRIPT, str(ROOT)], capture_output=True, text=True, encoding="utf-8"
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout.strip().splitlines()[-1])
def test_탭_셋_왕복_엔티티_수_그대로_고친_장만_표시(got):
# b 는 들를 때마다 하나씩 더 그림(+1 두 번) · a · c 는 그대로
assert got["roundtrip"] == [["a", 3, False], ["b", 7, True], ["c", 0, False]]
assert got["shownOrder"] == ["b", "c", "a", "b", "a"]
def test_고정_탭_앞으로는_못_끎(got):
assert got["order"] == ["a", "c", "b"]
assert got["fixedDraggable"] is False and got["fixedX"] == 1
def test_지우기는_물음_뒤_다음_탭을_실음(got):
assert got["afterRemove"] == {"ids": ["a", "b"], "active": "b", "confirms": 1}
def test_닫기_기능은_미저장_탭만_물음(got):
b07 = got["b07"]
assert b07["log"][:3] == [
["show", "d1", None, False],
["show", "d2", None, False],
["show", "d1", "편집본", True], # 받아 둔 편집본으로 돌아옴
]
assert b07["asked"] == 1 and ["closed", "d2"] in b07["log"] and b07["ids"] == ["d1"]
def test_여러_장_문서_장_도번_백업_칸(got):
m02 = got["m02"]
assert m02["first"] == 2
assert m02["more"] == [["단면", 1], ["3장", 1]]
assert m02["newId"] is True and m02["active"] == ""
assert m02["scopes"][0] == "m02:system:rv" and m02["scopes"][1].startswith("m02:system:rv#s")
assert m02["number"] == "SD-1-3"
q = "/api/m02/structure-frame?number="
assert m02["frames"] == [q + "SD-1", q + "SD-1-3"] # 장 도번마다 한 번(1장은 다시 안 받음)
assert m02["dirty"] is True # 장 더함 = 저장 안 한 고침
def test_안_실린_장_표도_갈음(got):
assert got["updated"] == 2 # 받아 둔 둘째 장 1 + 실린 장(가짜 CAD) 1
assert got["m02"]["t2"] == "새"
def test_카드는_1장_그리고_장_수():
card = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_DrawingCard.ts").read_text("utf-8")
assert "const sheets = 1 + (doc.더한장?.length ?? 0);" in card
assert "badge.textContent = `장 ${sheets}`;" in card
main = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts").read_text("utf-8")
assert '"mountStructureSheets"' in main
assert '...(edited as Pick<StructureDoc, "도면" | "더한장">),' in main
+4 -3
View File
@@ -90,10 +90,11 @@ def test_집계표만_컨테이너를_붙임():
r'at\.layer !== "project" && at\.name === STRUCTURE_TABLE\s*\? buildPanel\(at\.layer\)\s*: null',
body,
)
# 구조물 도면 저장은 `도면` 칸만 갈아 끼움 — 도번 · 산출근거는 읽은 그대로
# 도면 칸만 갈아 끼움 · 맨 위 연결표는 도면 표에 맞춤(41-3 `toDrawing.withLinks`)
# 구조물 도면 저장은 `도면` · `더한장`(82-2) 칸만 갈아 끼움 — 도번 · 산출근거는 읽은 그대로
# 맨 위 연결표는 도면 표에 맞춤(41-3 `toDrawing.withLinks`)
assert re.search(
r'toDrawing\.withLinks\(\{\s*\.\.\.\(loaded as StructureDoc\),\s*도면: edited as StructureDoc\["도면"\],\s*\}\)',
r'toDrawing\.withLinks\(\{\s*\.\.\.\(loaded as StructureDoc\),\s*'
r'\.\.\.\(edited as Pick<StructureDoc, "도면" \| "더한장">\),\s*\}\)',
body,
)
+95
View File
@@ -0,0 +1,95 @@
/* ui_template/cad_host/cad_host_tabs — CAD 를 감싸는 공용 탭 줄(82-1 · M02 구조물 도면 · B07). */
.cad-tabs {
display: flex;
flex: 0 0 auto;
align-items: flex-end;
gap: 2px;
min-width: 0;
padding: 4px 6px 0;
overflow-x: auto;
border-bottom: 1px solid var(--color-border);
background: var(--color-surface);
scrollbar-width: thin;
}
.cad-tabs__tab {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
gap: 4px;
max-width: 220px;
padding: 4px 8px;
border: 1px solid var(--color-border);
border-bottom: none;
border-radius: 6px 6px 0 0;
background: var(--color-canvas);
color: var(--color-text-secondary);
font-size: 12px;
cursor: pointer;
user-select: none;
}
.cad-tabs__tab[data-active="true"] {
background: var(--color-surface-raised);
color: var(--color-text);
font-weight: 600;
box-shadow: inset 0 2px 0 var(--color-primary);
}
.cad-tabs__tab[data-dirty="true"] .cad-tabs__label::after {
content: " ●";
color: var(--color-primary);
}
.cad-tabs__tab[data-drop="before"] {
box-shadow: inset 2px 0 0 var(--color-accent);
}
.cad-tabs__tab[data-drop="after"] {
box-shadow: inset -2px 0 0 var(--color-accent);
}
.cad-tabs__label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cad-tabs__input {
width: 110px;
padding: 0 4px;
border: 1px solid var(--color-accent);
border-radius: 4px;
background: var(--color-canvas);
color: var(--color-text);
font: inherit;
}
.cad-tabs__x,
.cad-tabs__add {
padding: 0 4px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--color-text-secondary);
font-size: 14px;
line-height: 1;
cursor: pointer;
}
.cad-tabs__x:hover,
.cad-tabs__add:hover {
background: var(--color-surface-raised);
color: var(--color-text);
}
.cad-tabs__add {
align-self: center;
margin-left: 2px;
font-size: 16px;
}
.cad-tabs[data-busy="true"] .cad-tabs__tab {
cursor: progress;
}
+330
View File
@@ -0,0 +1,330 @@
/* =============================================================================
* ui_template/cad_host/cad_host_tabs.ts
* CAD 를 감싸는 공용 탭 줄(PLAN 82-1) — 한 iframe 에 여러 장 · CAD 안 상태는 안 건드림.
*
* 탭을 바꿀 때 지금 장을 `capture`(= `cad_host.requestSave`)로 받아 그 탭에 두고 다음 장을
* `show` 로 싣는다(받아 둔 편집본이 있으면 그것). 저장 길 · 장 내용 꼴은 쓰는 페이지 몫.
* 기능(더하기 · 이름 · 지우기 · 끌어 순서 · 닫기)은 쓰는 페이지가 고른다 — M02 구조물 도면 ·
* B07 상세 설계(목록에서 연 도면 = 탭 · 닫기만).
* ========================================================================== */
import "./cad_host_tabs.css";
import { showConfirmDialog, showToast } from "@ui/ui_template_elements";
export interface CadTab<D> {
id: string;
name: string;
/** 받아 둔 편집본 — 없으면 `show` 가 페이지 자료에서 받아 옴 */
drawing?: D;
/** 저장 안 한 고침 — 받아 둘 때 페이지 미저장 표시를 옮김 */
dirty?: boolean;
/** 고정 — 끌기 · 지우기 · 닫기 · 이름 바꾸기 없음(M02 1장) */
fixed?: boolean;
/** 풍선 글(장 도번 등) */
title?: string;
}
export interface CadTabsFeatures {
add?: boolean;
rename?: boolean;
remove?: boolean;
reorder?: boolean;
close?: boolean;
}
export interface CadTabsOptions<D> {
features?: CadTabsFeatures;
/** 지금 장 편집본 받기(requestSave) · dirty = 페이지 미저장 표시 */
capture: () => Promise<{ drawing: D; dirty: boolean }>;
/** 장 싣기 — `tab.drawing` 이 있으면 받아 둔 편집본 */
show: (tab: CadTab<D>) => void | Promise<void>;
/** 떠나도 되나(도각 편집 중 등) — false 면 그대로 */
canLeave?: () => boolean;
/** [+] — 새 장 · null = 그만 */
create?: () => CadTab<D> | null | Promise<CadTab<D> | null>;
/** 장 목록이 바뀜(더하기 · 이름 · 지우기 · 순서) — 페이지 미저장 표시 */
onChange?: () => void;
/** 탭을 닫음(close) — 남은 탭이 없으면 페이지가 정함 */
onClosed?: (tab: CadTab<D>) => void;
/** 물음 글 */
text?: Partial<typeof TEXT>;
}
export interface CadTabs<D> {
element: HTMLElement;
tabs: () => CadTab<D>[];
active: () => CadTab<D> | undefined;
/** 탭 목록을 갈아 끼움(싣지 않음) */
set: (tabs: CadTab<D>[], activeId: string) => void;
/** 그 탭으로(없으면 끝에 더함) — 지금 장은 받아 둠 · 못 떠나면 false */
open: (tab: CadTab<D>) => Promise<boolean>;
select: (id: string) => Promise<boolean>;
/** 지금 장을 받아 두고 모든 탭 */
collect: () => Promise<CadTab<D>[]>;
/** 저장 뒤 — 받아 둔 편집본 · 미저장 표시를 지움(`keepDrawings` = 편집본은 둠) */
markClean: (keepDrawings?: boolean) => void;
/** 미저장 탭이 있나(지금 탭은 페이지 표시로) */
anyDirty: () => boolean;
}
const TEXT = {
add: "새 장",
rename: "두 번 눌러 이름 바꾸기",
remove: "장 지우기",
close: "닫기",
removeAsk: "「{name}」 장을 지울까요? 저장하면 되돌릴 수 없음",
removeOk: "지우기",
closeAsk: "「{name}」 에 저장 안 한 고침이 있음 — 버리고 닫을까요?",
closeOk: "버리고 닫기",
emptyName: "장 이름을 넣을 것",
sameName: "같은 이름의 장이 있음",
};
export function createCadTabs<D>(options: CadTabsOptions<D>): CadTabs<D> {
const features = options.features ?? {};
const text = { ...TEXT, ...options.text };
const element = document.createElement("div");
element.className = "cad-tabs";
element.setAttribute("role", "tablist");
let list: CadTab<D>[] = [];
let activeId = "";
let busy = false;
let dragId: string | null = null;
const active = (): CadTab<D> | undefined => list.find((tab) => tab.id === activeId);
const changed = (): void => options.onChange?.();
/** 지금 장을 받아 그 탭에 둠 */
const stash = async (): Promise<void> => {
const now = active();
if (!now) return;
const got = await options.capture();
now.drawing = got.drawing;
now.dirty = !!now.dirty || got.dirty;
};
const go = async (id: string): Promise<boolean> => {
const next = list.find((tab) => tab.id === id);
if (!next || busy) return false;
if (id === activeId) return true;
if (options.canLeave && !options.canLeave()) return false;
busy = true;
try {
await stash();
activeId = id;
render();
await options.show(next);
return true;
} catch (error) {
showToast(error instanceof Error ? error.message : String(error), "error");
return false;
} finally {
busy = false;
render();
}
};
const rename = (tab: CadTab<D>, label: HTMLElement): void => {
const input = document.createElement("input");
input.className = "cad-tabs__input";
input.value = tab.name;
input.maxLength = 40;
let done = false;
const finish = (save: boolean): void => {
if (done) return;
const name = input.value.trim();
if (save && name !== tab.name) {
if (!name) return void showToast(text.emptyName, "error");
if (list.some((other) => other !== tab && other.name === name))
return void showToast(text.sameName, "error");
tab.name = name;
changed();
}
done = true;
render();
};
input.addEventListener("keydown", (event) => {
event.stopPropagation();
if (event.isComposing) return;
if (event.key === "Enter") finish(true);
else if (event.key === "Escape") finish(false);
});
input.addEventListener("blur", () => {
finish(true);
if (!done) finish(false); // 틀린 이름으로 초점을 잃으면 옛 이름 그대로
});
label.replaceWith(input);
input.select();
};
const remove = async (tab: CadTab<D>): Promise<void> => {
if (busy) return;
const closing = !features.remove;
if (closing && tab.id === activeId) {
// 닫기 — 지금 탭의 미저장은 페이지 표시로 · 받아 둔 장은 탭에
await stash().catch(() => undefined);
}
const ask = closing
? tab.dirty && text.closeAsk.replace("{name}", tab.name)
: text.removeAsk.replace("{name}", tab.name);
if (ask && !(await showConfirmDialog(ask, closing ? text.closeOk : text.removeOk))) return;
const at = list.indexOf(tab);
list = list.filter((one) => one !== tab);
if (closing) options.onClosed?.(tab);
else changed();
if (tab.id === activeId) {
activeId = "";
const next = list[Math.min(at, list.length - 1)];
if (next) {
activeId = next.id;
render();
busy = true;
try {
await options.show(next);
} finally {
busy = false;
}
}
}
render();
};
const drop = (target: CadTab<D>, after: boolean): void => {
const moving = list.find((tab) => tab.id === dragId);
dragId = null;
if (!moving || moving === target) return;
const rest = list.filter((tab) => tab !== moving);
const at = rest.indexOf(target) + (after ? 1 : 0);
rest.splice(Math.max(at, rest.filter((tab) => tab.fixed).length), 0, moving); // 고정 탭 앞으로는 못 감
if (rest.every((tab, index) => tab === list[index])) return render();
list = rest;
changed();
render();
};
function render(): void {
element.dataset.busy = String(busy);
element.replaceChildren(
...list.map((tab) => {
const node = document.createElement("div");
node.className = "cad-tabs__tab";
node.setAttribute("role", "tab");
node.dataset.tabId = tab.id;
node.dataset.active = String(tab.id === activeId);
node.dataset.dirty = String(!!tab.dirty);
node.setAttribute("aria-selected", String(tab.id === activeId));
node.title = [tab.title, features.rename && !tab.fixed ? text.rename : ""]
.filter(Boolean)
.join(" · ");
const label = document.createElement("span");
label.className = "cad-tabs__label";
label.textContent = tab.name;
node.append(label);
node.addEventListener("click", () => void go(tab.id));
if (features.rename && !tab.fixed) {
node.addEventListener("dblclick", (event) => {
event.preventDefault();
rename(tab, label);
});
}
if ((features.remove || features.close) && !tab.fixed) {
const x = document.createElement("button");
x.type = "button";
x.className = "cad-tabs__x";
x.textContent = "×";
x.title = x.ariaLabel = features.remove ? text.remove : text.close;
x.addEventListener("click", (event) => {
event.stopPropagation();
void remove(tab);
});
node.append(x);
}
if (features.reorder && !tab.fixed) {
node.draggable = true;
node.addEventListener("dragstart", (event) => {
dragId = tab.id;
event.dataTransfer?.setData("text/plain", tab.id);
if (event.dataTransfer) event.dataTransfer.effectAllowed = "move";
});
node.addEventListener("dragend", () => (dragId = null));
}
if (features.reorder) {
node.addEventListener("dragover", (event) => {
if (!dragId) return;
event.preventDefault();
const box = node.getBoundingClientRect();
node.dataset.drop = event.clientX > box.left + box.width / 2 ? "after" : "before";
});
node.addEventListener("dragleave", () => delete node.dataset.drop);
node.addEventListener("drop", (event) => {
event.preventDefault();
const after = node.dataset.drop === "after";
delete node.dataset.drop;
drop(tab, after);
});
}
return node;
}),
);
if (features.add) {
const plus = document.createElement("button");
plus.type = "button";
plus.className = "cad-tabs__add";
plus.textContent = "+";
plus.title = plus.ariaLabel = text.add;
plus.disabled = busy;
plus.addEventListener("click", async () => {
if (busy) return;
const made = await options.create?.();
if (!made) return;
list = [...list, made];
changed();
await go(made.id);
});
element.append(plus);
}
}
return {
element,
tabs: () => list,
active,
set: (tabs, id) => {
list = [...tabs];
activeId = id;
render();
},
open: async (tab) => {
if (!list.some((one) => one.id === tab.id)) {
if (!activeId) {
// 첫 탭 — 받아 둘 지금 장이 없음
list = [...list, tab];
activeId = tab.id;
render();
await options.show(tab);
return true;
}
if (options.canLeave && !options.canLeave()) return false;
list = [...list, tab];
if (await go(tab.id)) return true;
list = list.filter((one) => one !== tab); // 못 옮겨 감 — 더한 탭도 뺌
render();
return false;
}
return go(tab.id);
},
select: go,
collect: async () => {
await stash();
return list;
},
markClean: (keepDrawings = false) => {
for (const tab of list) {
tab.dirty = false;
if (!keepDrawings) delete tab.drawing;
}
render();
},
anyDirty: () => list.some((tab) => tab.dirty),
};
}