Merge remote-tracking branch 'origin/sub_laptop_5' into sub_laptop_7
This commit is contained in:
@@ -32,6 +32,8 @@ export interface PickerRow {
|
||||
도번?: string;
|
||||
면적번?: string;
|
||||
재료번?: string;
|
||||
/** 폴더 이름 배열(깊이 자유 · 26-1) — 없으면 종류 · 분류1~3 중 있는 것으로 이음 */
|
||||
경로?: string[] | null;
|
||||
/** 위 키 칸의 이름(21-9 서버가 더함) — 없으면 키를 작은 글로 보임 */
|
||||
names?: Partial<Record<NameField, string | null>>;
|
||||
layer?: PickerLayer;
|
||||
@@ -43,6 +45,8 @@ export const PICKER_LAYERS: readonly PickerLayer[] = ["system", "company", "pers
|
||||
|
||||
export interface PickNode {
|
||||
id: string;
|
||||
/** 어느 그룹 · 폴더 이름 배열 — 폴더 줄만(오른쪽 클릭 메뉴가 「누른 폴더」 를 앎) · 그룹 줄 = 빈 배열 */
|
||||
folder?: { layer: PickerLayer; path: string[] };
|
||||
/** 그룹 · 종류 · 분류 이름 — 잎은 항목 이름 */
|
||||
label: string;
|
||||
/** 마우스를 올렸을 때 — 잎은 키 */
|
||||
@@ -59,11 +63,15 @@ const PLACEHOLDER = /^새 분류\s*\d*$/;
|
||||
export const shown = (part?: string): string =>
|
||||
part && !PLACEHOLDER.test(part.trim()) ? part : "";
|
||||
|
||||
/** 보이는 항목 이름 — 종류 · 분류1~3 을 이음(자리표는 뺌 · 비면 열 id · 키) */
|
||||
export const itemName = (row: PickerRow): string =>
|
||||
[row.종류, row.분류1, row.분류2, row.분류3].map(shown).filter(Boolean).join(" ") ||
|
||||
row.열 ||
|
||||
row.key;
|
||||
/** 폴더 이름 배열 — 서버가 준 `경로` · 없으면 종류 · 분류1~3 중 있는 것(자리표 · 빈 것은 뺌) */
|
||||
export function pathOf(row: PickerRow): string[] {
|
||||
const own = (row.경로 ?? []).map(shown).filter(Boolean);
|
||||
if (own.length) return own;
|
||||
return [row.종류, row.분류1, row.분류2, row.분류3].map(shown).filter(Boolean);
|
||||
}
|
||||
|
||||
/** 보이는 항목 이름 — 경로를 이음(비면 열 id · 키) */
|
||||
export const itemName = (row: PickerRow): string => pathOf(row).join(" ") || row.열 || row.key;
|
||||
|
||||
/** 마우스를 올렸을 때 — 층 이름 + 키 */
|
||||
export const rowTip = (row: PickerRow, labels: TreeLabels): string =>
|
||||
@@ -113,49 +121,58 @@ export interface TreeLabels {
|
||||
unclassified: string;
|
||||
}
|
||||
|
||||
/** 그룹 → 종류 → 분류1 → 항목(잎). 서버가 준 차례를 지킴 · 빈 그룹도 한 줄 남김. */
|
||||
const folderId = (layer: PickerLayer, path: string[]): string => [layer, ...path].join("|");
|
||||
|
||||
/** 그룹 → 폴더(경로 앞부분 · 깊이 자유) → 항목(잎). 서버가 준 차례를 지킴 · 빈 그룹도 한 줄 남김. */
|
||||
export function buildTree(rows: PickerRow[], labels: TreeLabels): PickNode[] {
|
||||
return PICKER_LAYERS.map((layer) => {
|
||||
const mine = rows.filter((row) => layerOf(row) === layer);
|
||||
const kinds = new Map<string, Map<string, PickerRow[]>>();
|
||||
for (const row of mine) {
|
||||
const kind = shown(row.종류) || labels.unclassified;
|
||||
const cat = shown(row.분류1) || labels.unclassified;
|
||||
const cats = kinds.get(kind) ?? new Map<string, PickerRow[]>();
|
||||
cats.set(cat, [...(cats.get(cat) ?? []), row]);
|
||||
kinds.set(kind, cats);
|
||||
const root: PickNode = {
|
||||
id: layer,
|
||||
label: labels.layers[layer],
|
||||
count: 0,
|
||||
children: [],
|
||||
folder: { layer, path: [] },
|
||||
};
|
||||
const folders = new Map<string, PickNode>([[layer, root]]);
|
||||
for (const row of rows.filter((one) => layerOf(one) === layer)) {
|
||||
let parent = root;
|
||||
const path = pathOf(row).slice(0, -1);
|
||||
path.forEach((name, index) => {
|
||||
const here = path.slice(0, index + 1);
|
||||
const id = folderId(layer, here);
|
||||
let node = folders.get(id);
|
||||
if (!node) {
|
||||
node = { id, label: name, count: 0, children: [], folder: { layer, path: here } };
|
||||
folders.set(id, node);
|
||||
parent.children?.push(node);
|
||||
}
|
||||
parent = node;
|
||||
});
|
||||
parent.children?.push({
|
||||
id: rowId(row),
|
||||
label: itemName(row),
|
||||
tip: rowTip(row, labels),
|
||||
count: null,
|
||||
row,
|
||||
});
|
||||
for (const id of ancestorIds(layer, path)) {
|
||||
const node = folders.get(id);
|
||||
if (node) node.count = (node.count ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
const kindNodes: PickNode[] = [...kinds].map(([kind, cats]) => {
|
||||
const catNodes: PickNode[] = [...cats].map(([cat, leaves]) => ({
|
||||
id: `${layer}|${kind}|${cat}`,
|
||||
label: cat,
|
||||
count: leaves.length,
|
||||
children: leaves.map((row) => ({
|
||||
id: rowId(row),
|
||||
label: itemName(row),
|
||||
tip: rowTip(row, labels),
|
||||
count: null,
|
||||
row,
|
||||
})),
|
||||
}));
|
||||
return {
|
||||
id: `${layer}|${kind}`,
|
||||
label: kind,
|
||||
count: catNodes.reduce((sum, node) => sum + (node.count ?? 0), 0),
|
||||
children: catNodes,
|
||||
};
|
||||
});
|
||||
return { id: layer, label: labels.layers[layer], count: mine.length, children: kindNodes };
|
||||
return root;
|
||||
});
|
||||
}
|
||||
|
||||
/** 잎까지 가는 길의 나무 줄 id(그룹 · 폴더) */
|
||||
const ancestorIds = (layer: PickerLayer, path: string[]): string[] => [
|
||||
layer,
|
||||
...path.map((_, index) => folderId(layer, path.slice(0, index + 1))),
|
||||
];
|
||||
|
||||
/** 잎까지 가는 길의 나무 줄 id — 처음부터 펼칠 자리 */
|
||||
export function pathIds(row: PickerRow, labels: TreeLabels): string[] {
|
||||
const layer = layerOf(row);
|
||||
const kind = shown(row.종류) || labels.unclassified;
|
||||
const cat = shown(row.분류1) || labels.unclassified;
|
||||
return [layer, `${layer}|${kind}`, `${layer}|${kind}|${cat}`];
|
||||
}
|
||||
export const pathIds = (row: PickerRow, _labels?: TreeLabels): string[] =>
|
||||
ancestorIds(layerOf(row), pathOf(row).slice(0, -1));
|
||||
|
||||
export type FolderKind = "drawing" | "area" | "material" | "combo" | "unitPrice";
|
||||
|
||||
|
||||
@@ -21,3 +21,38 @@
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
/* 오른쪽 클릭 메뉴 — 모달(z-index 1000) 위 */
|
||||
.ui-tree__menu {
|
||||
position: fixed;
|
||||
z-index: 1100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 10rem;
|
||||
padding: var(--spacing-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-raised);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.ui-tree__menu-item {
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: none;
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ui-tree__menu-item:hover:not(:disabled),
|
||||
.ui-tree__menu-item:focus-visible {
|
||||
background: var(--color-surface-sunken);
|
||||
}
|
||||
|
||||
.ui-tree__menu-item:disabled {
|
||||
color: var(--color-text-secondary);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* ui_template_tree.ts
|
||||
* 공용 나무 보기 — 부모(접기/펴기) · 자식(깊이 들임) · 잎 고름. M01 왼쪽 패널과 항목 고르기가 함께 씀.
|
||||
* 줄 모양은 부르는 쪽이 `MakeRow` 로 정함(기본 줄 = `createTreeRow`).
|
||||
* 오른쪽 클릭 메뉴 = 줄마다 `menu()` 가 **메뉴 항목을 밖에서 줌**(고르기 모달 · 관리 화면 나무가 같은 자리를 씀 ·
|
||||
* 26-3) — 항목이 없으면 브라우저 기본 메뉴 그대로.
|
||||
* ========================================================================== */
|
||||
|
||||
import "./ui_template_tree.css";
|
||||
@@ -19,6 +21,60 @@ export interface TreeNode {
|
||||
open?: boolean;
|
||||
/** 마우스를 올렸을 때 글(기본 줄) */
|
||||
tip?: string;
|
||||
/** 오른쪽 클릭 메뉴 항목 — 부를 때마다 새로 받음 · 빈 목록 · 없음 = 메뉴 없음(기본 줄) */
|
||||
menu?: () => TreeMenuItem[];
|
||||
}
|
||||
|
||||
export interface TreeMenuItem {
|
||||
label: string;
|
||||
run: () => void;
|
||||
/** 못 누르는 까닭 — 있으면 흐리고 마우스를 올리면 까닭이 보임 */
|
||||
disabledReason?: string;
|
||||
}
|
||||
|
||||
/** 오른쪽 클릭 자리에 작은 메뉴 — 바깥 누르기 · Esc · 스크롤 = 닫힘 */
|
||||
export function openTreeMenu(event: MouseEvent, items: TreeMenuItem[]): void {
|
||||
document.querySelector(".ui-tree__menu")?.remove();
|
||||
const menu = el("div", { className: "ui-tree__menu", attrs: { role: "menu" } });
|
||||
const close = (): void => {
|
||||
menu.remove();
|
||||
document.removeEventListener("pointerdown", onOutside, true);
|
||||
document.removeEventListener("keydown", onKey, true);
|
||||
document.removeEventListener("scroll", close, true);
|
||||
};
|
||||
const onOutside = (down: Event): void => {
|
||||
if (!menu.contains(down.target as Node)) close();
|
||||
};
|
||||
const onKey = (key: KeyboardEvent): void => {
|
||||
if (key.key === "Escape") {
|
||||
key.stopPropagation(); // 모달의 Esc(취소)까지 가지 않게
|
||||
close();
|
||||
}
|
||||
};
|
||||
for (const item of items) {
|
||||
const button = el("button", {
|
||||
className: "ui-tree__menu-item",
|
||||
text: item.label,
|
||||
attrs: { type: "button", role: "menuitem" },
|
||||
});
|
||||
if (item.disabledReason) {
|
||||
button.disabled = true;
|
||||
button.title = item.disabledReason;
|
||||
} else {
|
||||
button.addEventListener("click", () => {
|
||||
close();
|
||||
item.run();
|
||||
});
|
||||
}
|
||||
menu.append(button);
|
||||
}
|
||||
document.body.append(menu);
|
||||
const room = menu.getBoundingClientRect();
|
||||
menu.style.left = `${Math.max(4, Math.min(event.clientX, innerWidth - room.width - 4))}px`;
|
||||
menu.style.top = `${Math.max(4, Math.min(event.clientY, innerHeight - room.height - 4))}px`;
|
||||
document.addEventListener("pointerdown", onOutside, true);
|
||||
document.addEventListener("keydown", onKey, true);
|
||||
document.addEventListener("scroll", close, true);
|
||||
}
|
||||
|
||||
/** 줄(버튼) 만들기 — 돌려받은 것 = 그 버튼 · click 은 부르는 쪽이 이음 */
|
||||
@@ -72,5 +128,11 @@ export function createTreeRow(
|
||||
button.style.paddingLeft = `calc(var(--spacing-8) + ${depth} * var(--spacing-16))`;
|
||||
if (node.tip) button.title = node.tip;
|
||||
button.addEventListener("click", onClick);
|
||||
button.addEventListener("contextmenu", (event) => {
|
||||
const items = node.menu?.() ?? [];
|
||||
if (!items.length) return;
|
||||
event.preventDefault();
|
||||
openTreeMenu(event, items);
|
||||
});
|
||||
return button;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user