Merge remote-tracking branch 'origin/dev' into sub_laptop_2

This commit is contained in:
2026-10-08 12:34:40 +09:00
159 changed files with 10555 additions and 957 deletions
+95
View File
@@ -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;
}
+339
View File
@@ -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),
};
}
+7
View File
@@ -64,3 +64,10 @@
.ui-ctx-menu__item.is-sub {
padding-left: var(--spacing-24);
}
/* 구분선(`"-"` 항목) */
.ui-ctx-menu__sep {
height: 1px;
margin: var(--spacing-4) 0;
background: var(--color-border);
}
+14 -2
View File
@@ -14,10 +14,12 @@
import "./ui_template_context_menu.css";
/** 평면 항목 = [문구, 동작]. 하위 메뉴 항목 = {label, children} — 누르면 바로 아래로
* 펼쳐지는 아코디언(구조물군 → 종류, 2026-08-18 B05 우클릭 메뉴 일원화). */
* 펼쳐지는 아코디언(구조물군 → 종류, 2026-08-18 B05 우클릭 메뉴 일원화).
* 구분선 = `"-"` — 맨 앞 · 맨 뒤 · 겹친 것은 안 그림. */
export type MapContextMenuItem =
| [label: string, action: () => void]
| { label: string; children: Array<[label: string, action: () => void]> };
| { label: string; children: Array<[label: string, action: () => void]> }
| "-";
export interface MapContextMenu {
/** 뷰포트에 append할 요소. */
@@ -118,6 +120,14 @@ export function createMapContextMenu(blockClass: string): MapContextMenu {
enterMenu(registryEntry);
element.replaceChildren();
items.forEach((item) => {
if (item === "-") {
const last = element.lastElementChild;
if (last && !last.classList.contains("ui-ctx-menu__sep"))
element.append(
Object.assign(document.createElement("div"), { className: "ui-ctx-menu__sep" }),
);
return;
}
if (Array.isArray(item)) {
element.append(buildLeaf(item[0], item[1]));
return;
@@ -145,6 +155,8 @@ export function createMapContextMenu(blockClass: string): MapContextMenu {
});
element.append(head, body);
});
if (element.lastElementChild?.classList.contains("ui-ctx-menu__sep"))
element.lastElementChild.remove();
element.style.left = `${x}px`;
element.style.top = `${y}px`;
element.hidden = false;
-4
View File
@@ -290,10 +290,6 @@ export const ui_locales_b2 = {
"Distribute tiers evenly along the slope",
],
B06_Cross_Extra_Remove: ["한 단 삭제", "Remove one tier"],
B06_Cross_Extra_Limit: [
"지형상 추가 기슭막이는 {n}단까지만 가능합니다 — 벽이 원지반에 0.5m 이상 묻히면 성토가 필요 없습니다",
"Terrain allows only {n} extra revetment tier(s) — no fill needed once a wall is buried 0.5m+",
],
B06_Cross_Struct_Auto: ["자동(규칙)", "Auto (by rule)"],
B06_Cross_Struct_Revet: ["기슭막이+배관", "Revetment + pipe"],
B06_Cross_Struct_I: ["집수정 I형", "Catch basin — I"],
+16
View File
@@ -28,6 +28,21 @@
margin-left: calc(var(--spacing-24) + var(--spacing-8));
}
/* follow:false(85-5) — 부르는 칸(고정 높이) 높이를 채움 · 패널 내용이 칸 높이를 늘리지 않음 */
.ui-side-panels.is-fill {
align-self: stretch;
width: var(--ui-side-panels-width, 420px);
height: auto;
min-height: 240px;
}
.ui-side-panels.is-fill > .ui-side-panels__panel {
position: absolute;
inset: 0;
width: auto;
height: auto;
}
/* 모두 접힘 — 폭 0 · 손잡이만 왼쪽 밖에 남음 */
.ui-side-panels.is-collapsed {
width: 0;
@@ -113,4 +128,5 @@
min-height: 0;
padding: var(--spacing-12);
overflow: auto;
overscroll-behavior: contain; /* 몸 끝에서 메인으로 이어 굴리지 않음(85-5) */
}
+5 -1
View File
@@ -4,6 +4,7 @@
* 좌측 패널과 같은 접기 양식 — 패널 왼쪽 가장자리 손잡이(공용 createWorkflowPanelHandle · 오른쪽으로 접힘)
* 패널마다 손잡이 하나(이름표 세로) · 누르면 폄/접음 · 모두 접히면 손잡이만 남음
* 편 패널 = 머리(제목) + 몸 · 왼쪽 경계 끌기(화살표 키) · 보이는 칸 안에 붙어 몸만 따로 스크롤(followView)
* `follow: false`(85-5) = followView 안 씀 · 부르는 칸(고정 높이) 높이를 채움 · 메인 스크롤과 따로(M02 상세)
* 열린 패널 · 폭 = 부르는 쪽 취향 키(`aislo:pref:{prefKey}`)
* 부르기: const sp = createSidePanels({ prefKey, panels: [{ id, title, body }] }) →
* 본문과 함께 `.ui-side-panels-row` 칸에 [본문, sp.root] 로 넣음
@@ -42,6 +43,8 @@ export interface SidePanelsOpts {
onChange?(open: string | null): void;
/** 폭이 바뀜(끌기 · 화살표) */
onResize?(px: number): void;
/** 보이는 칸 따라 붙음(followView) · 기본 참 · 거짓 = 부르는 칸 높이를 채움(칸이 고정 높이 · 85-5) */
follow?: boolean;
}
export interface SidePanels {
@@ -166,7 +169,8 @@ export function createSidePanels(opts: SidePanelsOpts): SidePanels {
}
draw();
followView(root);
if (opts.follow === false) root.classList.add("is-fill");
else followView(root);
return { root, open, current: () => now, width: () => width };
}
+17
View File
@@ -510,3 +510,20 @@ a:hover {
outline: 2px solid var(--color-focus-ring);
outline-offset: 2px;
}
/* ─── B · M 화면 전역 글 선택 막음(81-1) ───────────────────────────────────
`html.is-bm-route` 는 main.ts 가 라우트마다 토글(A 영역은 안 붙음).
허용 = input · textarea · select · [contenteditable] · 원문 본문(m01lab-src__body). */
html.is-bm-route {
user-select: none;
-webkit-user-select: none;
}
html.is-bm-route input,
html.is-bm-route textarea,
html.is-bm-route select,
html.is-bm-route [contenteditable],
html.is-bm-route .m01lab-src__body,
html.is-bm-route .m01lab-src__body * {
user-select: text;
-webkit-user-select: text;
}