From 4e18bca4e4daf926b1856bb7a67ef2971aa8e80e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 12:12:58 +0900 Subject: [PATCH] =?UTF-8?q?feat(B07):=20=EB=AA=A9=EB=A1=9D=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=97=B0=20=EB=8F=84=EB=A9=B4=20=3D=20=ED=83=AD=20?= =?UTF-8?q?=E2=80=94=20=EC=97=B4=EA=B8=B0=20=C2=B7=20=EB=8B=AB=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20=EC=A0=84=ED=99=98=20=C2=B7=20=EC=9D=B4=EC=A0=84/?= =?UTF-8?q?=EB=8B=A4=EC=9D=8C=20=C2=B7=20=ED=83=AD=20=EC=A0=84=ED=99=98=20?= =?UTF-8?q?=EB=95=8C=20=EB=AF=B8=EC=A0=80=EC=9E=A5=20=ED=8E=B8=EC=A7=91=20?= =?UTF-8?q?=EC=A7=80=ED=82=B4(82-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 공용 탭 줄: 마지막 한 탭 닫기 없음 · 지금 탭 미저장 점 · 탭 하나만 저장 표시 지움 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK --- B07_DesignDetail/B07_DesignDetail_UI_Page.ts | 51 +++++++++++++++---- .../B07_DesignDetail_UI_Style.css | 13 +++++ .../M02_MasterTemplete_Drawing.ts | 3 +- resources/tester/test_cad_host_tabs.py | 14 ++++- ui_template/cad_host/cad_host_tabs.ts | 19 +++++-- 5 files changed, 82 insertions(+), 18 deletions(-) diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts index 3be48a4e3..f53756663 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts @@ -52,6 +52,7 @@ import { appendStructureEntities } from "./B07_DesignDetail_UI_Cad_Structures"; // 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"; +import { createCadTabs, type CadTab } from "@ui/cad_host/cad_host_tabs"; /** CAD 앱 수량 패널로 넘기는 설계 컨텍스트 (openwebcad DesignMeta와 동일 형식). */ interface DesignMeta { @@ -109,7 +110,9 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { // (2026-09-01 사용자 확정) — 예전에는 이 통지가 확정을 풀어, 되돌리기나 색 // 고르기 같은 곁가지 동작에도 확정이 조용히 날아갔다. onChanged: (dirty) => { - if (!frameEditor.isEditing()) cadDirty = dirty; + if (frameEditor.isEditing()) return; + cadDirty = dirty; + if (dirty) tabs.markDirty(); }, onNavigate: (direction) => navigateDrawing(direction), onExport: (fileFormat) => void exportDrawingFile(fileFormat), @@ -248,7 +251,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { /** 저장하지 않은 편집이 있으면 묻는다. 버리기로 해야 이동한다. */ const mayDiscardEdits = (): boolean => { - if (!cadDirty) return true; + if (!cadDirty && !tabs.anyDirty()) return true; return window.confirm( "저장하지 않은 편집이 있습니다.\n" + "지금 도면을 바꾸면 편집이 사라집니다. 버리고 이동할까요?\n\n" + @@ -259,7 +262,8 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { /** 도면 전환 중인가 — 겹쳐 누르면 늦게 온 응답이 화면을 덮는다. */ let loadInFlight = false; - const loadDrawing = async (drawing: DesignDrawingItem, index: number) => { + /** `stash` = 탭에 받아 둔 저장 안 한 편집본(82-4) — 있으면 그것을 싣고 미저장 그대로 */ + const loadDrawing = async (drawing: DesignDrawingItem, index: number, stash?: CadDrawing) => { if (!projectId || loadInFlight) return; loadInFlight = true; highlightActive(drawing.id); @@ -271,10 +275,10 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { currentDrawing = drawing; currentIndex = index; currentConfirmed = response.confirmed; - cadDirty = false; // 새 도면을 실었다 — 미저장 편집은 이 도면 것이 아니다 + cadDirty = !!stash; // 새 도면을 실었다 — 미저장은 받아 둔 편집본일 때만 applyConfirmButtonState(); updateInfoPanel(drawing, response); - cad.load(response.drawing, buildMeta(drawing, response, index)); + cad.load(stash ?? response.drawing, buildMeta(drawing, response, index)); if (response.drawing.stale_layout) { showToast( "옛 배치로 확정된 장입니다. [수정]을 누르면 박스를 그림에 1 mm로 붙인 새 배치로 다시 만듭니다.", @@ -290,17 +294,37 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { } }; + /** + * 목록에서 연 도면 = 탭(82-4) — 탭을 바꿀 때 지금 도면을 받아 두고(저장 안 한 편집 그대로) 다음 장을 싣는다. + * 새 장 · 지우기는 없음(서버 길 없음) · 닫기는 미저장 탭만 물음 · 마지막 한 탭은 못 닫음. + */ + const tabs = createCadTabs({ + features: { close: true }, + capture: async () => ({ drawing: (await requestCadDrawing()).drawing, dirty: cadDirty }), + canLeave: () => { + if (!frameEditor.isEditing()) return !loadInFlight; + showToast("도각 편집을 마친 뒤 다른 도면으로 옮기세요.", "warning"); + return false; + }, + show: async (tab: CadTab) => { + const index = drawings.findIndex((item) => item.id === tab.id); + if (index >= 0) + await loadDrawing(drawings[index], index, tab.dirty ? tab.drawing : undefined); + }, + }); + const openTab = (drawing: DesignDrawingItem): Promise => + tabs.open({ id: drawing.id, name: drawing.label, title: drawing.label }); + const selectDrawing = (drawing: DesignDrawingItem) => { - if (drawing === currentDrawing || !mayDiscardEdits()) return; - void loadDrawing(drawing, drawings.indexOf(drawing)); + if (drawing === currentDrawing) return; + void openTab(drawing); }; const navigateDrawing = (direction: "prev" | "next") => { if (currentIndex < 0) return; const target = direction === "prev" ? currentIndex - 1 : currentIndex + 1; if (target < 0 || target >= drawings.length) return; - if (!mayDiscardEdits()) return; - void loadDrawing(drawings[target], target); + void openTab(drawings[target]); }; const requestCadDrawing = (): Promise => cad.requestSave(); @@ -319,6 +343,8 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { currentConfirmed = true; currentDrawing.confirmed = true; cadDirty = false; // 저장했다 + const savedTab = tabs.active(); + if (savedTab) tabs.markClean(savedTab); // 받아 둔 편집본은 저장됨 applyConfirmButtonState(); drawingCache.delete(currentDrawing.id); // 확정본은 서버 저장분이 정본이다. @@ -462,12 +488,15 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { drawingPanel.append(infoPanelHost, confirmActions); + const cadStack = document.createElement("div"); + cadStack.className = "b07-cad-stack"; + cadStack.append(tabs.element, cad.element); const layout = createWorkflowLayout({ title: L("B07_Design_Title"), steps: workflowSteps(), activeStep: 4, leftPanel: drawingPanel, - mainContent: cad.element, + mainContent: cadStack, stages: workflowState?.stages, currentStage: workflowState?.current_stage, routes: WORKFLOW_STEP_ROUTES, @@ -492,7 +521,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { const reopen = drawings.findIndex((item) => item.id === reopenDrawingId); reopenDrawingId = undefined; const first = Math.max(reopen, 0); - void loadDrawing(drawings[first], first).then(() => { + void openTab(drawings[first]).then(() => { // 첫 장을 띄운 뒤에 나머지를 받는다 — 진입 속도를 뺏지 않는다. void prefetchAllDrawings(); }); diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Style.css b/B07_DesignDetail/B07_DesignDetail_UI_Style.css index d098787a4..20dd06b03 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Style.css +++ b/B07_DesignDetail/B07_DesignDetail_UI_Style.css @@ -328,3 +328,16 @@ .b07-plan-tools__remove { padding: 0 var(--spacing-8); } + +/* 목록에서 연 도면 탭 줄 + CAD(82-4) */ +.b07-cad-stack { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} + +.b07-cad-stack > .cad-host { + flex: 1 1 0; + height: auto; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts b/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts index 26d2af925..c245f122c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts @@ -474,6 +474,7 @@ export function mountStructureSheets( tabs: tabs.element, onChanged: (dirty) => { cadDirty = dirty; + if (dirty && !readOnly) tabs.markDirty(); report(); }, }); @@ -490,7 +491,7 @@ export function mountStructureSheets( 도면: tab.drawing ?? { format: 6, entities: [], layers: [{ ...BLANK_LAYER }] }, })); listDirty = false; - tabs.markClean(true); + tabs.markClean(undefined, true); return { 도면: first?.drawing ?? doc.도면, ...(rest.length || doc.더한장 ? { 더한장: rest } : {}), diff --git a/resources/tester/test_cad_host_tabs.py b/resources/tester/test_cad_host_tabs.py index 96b6c78bc..e9ee764b8 100644 --- a/resources/tester/test_cad_host_tabs.py +++ b/resources/tester/test_cad_host_tabs.py @@ -98,7 +98,8 @@ const tabsMod = load("ui_template/cad_host/cad_host_tabs.ts", { "@ui/ui_template 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) }; + const last = closer.element.kids.find((k) => k.dataset.tabId === "d1").kids.length; // 마지막 탭 = 닫기 없음 + out.b07 = { log: b07, asked: confirms.length - before, ids: closer.tabs().map((t) => t.id), last }; // ⑤ M02 여러 장 — 가짜 CAD(requestSave = 지금 실린 도면 + 편집) const cadLoads = []; @@ -193,6 +194,17 @@ def test_닫기_기능은_미저장_탭만_물음(got): ["show", "d1", "편집본", True], # 받아 둔 편집본으로 돌아옴 ] assert b07["asked"] == 1 and ["closed", "d2"] in b07["log"] and b07["ids"] == ["d1"] + assert b07["last"] == 1 + + +def test_B07_목록에서_연_도면이_탭_미저장_편집_지킴(): + page = (ROOT / "B07_DesignDetail/B07_DesignDetail_UI_Page.ts").read_text("utf-8") + assert "features: { close: true }," in page # [+] · 지우기 없음(서버 길 없음) + assert "dirty: cadDirty }" in page # 떠날 때 받아 둠 + assert "tab.dirty ? tab.drawing : undefined" in page # 돌아오면 받아 둔 편집본 + assert "cadDirty = !!stash;" in page and "cad.load(stash ?? response.drawing" in page + assert "if (!cadDirty && !tabs.anyDirty()) return true;" in page + assert "void openTab(drawings[target]);" in page # 이전 · 다음 = 그 도면 탭 def test_여러_장_문서_장_도번_백업_칸(got): diff --git a/ui_template/cad_host/cad_host_tabs.ts b/ui_template/cad_host/cad_host_tabs.ts index 960b2f6f5..5e9ef481f 100644 --- a/ui_template/cad_host/cad_host_tabs.ts +++ b/ui_template/cad_host/cad_host_tabs.ts @@ -61,8 +61,10 @@ export interface CadTabs { select: (id: string) => Promise; /** 지금 장을 받아 두고 모든 탭 */ collect: () => Promise[]>; - /** 저장 뒤 — 받아 둔 편집본 · 미저장 표시를 지움(`keepDrawings` = 편집본은 둠) */ - markClean: (keepDrawings?: boolean) => void; + /** 저장 뒤 — 받아 둔 편집본 · 미저장 표시를 지움(`tab` 없으면 모두 · `keepDrawings` = 편집본은 둠) */ + markClean: (tab?: CadTab, keepDrawings?: boolean) => void; + /** 지금 탭에 미저장 점(●) — 페이지 편집 통지(참만 · 거짓은 저장 뒤 `markClean`) */ + markDirty: () => void; /** 미저장 탭이 있나(지금 탭은 페이지 표시로) */ anyDirty: () => boolean; } @@ -227,7 +229,8 @@ export function createCadTabs(options: CadTabsOptions): CadTabs { rename(tab, label); }); } - if ((features.remove || features.close) && !tab.fixed) { + // 닫기는 마지막 한 탭이면 없음(볼 장이 하나는 남음) + if ((features.remove || (features.close && list.length > 1)) && !tab.fixed) { const x = document.createElement("button"); x.type = "button"; x.className = "cad-tabs__x"; @@ -318,13 +321,19 @@ export function createCadTabs(options: CadTabsOptions): CadTabs { await stash(); return list; }, - markClean: (keepDrawings = false) => { - for (const tab of list) { + markClean: (only, keepDrawings = false) => { + for (const tab of only ? [only] : list) { tab.dirty = false; if (!keepDrawings) delete tab.drawing; } render(); }, + markDirty: () => { + const now = active(); + if (!now || now.dirty) return; + now.dirty = true; + render(); + }, anyDirty: () => list.some((tab) => tab.dirty), }; }