- 공용 탭 줄: 마지막 한 탭 닫기 없음 · 지금 탭 미저장 점 · 탭 하나만 저장 표시 지움 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK
340 lines
12 KiB
TypeScript
340 lines
12 KiB
TypeScript
/* =============================================================================
|
||
* 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),
|
||
};
|
||
}
|