feat(B07): 목록에서 연 도면 = 탭 — 열기 · 닫기 · 전환 · 이전/다음 · 탭 전환 때 미저장 편집 지킴(82-4)

- 공용 탭 줄: 마지막 한 탭 닫기 없음 · 지금 탭 미저장 점 · 탭 하나만 저장 표시 지움

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:12:58 +09:00
co-authored by Claude Opus 5.5
parent b15a945bf8
commit 4e18bca4e4
5 changed files with 82 additions and 18 deletions
+40 -11
View File
@@ -52,6 +52,7 @@ import { appendStructureEntities } from "./B07_DesignDetail_UI_Cad_Structures";
// CAD iframe 부모 흐름 · 도각 편집은 M02 와 한 벌로 공용에 둔다(PLAN 10-3). // CAD iframe 부모 흐름 · 도각 편집은 M02 와 한 벌로 공용에 둔다(PLAN 10-3).
import { createCadHost, type CadSaveResult } from "@ui/cad_host/cad_host"; import { createCadHost, type CadSaveResult } from "@ui/cad_host/cad_host";
import { createFrameTemplateEditor } from "@ui/cad_host/cad_host_frame_edit"; import { createFrameTemplateEditor } from "@ui/cad_host/cad_host_frame_edit";
import { createCadTabs, type CadTab } from "@ui/cad_host/cad_host_tabs";
/** CAD 앱 수량 패널로 넘기는 설계 컨텍스트 (openwebcad DesignMeta와 동일 형식). */ /** CAD 앱 수량 패널로 넘기는 설계 컨텍스트 (openwebcad DesignMeta와 동일 형식). */
interface DesignMeta { interface DesignMeta {
@@ -109,7 +110,9 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
// (2026-09-01 사용자 확정) — 예전에는 이 통지가 확정을 풀어, 되돌리기나 색 // (2026-09-01 사용자 확정) — 예전에는 이 통지가 확정을 풀어, 되돌리기나 색
// 고르기 같은 곁가지 동작에도 확정이 조용히 날아갔다. // 고르기 같은 곁가지 동작에도 확정이 조용히 날아갔다.
onChanged: (dirty) => { onChanged: (dirty) => {
if (!frameEditor.isEditing()) cadDirty = dirty; if (frameEditor.isEditing()) return;
cadDirty = dirty;
if (dirty) tabs.markDirty();
}, },
onNavigate: (direction) => navigateDrawing(direction), onNavigate: (direction) => navigateDrawing(direction),
onExport: (fileFormat) => void exportDrawingFile(fileFormat), onExport: (fileFormat) => void exportDrawingFile(fileFormat),
@@ -248,7 +251,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
/** 저장하지 않은 편집이 있으면 묻는다. 버리기로 해야 이동한다. */ /** 저장하지 않은 편집이 있으면 묻는다. 버리기로 해야 이동한다. */
const mayDiscardEdits = (): boolean => { const mayDiscardEdits = (): boolean => {
if (!cadDirty) return true; if (!cadDirty && !tabs.anyDirty()) return true;
return window.confirm( return window.confirm(
"저장하지 않은 편집이 있습니다.\n" + "저장하지 않은 편집이 있습니다.\n" +
"지금 도면을 바꾸면 편집이 사라집니다. 버리고 이동할까요?\n\n" + "지금 도면을 바꾸면 편집이 사라집니다. 버리고 이동할까요?\n\n" +
@@ -259,7 +262,8 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
/** 도면 전환 중인가 — 겹쳐 누르면 늦게 온 응답이 화면을 덮는다. */ /** 도면 전환 중인가 — 겹쳐 누르면 늦게 온 응답이 화면을 덮는다. */
let loadInFlight = false; 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; if (!projectId || loadInFlight) return;
loadInFlight = true; loadInFlight = true;
highlightActive(drawing.id); highlightActive(drawing.id);
@@ -271,10 +275,10 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
currentDrawing = drawing; currentDrawing = drawing;
currentIndex = index; currentIndex = index;
currentConfirmed = response.confirmed; currentConfirmed = response.confirmed;
cadDirty = false; // 새 도면을 실었다 — 미저장 편집은 이 도면 것이 아니다 cadDirty = !!stash; // 새 도면을 실었다 — 미저장은 받아 둔 편집본일 때만
applyConfirmButtonState(); applyConfirmButtonState();
updateInfoPanel(drawing, response); 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) { if (response.drawing.stale_layout) {
showToast( showToast(
"옛 배치로 확정된 장입니다. [수정]을 누르면 박스를 그림에 1 mm로 붙인 새 배치로 다시 만듭니다.", "옛 배치로 확정된 장입니다. [수정]을 누르면 박스를 그림에 1 mm로 붙인 새 배치로 다시 만듭니다.",
@@ -290,17 +294,37 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
} }
}; };
/**
* 목록에서 연 도면 = 탭(82-4) — 탭을 바꿀 때 지금 도면을 받아 두고(저장 안 한 편집 그대로) 다음 장을 싣는다.
* 새 장 · 지우기는 없음(서버 길 없음) · 닫기는 미저장 탭만 물음 · 마지막 한 탭은 못 닫음.
*/
const tabs = createCadTabs<CadDrawing>({
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<CadDrawing>) => {
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<boolean> =>
tabs.open({ id: drawing.id, name: drawing.label, title: drawing.label });
const selectDrawing = (drawing: DesignDrawingItem) => { const selectDrawing = (drawing: DesignDrawingItem) => {
if (drawing === currentDrawing || !mayDiscardEdits()) return; if (drawing === currentDrawing) return;
void loadDrawing(drawing, drawings.indexOf(drawing)); void openTab(drawing);
}; };
const navigateDrawing = (direction: "prev" | "next") => { const navigateDrawing = (direction: "prev" | "next") => {
if (currentIndex < 0) return; if (currentIndex < 0) return;
const target = direction === "prev" ? currentIndex - 1 : currentIndex + 1; const target = direction === "prev" ? currentIndex - 1 : currentIndex + 1;
if (target < 0 || target >= drawings.length) return; if (target < 0 || target >= drawings.length) return;
if (!mayDiscardEdits()) return; void openTab(drawings[target]);
void loadDrawing(drawings[target], target);
}; };
const requestCadDrawing = (): Promise<SaveResult> => cad.requestSave(); const requestCadDrawing = (): Promise<SaveResult> => cad.requestSave();
@@ -319,6 +343,8 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
currentConfirmed = true; currentConfirmed = true;
currentDrawing.confirmed = true; currentDrawing.confirmed = true;
cadDirty = false; // 저장했다 cadDirty = false; // 저장했다
const savedTab = tabs.active();
if (savedTab) tabs.markClean(savedTab); // 받아 둔 편집본은 저장됨
applyConfirmButtonState(); applyConfirmButtonState();
drawingCache.delete(currentDrawing.id); // 확정본은 서버 저장분이 정본이다. drawingCache.delete(currentDrawing.id); // 확정본은 서버 저장분이 정본이다.
@@ -462,12 +488,15 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
drawingPanel.append(infoPanelHost, confirmActions); drawingPanel.append(infoPanelHost, confirmActions);
const cadStack = document.createElement("div");
cadStack.className = "b07-cad-stack";
cadStack.append(tabs.element, cad.element);
const layout = createWorkflowLayout({ const layout = createWorkflowLayout({
title: L("B07_Design_Title"), title: L("B07_Design_Title"),
steps: workflowSteps(), steps: workflowSteps(),
activeStep: 4, activeStep: 4,
leftPanel: drawingPanel, leftPanel: drawingPanel,
mainContent: cad.element, mainContent: cadStack,
stages: workflowState?.stages, stages: workflowState?.stages,
currentStage: workflowState?.current_stage, currentStage: workflowState?.current_stage,
routes: WORKFLOW_STEP_ROUTES, routes: WORKFLOW_STEP_ROUTES,
@@ -492,7 +521,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
const reopen = drawings.findIndex((item) => item.id === reopenDrawingId); const reopen = drawings.findIndex((item) => item.id === reopenDrawingId);
reopenDrawingId = undefined; reopenDrawingId = undefined;
const first = Math.max(reopen, 0); const first = Math.max(reopen, 0);
void loadDrawing(drawings[first], first).then(() => { void openTab(drawings[first]).then(() => {
// 첫 장을 띄운 뒤에 나머지를 받는다 — 진입 속도를 뺏지 않는다. // 첫 장을 띄운 뒤에 나머지를 받는다 — 진입 속도를 뺏지 않는다.
void prefetchAllDrawings(); void prefetchAllDrawings();
}); });
@@ -328,3 +328,16 @@
.b07-plan-tools__remove { .b07-plan-tools__remove {
padding: 0 var(--spacing-8); 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;
}
@@ -474,6 +474,7 @@ export function mountStructureSheets(
tabs: tabs.element, tabs: tabs.element,
onChanged: (dirty) => { onChanged: (dirty) => {
cadDirty = dirty; cadDirty = dirty;
if (dirty && !readOnly) tabs.markDirty();
report(); report();
}, },
}); });
@@ -490,7 +491,7 @@ export function mountStructureSheets(
도면: tab.drawing ?? { format: 6, entities: [], layers: [{ ...BLANK_LAYER }] }, 도면: tab.drawing ?? { format: 6, entities: [], layers: [{ ...BLANK_LAYER }] },
})); }));
listDirty = false; listDirty = false;
tabs.markClean(true); tabs.markClean(undefined, true);
return { return {
도면: first?.drawing ?? doc.도면, 도면: first?.drawing ?? doc.도면,
...(rest.length || doc.더한장 ? { 더한장: rest } : {}), ...(rest.length || doc.더한장 ? { 더한장: rest } : {}),
+13 -1
View File
@@ -98,7 +98,8 @@ const tabsMod = load("ui_template/cad_host/cad_host_tabs.ts", { "@ui/ui_template
const before = confirms.length; const before = confirms.length;
const d2 = closer.element.kids.find((k) => k.dataset.tabId === "d2"); const d2 = closer.element.kids.find((k) => k.dataset.tabId === "d2");
await d2.kids.find((k) => k.className === "cad-tabs__x").on.click({ stopPropagation() {} }); 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 = 지금 실린 도면 + 편집) // ⑤ M02 여러 장 — 가짜 CAD(requestSave = 지금 실린 도면 + 편집)
const cadLoads = []; const cadLoads = [];
@@ -193,6 +194,17 @@ def test_닫기_기능은_미저장_탭만_물음(got):
["show", "d1", "편집본", True], # 받아 둔 편집본으로 돌아옴 ["show", "d1", "편집본", True], # 받아 둔 편집본으로 돌아옴
] ]
assert b07["asked"] == 1 and ["closed", "d2"] in b07["log"] and b07["ids"] == ["d1"] 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): def test_여러_장_문서_장_도번_백업_칸(got):
+14 -5
View File
@@ -61,8 +61,10 @@ export interface CadTabs<D> {
select: (id: string) => Promise<boolean>; select: (id: string) => Promise<boolean>;
/** 지금 장을 받아 두고 모든 탭 */ /** 지금 장을 받아 두고 모든 탭 */
collect: () => Promise<CadTab<D>[]>; collect: () => Promise<CadTab<D>[]>;
/** 저장 뒤 — 받아 둔 편집본 · 미저장 표시를 지움(`keepDrawings` = 편집본은 둠) */ /** 저장 뒤 — 받아 둔 편집본 · 미저장 표시를 지움(`tab` 없으면 모두 · `keepDrawings` = 편집본은 둠) */
markClean: (keepDrawings?: boolean) => void; markClean: (tab?: CadTab<D>, keepDrawings?: boolean) => void;
/** 지금 탭에 미저장 점(●) — 페이지 편집 통지(참만 · 거짓은 저장 뒤 `markClean`) */
markDirty: () => void;
/** 미저장 탭이 있나(지금 탭은 페이지 표시로) */ /** 미저장 탭이 있나(지금 탭은 페이지 표시로) */
anyDirty: () => boolean; anyDirty: () => boolean;
} }
@@ -227,7 +229,8 @@ export function createCadTabs<D>(options: CadTabsOptions<D>): CadTabs<D> {
rename(tab, label); 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"); const x = document.createElement("button");
x.type = "button"; x.type = "button";
x.className = "cad-tabs__x"; x.className = "cad-tabs__x";
@@ -318,13 +321,19 @@ export function createCadTabs<D>(options: CadTabsOptions<D>): CadTabs<D> {
await stash(); await stash();
return list; return list;
}, },
markClean: (keepDrawings = false) => { markClean: (only, keepDrawings = false) => {
for (const tab of list) { for (const tab of only ? [only] : list) {
tab.dirty = false; tab.dirty = false;
if (!keepDrawings) delete tab.drawing; if (!keepDrawings) delete tab.drawing;
} }
render(); render();
}, },
markDirty: () => {
const now = active();
if (!now || now.dirty) return;
now.dirty = true;
render();
},
anyDirty: () => list.some((tab) => tab.dirty), anyDirty: () => list.some((tab) => tab.dirty),
}; };
} }