From caf38ccb3e137f6c8130e538d81a109713d37832 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 12:10:49 +0900 Subject: [PATCH] =?UTF-8?q?feat(M02):=20CAD=20=EA=B3=B5=EC=9A=A9=20?= =?UTF-8?q?=ED=83=AD=20=EC=A4=84=20=C2=B7=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20?= =?UTF-8?q?=EB=8F=84=EB=A9=B4=20=EC=83=81=EC=84=B8=20=EC=97=AC=EB=9F=AC=20?= =?UTF-8?q?=EC=9E=A5=20=ED=83=AD=20=C2=B7=20=EC=B9=B4=EB=93=9C=20=E3=80=8C?= =?UTF-8?q?=EC=9E=A5=20n=E3=80=8D(82-1=20=C2=B7=2082-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ui_template/cad_host/cad_host_tabs — 고르기 · [+] · 이름 · 지우기 · 끌어 순서 · 닫기(켬/끔) · 바꿀 때 지금 장 받아 두고 싣기 - mountStructureSheets — 1장 고정 + 더한장 · 백업 칸 장마다 · 장 도번 도각 · 안 실린 장 표 갈기 - 상세 열기 · 저장 = 도면 · 더한장 칸 · 카드 1장 + 장 수 · 도각 도번 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK --- .../M02_MasterTemplete_Drawing.css | 24 ++ .../M02_MasterTemplete_Drawing.ts | 214 +++++++++++- .../M02_MasterTemplete_DrawingCard.ts | 10 + .../M02_MasterTemplete_UI_Main.ts | 11 +- resources/tester/test_cad_host_tabs.py | 221 ++++++++++++ resources/tester/test_m02_structure_panel.py | 7 +- ui_template/cad_host/cad_host_tabs.css | 95 +++++ ui_template/cad_host/cad_host_tabs.ts | 330 ++++++++++++++++++ 8 files changed, 885 insertions(+), 27 deletions(-) create mode 100644 resources/tester/test_cad_host_tabs.py create mode 100644 ui_template/cad_host/cad_host_tabs.css create mode 100644 ui_template/cad_host/cad_host_tabs.ts diff --git a/M02_MasterTemplete/M02_MasterTemplete_Drawing.css b/M02_MasterTemplete/M02_MasterTemplete_Drawing.css index 4a9153a91..488d56cee 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Drawing.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Drawing.css @@ -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)); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts b/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts index fe5fabb1a..26d2af925 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts @@ -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, link: string) => void; updateTable: (link: string, cells: unknown[][]) => Promise; + /** 다른 장을 같은 CAD 에 실음(82-1) — 백업 칸 · 도각 도번도 그 장 것으로 */ + loadSheet: (drawing: DrawingTemplateDoc, sheet: string, frameNumber?: string) => void; destroy: () => void; } @@ -98,18 +107,22 @@ interface StructureFrame { layer: Record | null; } const BLANK_LAYER = { id: "기본", name: "기본", isVisible: true, isLocked: false }; -let framePromise: Promise | null = null; +const framePromises = new Map>(); -/** 구조물 도각 — 한 번만 받아 돌려씀 · 못 받으면 도각 없이 */ -function fetchFrame(): Promise { - framePromise ??= fetch(`${API_BASE_URL}/m02/structure-frame`, { credentials: "include" }) - .then((response) => (response.ok ? (response.json() as Promise) : null)) - .catch(() => null) - .then((frame) => { - if (!frame?.layer) framePromise = null; // 다음에 다시 받음 - return frame?.layer ? frame : null; - }); - return framePromise; +/** 구조물 도각 — 장 도번마다 한 번만 받아 돌려씀(`{{도면번호}}` · 82-2) · 못 받으면 도각 없이 */ +function fetchFrame(number = ""): Promise { + 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) : 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 => { 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 }).shapeData; + const style = shape?.style as Record | undefined; + if (entity.type !== "Table" || !shape || style?.연결 !== link) continue; + shape.cells = cells; + count += 1; + } + return count; +} + +export interface StructureSheetsHandle extends Omit { + /** `{도면, 더한장?}` — 부르는 쪽이 읽은 문서에 얹음 */ + 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 = createCadTabs({ + 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 => { + 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, + }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_DrawingCard.ts b/M02_MasterTemplete/M02_MasterTemplete_DrawingCard.ts index 43d010d1e..cf2d79135 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_DrawingCard.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_DrawingCard.ts @@ -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; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index 8bdf22c42..d5ce4ae27 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -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), }) : at.kind === "area" && loaded ? { ...(loaded as Record), ...(edited as Record) } diff --git a/resources/tester/test_cad_host_tabs.py b/resources/tester/test_cad_host_tabs.py new file mode 100644 index 000000000..96b6c78bc --- /dev/null +++ b/resources/tester/test_cad_host_tabs.py @@ -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),' in main diff --git a/resources/tester/test_m02_structure_panel.py b/resources/tester/test_m02_structure_panel.py index f2771090c..05880fc67 100644 --- a/resources/tester/test_m02_structure_panel.py +++ b/resources/tester/test_m02_structure_panel.py @@ -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\),\s*\}\)', body, ) diff --git a/ui_template/cad_host/cad_host_tabs.css b/ui_template/cad_host/cad_host_tabs.css new file mode 100644 index 000000000..abc753155 --- /dev/null +++ b/ui_template/cad_host/cad_host_tabs.css @@ -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; +} diff --git a/ui_template/cad_host/cad_host_tabs.ts b/ui_template/cad_host/cad_host_tabs.ts new file mode 100644 index 000000000..960b2f6f5 --- /dev/null +++ b/ui_template/cad_host/cad_host_tabs.ts @@ -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 { + 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 { + features?: CadTabsFeatures; + /** 지금 장 편집본 받기(requestSave) · dirty = 페이지 미저장 표시 */ + capture: () => Promise<{ drawing: D; dirty: boolean }>; + /** 장 싣기 — `tab.drawing` 이 있으면 받아 둔 편집본 */ + show: (tab: CadTab) => void | Promise; + /** 떠나도 되나(도각 편집 중 등) — false 면 그대로 */ + canLeave?: () => boolean; + /** [+] — 새 장 · null = 그만 */ + create?: () => CadTab | null | Promise | null>; + /** 장 목록이 바뀜(더하기 · 이름 · 지우기 · 순서) — 페이지 미저장 표시 */ + onChange?: () => void; + /** 탭을 닫음(close) — 남은 탭이 없으면 페이지가 정함 */ + onClosed?: (tab: CadTab) => void; + /** 물음 글 */ + text?: Partial; +} + +export interface CadTabs { + element: HTMLElement; + tabs: () => CadTab[]; + active: () => CadTab | undefined; + /** 탭 목록을 갈아 끼움(싣지 않음) */ + set: (tabs: CadTab[], activeId: string) => void; + /** 그 탭으로(없으면 끝에 더함) — 지금 장은 받아 둠 · 못 떠나면 false */ + open: (tab: CadTab) => Promise; + select: (id: string) => Promise; + /** 지금 장을 받아 두고 모든 탭 */ + collect: () => Promise[]>; + /** 저장 뒤 — 받아 둔 편집본 · 미저장 표시를 지움(`keepDrawings` = 편집본은 둠) */ + markClean: (keepDrawings?: boolean) => void; + /** 미저장 탭이 있나(지금 탭은 페이지 표시로) */ + anyDirty: () => boolean; +} + +const TEXT = { + add: "새 장", + rename: "두 번 눌러 이름 바꾸기", + remove: "장 지우기", + close: "닫기", + removeAsk: "「{name}」 장을 지울까요? 저장하면 되돌릴 수 없음", + removeOk: "지우기", + closeAsk: "「{name}」 에 저장 안 한 고침이 있음 — 버리고 닫을까요?", + closeOk: "버리고 닫기", + emptyName: "장 이름을 넣을 것", + sameName: "같은 이름의 장이 있음", +}; + +export function createCadTabs(options: CadTabsOptions): CadTabs { + 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[] = []; + let activeId = ""; + let busy = false; + let dragId: string | null = null; + + const active = (): CadTab | undefined => list.find((tab) => tab.id === activeId); + const changed = (): void => options.onChange?.(); + + /** 지금 장을 받아 그 탭에 둠 */ + const stash = async (): Promise => { + 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 => { + 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, 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): Promise => { + 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, 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), + }; +}