Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY
182 lines
7.8 KiB
TypeScript
182 lines
7.8 KiB
TypeScript
/* =============================================================================
|
|
* B09_Estimation_UI_Tabs_Bill_Level.ts
|
|
* B09 설계내역서 레벨 단추(PLAN 200-2 · 229-1) — 보조 탭 줄 오른쪽 [→ 들여쓰기] [← 내어쓰기] [↑ 위로] [↓ 아래로] ·
|
|
* 키 · 안내 한 줄(표 아래).
|
|
*
|
|
* 단추 = 보조 탭 줄 도구 자리(`…_UI_TabBar` setTools) · 안내 글 = 표 아래(202-1)
|
|
* 동작 = 줄 우클릭 메뉴(`…_UI_Tabs_Bill_Edit` billMenu)의 같은 항목을 부름 — 메뉴에 없으면 단추 흐림 · 키 안 먹음
|
|
* 고른 줄 = 표 줄 누름(키로 기억 · 다시 그려도 남음) · 표시 = 줄 class is-focus(Shell 이 줄 class 곁값으로) ·
|
|
* 꼴 = 줄 전체 상자 테두리(235-1 · 페이지 CSS) · 글 칸도 한 번 누름 = 고름 · 더블클릭 · Enter · F2 = 입력(공용 표)
|
|
* 키 = Tab 들여쓰기 · Shift+Tab 내어쓰기 · Alt+↑↓ 옮기기 · Esc 편집 끝(편집 아니면 고름 풂)
|
|
* 방향키(204) = → 들여쓰기 · ← 내어쓰기 · ↑↓ 옮기기(같은 메뉴 항목) · 끌기 = `…_Bill_Drag`(놓은 줄 = 고른 줄)
|
|
* 글 칸 편집 중 · 초점이 표 · 도구 줄 밖이면 안 먹음 — 줄 누름이 다른 탭으로 가는 줄은 글 칸을 눌러 고름
|
|
* 242-1 이동해 온 범위(총괄 등 → 그 줄 + 자손 · 빨강 is-pick) = 고름과 한때 하나 — 오면 고름 풂 ·
|
|
* 줄 누름 · 방향키 · Esc · 끌어 놓음 = 빨강 풂
|
|
* ========================================================================== */
|
|
|
|
import type { DataRow } from "@ui/ui_template_data_table";
|
|
import { createButton } from "@ui/ui_template_elements";
|
|
import { attachHoverInfo } from "@ui/ui_template_hover_info";
|
|
import { attachDrag } from "./B09_Estimation_UI_Tabs_Bill_Drag";
|
|
import type { BillTable } from "./B09_Estimation_UI_Tabs_Bill_Edit";
|
|
|
|
/** [단추 글, 메뉴 글] — 차례 = 들여 · 내어 · 위 · 아래 */
|
|
const ACTS: [string, string][] = [
|
|
["→ 들여쓰기", "들여쓰기 →"],
|
|
["← 내어쓰기", "내어쓰기 ←"],
|
|
["↑ 위로", "위로 ↑"],
|
|
["↓ 아래로", "아래로 ↓"],
|
|
];
|
|
|
|
const GUIDE = "줄 누르고 단추 · 방향키 ←→ 레벨 · ↑↓ 자리 · 줄 끌기 · 더블클릭 · Enter · F2 입력";
|
|
|
|
let picked: string | null = null;
|
|
/** 242-1 이동해 온 범위 [첫 줄, 끝 줄] — 고름과 한때 하나 */
|
|
let jump: [number, number] | null = null;
|
|
/** 지금 그린 도구 줄 — 키가 부름 */
|
|
let live: {
|
|
root: HTMLElement;
|
|
tools: HTMLElement[];
|
|
run: (label: string) => void;
|
|
sync: () => void;
|
|
edit: (index: number) => void;
|
|
index: () => number;
|
|
} | null = null;
|
|
let keysOn = false;
|
|
|
|
/** 고른 줄 class — Shell 줄 class 곁값 */
|
|
export const pickedClass = (row: DataRow | undefined): string | null =>
|
|
row && picked !== null && String(row.key) === picked ? "is-focus" : null;
|
|
|
|
/** 그 줄 + 자손(뒤 줄 중 레벨이 더 깊은 연속 줄) 끝 줄 번호 */
|
|
export function subtreeEnd(rows: DataRow[], at: number): number {
|
|
const lv = Number(rows[at]?.level ?? 1);
|
|
let end = at;
|
|
while (end + 1 < rows.length && Number(rows[end + 1]?.level ?? 1) > lv) end++;
|
|
return end;
|
|
}
|
|
|
|
/** 242-1 이동해 온 줄(-1 = 없음) — 범위 = 그 줄 + 자손 · 오면 고름 풂(Shell 이 표 그리기 전에) */
|
|
export function jumpTo(rows: DataRow[], at: number): void {
|
|
jump = at >= 0 ? [at, subtreeEnd(rows, at)] : null;
|
|
if (jump) picked = null;
|
|
}
|
|
|
|
/** 지금 빨강 범위 — Shell 줄 class 곁값(pickClass) */
|
|
export const jumpRange = (): [number, number] | null => jump;
|
|
|
|
/** 그 줄 메뉴 항목(글 → 동작) — 메뉴에 없는 동작 = 못 함 */
|
|
export function menuActs(table: BillTable, index: number): Map<string, () => void> {
|
|
const items = index >= 0 && table.menu ? table.menu(index) : [];
|
|
return new Map(items.filter((x): x is [string, () => void] => Array.isArray(x)));
|
|
}
|
|
|
|
/** 키 → 메뉴 글 · 편집 중 · 다른 키 = null */
|
|
export function keyAct(
|
|
e: { key: string; shiftKey: boolean; altKey: boolean; ctrlKey?: boolean },
|
|
editing: boolean,
|
|
): string | null {
|
|
if (editing || e.ctrlKey) return null;
|
|
if (e.key === "Tab" && !e.altKey) return ACTS[e.shiftKey ? 1 : 0][1];
|
|
if (e.shiftKey) return null;
|
|
const arrow = ["ArrowRight", "ArrowLeft", "ArrowUp", "ArrowDown"].indexOf(e.key);
|
|
// 방향키(204) — 그냥 · Alt 같음(Alt+←→ = 브라우저 뒤로 · 앞으로라 안 받음)
|
|
if (arrow < 0 || (e.altKey && arrow < 2)) return null;
|
|
return ACTS[arrow][1];
|
|
}
|
|
|
|
const editing = (at: Element | null): boolean =>
|
|
/^(INPUT|TEXTAREA|SELECT)$/.test(at?.tagName ?? "") ||
|
|
!!(at as HTMLElement | null)?.isContentEditable;
|
|
|
|
function listenKeys(): void {
|
|
if (keysOn) return;
|
|
keysOn = true;
|
|
document.addEventListener("keydown", (event) => {
|
|
if (!live?.root.isConnected || (picked === null && !jump)) return;
|
|
const at = document.activeElement;
|
|
if (
|
|
at &&
|
|
at !== document.body &&
|
|
!live.root.contains(at) &&
|
|
!live.tools.includes(at as HTMLElement)
|
|
)
|
|
return;
|
|
// 242-1 빨강 중 = 방향키 · Esc 가 빨강만 풂(고른 줄 없음)
|
|
if (picked === null) {
|
|
if (event.key !== "Escape" && !keyAct(event, editing(at))) return;
|
|
jump = null;
|
|
return live.sync();
|
|
}
|
|
// Esc = 편집 끝(표에 초점 · 키 받음) · 편집 아닐 때 = 고름 풂
|
|
if (event.key === "Escape") {
|
|
if (!editing(at)) {
|
|
picked = null;
|
|
return live.sync();
|
|
}
|
|
(at as HTMLElement).blur();
|
|
return live.root.focus();
|
|
}
|
|
// Enter · F2 = 고른 줄 입력 시작(235-1) — 도구 단추 위 Enter 는 단추 몫
|
|
if ((event.key === "Enter" || event.key === "F2") && !editing(at)) {
|
|
if (live.tools.includes(at as HTMLElement) || event.isComposing) return;
|
|
event.preventDefault();
|
|
return live.edit(live.index());
|
|
}
|
|
const label = keyAct(event, editing(at));
|
|
if (!label) return;
|
|
event.preventDefault();
|
|
live.run(label);
|
|
});
|
|
}
|
|
|
|
/** 레벨 단추 넷(보조 탭 줄로) · 안내 글(표 아래로) — 표 root 에 줄 누름(고름)을 붙임 */
|
|
export function levelBar(
|
|
root: HTMLElement,
|
|
table: BillTable,
|
|
edit: (index: number) => void = () => {},
|
|
): { tools: HTMLElement[]; guide: HTMLElement } {
|
|
const index = (): number =>
|
|
picked === null ? -1 : table.rows.findIndex((r) => String(r.key) === picked);
|
|
const run = (label: string): void => menuActs(table, index()).get(label)?.();
|
|
const buttons = ACTS.map(([text, label]) => {
|
|
const button = createButton({
|
|
label: text,
|
|
variant: "ghost",
|
|
size: "sm",
|
|
onClick: () => run(label),
|
|
});
|
|
button.dataset.b09Level = label;
|
|
return button;
|
|
});
|
|
const guide = document.createElement("p");
|
|
guide.className = "ui-general-block__empty b09-estimation__line-hint";
|
|
guide.textContent = GUIDE;
|
|
attachHoverInfo(guide, (el) => (el.scrollWidth > el.clientWidth ? el.textContent : null)); // 236-2 잘릴 때만
|
|
const sync = (): void => {
|
|
const acts = menuActs(table, index());
|
|
ACTS.forEach(([, label], k) => (buttons[k].disabled = !acts.has(label)));
|
|
for (const tr of root.querySelectorAll("tr.is-focus")) tr.classList.remove("is-focus");
|
|
if (!jump)
|
|
for (const tr of root.querySelectorAll("tr.is-pick"))
|
|
tr.classList.remove("is-pick", "is-pick-top", "is-pick-end");
|
|
root.querySelector(`tr[data-i="${index()}"]`)?.classList.add("is-focus");
|
|
};
|
|
root.addEventListener("click", (event) => {
|
|
const tr = (event.target as Element).closest<HTMLElement>("tr[data-i]");
|
|
const row = tr ? table.rows[Number(tr.dataset.i)] : undefined;
|
|
if (!row) return;
|
|
picked = String(row.key);
|
|
jump = null;
|
|
sync();
|
|
});
|
|
attachDrag(root, table, (key) => {
|
|
picked = key;
|
|
jump = null;
|
|
});
|
|
sync();
|
|
live = { root, tools: buttons, run, sync, edit, index };
|
|
listenKeys();
|
|
return { tools: buttons, guide };
|
|
}
|