Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1
This commit is contained in:
@@ -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<void> {
|
||||
// (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<void> {
|
||||
|
||||
/** 저장하지 않은 편집이 있으면 묻는다. 버리기로 해야 이동한다. */
|
||||
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<void> {
|
||||
/** 도면 전환 중인가 — 겹쳐 누르면 늦게 온 응답이 화면을 덮는다. */
|
||||
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<void> {
|
||||
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<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) => {
|
||||
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<SaveResult> => cad.requestSave();
|
||||
@@ -319,6 +343,8 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
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<void> {
|
||||
|
||||
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<void> {
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
* 표 style.연결 = 연결 · 도면에서 지운 표는 기록에서 뺌(면적 [저장] 때 서버가 칸 글 · 값을 다시 적음 · 41-1)
|
||||
* ③ 도면을 열 때 — 연결표마다 지금 면적 값과 마지막 값이 다르면 알림 한 줄 + [업데이트](`updateTable`)
|
||||
* ④ 표 여러 개 — id 마다 한 줄
|
||||
* ⑤ 여러 장(82-3) — 놓을 장 = 지금 탭 · 새 T번호 = 모든 장에서 안 쓴 것 · 연결표 줄 `장` = 표가 놓인 장id
|
||||
* (1장 = 빈 값) · 안 실린 장 표 갱신 = 도면 손잡이(받아 둔 장) · 면적 [저장] 때는 서버가 모든 장
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
@@ -108,6 +110,12 @@ async function fetchTable(layer: Layer, column: string, spec: LinkSpec): Promise
|
||||
return data as AreaTable;
|
||||
}
|
||||
|
||||
/** 장마다 `[장id, entities]` — 1장 = 빈 값(서버 `drawing_sheets` 와 같음) */
|
||||
const sheetsOf = (doc: StructureDoc): [string, unknown[]][] => [
|
||||
["", doc.도면?.entities ?? []],
|
||||
...(doc.더한장 ?? []).map((s): [string, unknown[]] => [s.장id, s.도면?.entities ?? []]),
|
||||
];
|
||||
|
||||
const isTable = (e: unknown): e is TableEntityJson =>
|
||||
!!e && typeof e === "object" && (e as { type?: unknown }).type === "Table";
|
||||
const linkOf = (e: TableEntityJson): string | null => {
|
||||
@@ -241,7 +249,8 @@ export function createAreaToDrawing(opt: AreaToDrawingOptions): AreaToDrawing {
|
||||
/** 다음 연결 id — 기록 · 도면 표지에 없는 가장 작은 T번호 */
|
||||
const nextId = (doc: StructureDoc, taken: Set<string>): string => {
|
||||
for (const row of doc.연결표 ?? []) taken.add(row.연결);
|
||||
for (const e of doc.도면?.entities ?? []) if (isTable(e)) taken.add(linkOf(e) ?? "");
|
||||
for (const [, entities] of sheetsOf(doc))
|
||||
for (const e of entities) if (isTable(e)) taken.add(linkOf(e) ?? "");
|
||||
let n = 1;
|
||||
while (taken.has(`T${n}`)) n += 1;
|
||||
return `T${n}`;
|
||||
@@ -323,12 +332,14 @@ export function createAreaToDrawing(opt: AreaToDrawingOptions): AreaToDrawing {
|
||||
waiting = false;
|
||||
const old = new Map((doc.연결표 ?? []).map((row) => [row.연결, row]));
|
||||
const rows: TableLink[] = [];
|
||||
for (const e of doc.도면?.entities ?? []) {
|
||||
if (!isTable(e)) continue;
|
||||
const id = linkOf(e);
|
||||
const row = id ? (old.get(id) ?? fresh.get(id)) : undefined;
|
||||
if (!id || !row || rows.some((r) => r.연결 === id)) continue;
|
||||
rows.push({ ...row, 값: latest.get(id) ?? row.값 });
|
||||
for (const [sheet, entities] of sheetsOf(doc)) {
|
||||
for (const e of entities) {
|
||||
if (!isTable(e)) continue;
|
||||
const id = linkOf(e);
|
||||
const row = id ? (old.get(id) ?? fresh.get(id)) : undefined;
|
||||
if (!id || !row || rows.some((r) => r.연결 === id)) continue;
|
||||
rows.push({ ...row, 장: sheet, 값: latest.get(id) ?? row.값 });
|
||||
}
|
||||
}
|
||||
// 기록이 없던 도면은 칸을 안 만듦(옛 문서 그대로)
|
||||
return rows.length || doc.연결표 ? { ...doc, 연결표: rows } : doc;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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,161 @@ 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;
|
||||
if (dirty && !readOnly) tabs.markDirty();
|
||||
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(undefined, 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -426,14 +426,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,
|
||||
@@ -514,13 +513,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>) }
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
"""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() {} });
|
||||
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 = [];
|
||||
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"]
|
||||
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):
|
||||
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
|
||||
@@ -112,6 +112,16 @@ globalThis.fetch = async (url, init) => {
|
||||
await slot.kids[0].on.click();
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
ad.onDrawing(handle, { ...area, kind: "structure", name: "item2" }, drawing([]));
|
||||
const other = placed.length - before;
|
||||
// 여러 장(82-3) — 둘째 장 T2 가 있으니 새 표는 T3 · 저장 연결표 줄 장 = 표가 놓인 장id
|
||||
ad.select({ 시트: "산출", 칸: "A1", 범위: "A1" });
|
||||
await slot.kids[0].on.click();
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
const multi = drawing([table([["a"]], { 연결: "T1" })], [old]);
|
||||
multi.더한장 = [{ 장id: "s2", 이름: "2장", 도면: { entities: [table([["b"]], { 연결: "T2" })] } }];
|
||||
ad.onDrawing(handle, { ...area, kind: "structure" }, multi);
|
||||
const multiId = placed.at(-1)[1];
|
||||
const multiSaved = ad.withLinks({ ...multi, 연결표: [old, { ...old, 연결: "T2" }] }).연결표.map((r) => [r.연결, r.장]);
|
||||
// 빈 칸만 고른 범위 — 크기를 묻기 전에 막고 알림 · 도면 안 엶
|
||||
now = [["", " "]];
|
||||
const openedBefore = opened.length;
|
||||
@@ -119,7 +129,7 @@ globalThis.fetch = async (url, init) => {
|
||||
await slot.kids[0].on.click();
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
const empty = { opened: opened.length - openedBefore, toast: toasts.at(-1) };
|
||||
console.log(JSON.stringify({ ro, buttons: slot.kids.length, sent, placed, notice: notice.map((x) => (typeof x === "string" ? x : x.label)), saved: saved.연결표, plain: "연결표" in plain, other: placed.length - before, waitPlaced, waitDropped, waitSaved: ad.waiting(), empty }));
|
||||
console.log(JSON.stringify({ ro, buttons: slot.kids.length, sent, placed, notice: notice.map((x) => (typeof x === "string" ? x : x.label)), saved: saved.연결표, multiId, multiSaved, plain: "연결표" in plain, other, waitPlaced, waitDropped, waitSaved: ad.waiting(), empty }));
|
||||
})();
|
||||
"""
|
||||
|
||||
@@ -161,12 +171,17 @@ def test_다시_열면_다름_알림과_업데이트(got):
|
||||
def test_저장_연결표는_남은_표만(got):
|
||||
now = {"s1!B2": "1.5", "s1!C2": "2"}
|
||||
assert got["saved"] == [
|
||||
{"연결": "T1", "시트": "s1", "범위": "A1:A1", "글자크기": 3, "값": now}, # [업데이트] 값
|
||||
{"연결": "T2", "시트": "s1", "범위": "B2:C2", "글자크기": 2.5, "값": now},
|
||||
{"연결": "T1", "장": "", "시트": "s1", "범위": "A1:A1", "글자크기": 3, "값": now},
|
||||
{"연결": "T2", "장": "", "시트": "s1", "범위": "B2:C2", "글자크기": 2.5, "값": now},
|
||||
]
|
||||
assert got["plain"] is False
|
||||
|
||||
|
||||
def test_여러_장_새_번호는_모든_장에서_안_쓴_것_연결표_장(got):
|
||||
assert got["multiId"] == "T3"
|
||||
assert got["multiSaved"] == [["T1", ""], ["T2", "s2"]]
|
||||
|
||||
|
||||
def test_다른_열_도면에는_안_놓음(got):
|
||||
assert got["other"] == 0
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
/* =============================================================================
|
||||
* 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>[]>;
|
||||
/** 저장 뒤 — 받아 둔 편집본 · 미저장 표시를 지움(`tab` 없으면 모두 · `keepDrawings` = 편집본은 둠) */
|
||||
markClean: (tab?: CadTab<D>, keepDrawings?: boolean) => void;
|
||||
/** 지금 탭에 미저장 점(●) — 페이지 편집 통지(참만 · 거짓은 저장 뒤 `markClean`) */
|
||||
markDirty: () => 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 && list.length > 1)) && !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: (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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user