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:
@@ -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 } : {}),
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user