/* ============================================================================= * 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 { 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 { features?: CadTabsFeatures; /** 지금 장 편집본 받기(requestSave) · dirty = 페이지 미저장 표시 */ capture: () => Promise<{ drawing: D; dirty: boolean }>; /** 장 싣기 — `tab.drawing` 이 있으면 받아 둔 편집본 */ show: (tab: CadTab) => void | Promise; /** 떠나도 되나(도각 편집 중 등) — false 면 그대로 */ canLeave?: () => boolean; /** [+] — 새 장 · null = 그만 */ create?: () => CadTab | null | Promise | null>; /** 장 목록이 바뀜(더하기 · 이름 · 지우기 · 순서) — 페이지 미저장 표시 */ onChange?: () => void; /** 탭을 닫음(close) — 남은 탭이 없으면 페이지가 정함 */ onClosed?: (tab: CadTab) => void; /** 물음 글 */ text?: Partial; } export interface CadTabs { element: HTMLElement; tabs: () => CadTab[]; active: () => CadTab | undefined; /** 탭 목록을 갈아 끼움(싣지 않음) */ set: (tabs: CadTab[], activeId: string) => void; /** 그 탭으로(없으면 끝에 더함) — 지금 장은 받아 둠 · 못 떠나면 false */ open: (tab: CadTab) => Promise; select: (id: string) => Promise; /** 지금 장을 받아 두고 모든 탭 */ collect: () => Promise[]>; /** 저장 뒤 — 받아 둔 편집본 · 미저장 표시를 지움(`tab` 없으면 모두 · `keepDrawings` = 편집본은 둠) */ markClean: (tab?: CadTab, 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(options: CadTabsOptions): CadTabs { 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[] = []; let activeId = ""; let busy = false; let dragId: string | null = null; const active = (): CadTab | undefined => list.find((tab) => tab.id === activeId); const changed = (): void => options.onChange?.(); /** 지금 장을 받아 그 탭에 둠 */ const stash = async (): Promise => { 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 => { 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, 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): Promise => { 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, 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), }; }