Files
Aislo/ui_template/cad_host/cad_host_tabs.ts
T
eomsangdonandClaude Opus 5.5 4e18bca4e4 feat(B07): 목록에서 연 도면 = 탭 — 열기 · 닫기 · 전환 · 이전/다음 · 탭 전환 때 미저장 편집 지킴(82-4)
- 공용 탭 줄: 마지막 한 탭 닫기 없음 · 지금 탭 미저장 점 · 탭 하나만 저장 표시 지움

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK
2026-10-08 12:12:58 +09:00

340 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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),
};
}