refactor(M02): 항목 카드 넷 공용 틀 CardShell (PLAN 31-7)

- 공용 틀 — 제목 줄(제목 · 저장 안 한 점 ● · 오른쪽 단추 자리) + 한 줄 부제(번호 · 이름 · 말줄임) · 상태(불러오는 중 · 실패 + 다시 불러오기 · 비었음) · [저장](늘 보임 · 고쳐야 켜짐 · 카드 안 Ctrl+S · 알림 글 한 벌) · ?(단축키 상자)
- 도면 정보 · 면적산출 · 재료산출(ItemCards) · 선택 입력(관리 화면 · 고르기 모달 값 카드)이 틀에 내용만 넣음
- 「〇〇 수정」 빈 단추 한 모양 · 엑셀 카드에도 ? · 불러오는 중 네 카드 · 실패는 카드 안 글(토스트 안 씀) · 물음 한 번(어느 카드든)
- 저장 길(saveTemplate · 도면 write · sheetEdit.save · 위 [저장] saveInputs) · 단축키는 그대로 · 고르기 모달은 보기만
- 시험 test_m02_card_shell 새로 · 옛 모양 시험 맞춤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 21:24:49 +09:00
co-authored by Claude Opus 5.5
parent 2f7acff79f
commit bcb1c725c4
10 changed files with 500 additions and 313 deletions
@@ -1,28 +1,22 @@
/* =============================================================================
* M02_MasterTemplete_DrawingCard.ts
* 항목 카드 「도면 정보」 미리보기 — 고칠 수 있으면 메뉴(제목줄 · 리본 · 패널 · 상태줄) 없이
* **단축키로 그리기 · 고치기**(전체 편집기 [도면 수정]과 같은 웹캐드 · 같은 단축키) · 카드 안 [저장]
* (전체 편집기와 같은 저장 길 = 그 항목 구조물 도면 파일 `도면` 칸만 갈아 끼움) · ? = 단축키 도움말
* (PLAN 31-4). 고칠 수 없으면(읽기 층 · 저장 길 없는 자료 자리) 예전처럼 보기만.
* **단축키로 그리기 · 고치기**(전체 편집기 [도면 수정]과 같은 웹캐드 · 같은 단축키) · 카드 [저장]
* (전체 편집기와 같은 저장 길 = 그 항목 구조물 도면 파일 `도면` 칸만 갈아 끼움 · PLAN 31-4).
* [저장] · ? · 점(●) · 알림 글은 공용 카드 틀(`M02_MasterTemplete_CardShell` · 31-7) — 여기는 CAD 와 저장 길만.
* 고칠 수 없으면(읽기 층 · 저장 길 없는 자료 자리) 예전처럼 보기만.
* CAD 앱은 안 고침 — 메뉴는 같은 출처 iframe 에 스타일을 얹어 숨김.
* ========================================================================== */
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { StaleError, type StructureDoc } from "./M02_MasterTemplete_Api_Fetch";
import type { StructureDoc } from "./M02_MasterTemplete_Api_Fetch";
import type { SaveControl } from "./M02_MasterTemplete_CardShell";
import { mountDrawingTemplate, type DrawingTemplateDoc } from "./M02_MasterTemplete_Drawing";
import type { ItemSource } from "./M02_MasterTemplete_ItemSource";
const TEXT = {
save: "저장",
help: "?",
helpTitle: "단축키 — 글자를 치고 Enter",
saved: "도면을 저장함",
stale: "그 사이 도면이 바뀜 — 항목을 다시 골라 새로 읽음",
failed: "도면을 저장 못 함 — {value}",
};
export const DRAWING_HELP = "단축키 — 글자를 치고 Enter";
/** 단축키 도움말 — 전체 편집기 명령 별칭(openwebcad `commands.*.ts`)과 같은 것만 */
const KEYS: [string, string][] = [
export const DRAWING_KEYS: [string, string][] = [
["L · PL", "선 · 폴리선"],
["C · A · REC", "원 · 호 · 사각형"],
["T · DT", "글(여러 줄 · 한 줄)"],
@@ -58,6 +52,10 @@ function hideChrome(frame: HTMLIFrameElement, css: string): void {
apply();
}
/** 고칠 수 있나 — 층 권한 + 자료 자리에 저장 길(`write`) */
export const drawingEditable = (editable: boolean, source: ItemSource): boolean =>
editable && !!source.write;
export interface DrawingCardOptions {
/** 구조물 도면 문서 통째(`도번` · `도면` …) — 저장은 `도면` 칸만 갈아 끼움 */
doc: StructureDoc;
@@ -67,75 +65,29 @@ export interface DrawingCardOptions {
column: string;
/** 고칠 권한(층) — 자료 자리에 저장 길(`write`)도 있어야 고치기 */
editable: boolean;
/** 저장 안 한 고침이 생김 · 없어짐 */
onDirty?: (dirty: boolean) => void;
/** 카드 [저장] · 점(●) — 공용 틀(31-7) */
saver: SaveControl;
}
export interface DrawingCardHandle {
/** 카드 머리에 붙일 [저장] · ? (보기만이면 빈 칸) */
tools: HTMLElement;
dirty: () => boolean;
/** 고친 것이 있으면 카드 [저장]과 같이 저장 — 못 하면 던짐(위 [저장]이 먼저 부름) */
save: () => Promise<void>;
/** 저장 길 한 번(카드 [저장] · 위 [저장]이 틀의 `run` 으로 부름) — 못 하면 던짐 */
write: () => Promise<void>;
destroy: () => void;
}
export function mountDrawingCard(host: HTMLElement, opt: DrawingCardOptions): DrawingCardHandle {
const write = opt.source.write;
const edit = opt.editable && !!write;
const edit = drawingEditable(opt.editable, opt.source);
let doc = opt.doc;
let version = opt.version ?? "";
let dirty = false;
let saving = false;
const save = createButton({ label: TEXT.save, variant: "filled", size: "sm" });
const help = createButton({ label: TEXT.help, variant: "ghost", size: "sm" });
help.title = help.ariaLabel = TEXT.helpTitle;
const helpBox = el("div", {
className: "m02-struct__keys",
attrs: { hidden: "" },
children: [
el("strong", { text: TEXT.helpTitle }),
el("dl", {
children: KEYS.flatMap(([key, what]) => [
el("dt", { text: key }),
el("dd", { text: what }),
]),
}),
],
});
help.addEventListener("click", () => (helpBox.hidden = !helpBox.hidden));
const setDirty = (next: boolean): void => {
dirty = next;
save.disabled = !next || saving;
opt.onDirty?.(next);
};
/** 전체 편집기 [저장]과 같은 길 — 읽은 문서에 `도면` 만 갈아 끼워 그 항목 파일에 */
/** 전체 편집기 [저장]과 같은 길 — 읽은 문서에 `도면` 만 갈아 끼워 그 항목 파일에(실패는 던짐) */
async function doSave(drawing?: DrawingTemplateDoc): Promise<void> {
if (!write || saving) return;
saving = true;
save.disabled = true;
try {
const next = { ...doc, 도면: (drawing ?? (await handle.getDoc())) as StructureDoc["도면"] };
version = (await write("structure", opt.column, version, next)).판;
doc = next;
setDirty(false);
showToast(TEXT.saved, "success");
} catch (error) {
const why = error instanceof Error ? error.message : "";
showToast(
error instanceof StaleError ? TEXT.stale : TEXT.failed.replace("{value}", why),
"error",
);
} finally {
saving = false;
save.disabled = !dirty;
}
if (!write) return;
const next = { ...doc, 도면: (drawing ?? (await handle.getDoc())) as StructureDoc["도면"] };
version = (await write("structure", opt.column, version, next)).판;
doc = next;
}
save.addEventListener("click", () => void doSave());
save.disabled = true;
const handle = mountDrawingTemplate(host, doc.도면 as unknown as DrawingTemplateDoc, {
readOnly: !edit,
@@ -146,23 +98,14 @@ export function mountDrawingCard(host: HTMLElement, opt: DrawingCardOptions): Dr
preview: !edit, // 보기만 = 왼쪽 끌기 화면 옮기기 · 고치기 = 누름 고르기 · 단축키
originMarker: true, // 좌표 0,0 기준점 십자
noArea: true, // 구조물도는 도각이 없음
...(edit ? { onChanged: setDirty, onSave: (drawing) => doSave(drawing) } : {}),
...(edit
? {
onChanged: (dirty: boolean) => opt.saver.mark(dirty),
onSave: (drawing: DrawingTemplateDoc) => void opt.saver.run(() => doSave(drawing)),
}
: {}),
});
const frame = host.querySelector("iframe");
if (frame) hideChrome(frame, edit ? HIDE_EDIT : HIDE_VIEW);
if (edit) host.append(helpBox);
return {
tools: el("span", { className: "m02-struct__tools", children: edit ? [help, save] : [] }),
dirty: () => dirty,
save: async () => {
if (!dirty) return;
await doSave();
if (dirty) throw new Error(TEXT.failed.replace("{value}", ""));
},
destroy: () => {
handle.destroy();
helpBox.remove();
},
};
return { write: () => doSave(), destroy: () => handle.destroy() };
}