/* ============================================================================= * spreadsheet_data_menu.ts (주인 D) * 데이터 단추 · 메뉴 잇기 — 수식 입력줄 앞 [↑] [↓] [필터] [데이터 ▾]. * 정렬 · 자동 필터(sub3 `spreadsheet_sort_dialog` · `spreadsheet_filter`) · * 조건부 서식 · 데이터 유효성 · 이름 관리 · 시트 숨김/탭 색(sub4 패널 — 부품 오른위 창에 띄움) · 눈금선 · * 수식 분석(sub3 — 선행 · 종속 추적 화살표 · 화살표 지우기 · 수식 계산 · 오류 ⚠ 풍선) · 자동 합계(Σ · Alt+=). * 유효성 목록 칸은 고르면 칸 옆 ▼ · Alt+↓ 로 값 목록(필터 머리 칸이면 필터 목록이 먼저). * 누를 때만 쓰는 창(정렬 · 조건부 서식 · 시트)은 `import()` 로 늦게 — 크기 예산(열 때 받는 조각). * ========================================================================== */ import "./spreadsheet_stage3.css"; // sub4 패널 스타일(잇는 쪽이 붙임) import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu"; import { el, showToast } from "@ui/ui_template_elements"; import { parseRange, rangeToA1, toA1 } from "./spreadsheet_address"; import { autoSumRange } from "./spreadsheet_autosum"; import { attachErrorTip } from "./spreadsheet_errcheck"; import { openEvalSteps } from "./spreadsheet_eval_steps"; import { attachFilter, toggleFilter } from "./spreadsheet_filter"; import { createNameManagerPanel } from "./spreadsheet_names"; import { attachTrace } from "./spreadsheet_trace"; import { createValidationDropdown, createValidationPanel, findValidation, } from "./spreadsheet_validation"; import type { KeyHooks } from "./spreadsheet_keys"; import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types"; const TEXT = { asc: "오름차순 정렬(활성 칸 열 기준)", desc: "내림차순 정렬(활성 칸 열 기준)", filter: "자동 필터 켜기 · 끄기(Ctrl+Shift+L · 머리 칸에서 Alt+↓ 값 목록)", menu: "데이터 ▾", sort: "정렬…", filterItem: "자동 필터 켜기 · 끄기", condfmt: "조건부 서식…", validation: "데이터 유효성…", names: "이름 관리…", grid: "눈금선 보이기 · 숨기기", close: "닫기", list: "값 목록(Alt+↓)", sheets: "시트 숨기기 · 탭 색…", audit: "수식 분석", prec: "선행 참조 추적", dep: "종속 참조 추적", clear: "화살표 지우기", steps: "수식 계산…", noMore: "더 추적할 칸 없음", sum: "자동 합계(Alt+=)", }; export interface DataMenu { /** 수식 입력줄 앞에 둘 단추들 */ buttons: HTMLElement[]; keys: Pick; part: PartHandle; } export function mountDataMenu(ctx: SpreadsheetContext, back: () => void): DataMenu { const filter = attachFilter(ctx); const trace = attachTrace(ctx); const tip = attachErrorTip(ctx, trace); let closeSteps: (() => void) | null = null; const traced = (more: boolean): void => { if (!more) showToast(TEXT.noMore, "info"); back(); }; const menu = createMapContextMenu("ss"); ctx.root.append(menu.element); let closeSort: (() => void) | null = null; const button = (text: string, title: string, run: (b: HTMLElement) => void, keep = false) => { const b = el("button", { className: "ss-brush", text, attrs: { type: "button", title } }); b.addEventListener("click", () => { run(b); if (!keep) back(); }); return b; }; // ── 패널 창(조건부 서식 · 유효성 · 이름) — 한 번에 하나 ───────────────────── const title = el("span", { className: "ss-dialog__title" }); const body = el("div", { className: "ss-dialog__body" }); const shut = el("button", { className: "ss-find__btn", text: "×", attrs: { type: "button", title: TEXT.close }, }); const dialog = el("div", { className: "ss-dialog", attrs: { hidden: "" }, children: [el("div", { className: "ss-dialog__head", children: [title, shut] }), body], }); ctx.root.append(dialog); let panel: PartHandle | null = null; const closePanel = (): void => { panel?.destroy(); panel = null; body.replaceChildren(); dialog.setAttribute("hidden", ""); }; shut.addEventListener("click", () => (closePanel(), back())); dialog.addEventListener("keydown", (e) => { if (e.key !== "Escape") return; e.stopPropagation(); closePanel(); back(); }); async function openPanel( name: string, load: () => Promise<(c: SpreadsheetContext) => PartHandle>, ): Promise { const make = await load(); closePanel(); panel = make(ctx); title.textContent = name.replace("…", ""); if (panel.root) body.append(panel.root); dialog.removeAttribute("hidden"); } const sorting = () => import("./spreadsheet_sort_dialog"); async function sortQuick(desc: boolean): Promise { (await sorting()).sortSelection(ctx, desc); back(); } async function sortDialog(): Promise { const m = await sorting(); closeSort?.(); closeSort = m.openSortDialog(ctx); } function evalSteps(): void { closeSteps?.(); closeSteps = openEvalSteps(ctx); } function openMenu(anchor: HTMLElement): void { const r = anchor.getBoundingClientRect(); const items: MapContextMenuItem[] = [ [TEXT.sort, () => void sortDialog()], [TEXT.filterItem, () => (toggleFilter(ctx), back())], [ TEXT.condfmt, () => void openPanel(TEXT.condfmt, () => import("./spreadsheet_condfmt").then((m) => m.createCondFmtPanel), ), ], [TEXT.validation, () => void openPanel(TEXT.validation, async () => createValidationPanel)], [TEXT.names, () => void openPanel(TEXT.names, async () => createNameManagerPanel)], [ TEXT.sheets, () => void openPanel(TEXT.sheets, () => import("./spreadsheet_sheet_extra").then((m) => m.createSheetExtraPanel), ), ], [TEXT.grid, () => (toggleGridlines(), back())], { label: TEXT.audit, children: [ [TEXT.prec, () => traced(trace.precedents())], [TEXT.dep, () => traced(trace.dependents())], [TEXT.clear, () => (trace.clear(), back())], [TEXT.steps, evalSteps], ], }, ]; if (!ctx.readOnly) menu.open(r.left, r.bottom, items); } function toggleGridlines(): void { const s = ctx.sheet(); ctx.dispatch({ 종류: "눈금선", 시트: s.id, 보임: s.보기?.눈금선 === false }); } // ── 유효성 목록 — ▼ 단추 · Alt+↓ ───────────────────────────────────────── let drop: HTMLElement | null = null; const closeDrop = (): void => { drop?.remove(); drop = null; }; const arrow = el("button", { className: "ss-list-arrow", text: "▼", attrs: { type: "button", hidden: "", title: TEXT.list }, }); arrow.addEventListener("mousedown", (e) => e.preventDefault()); arrow.addEventListener("click", () => openList()); ctx.root.append(arrow); /** 칸 자리(부품 뿌리 기준) */ const at = (r: number, c: number) => { const { box } = ctx.grid.editorSlot(r, c); const g = ctx.grid.root.getBoundingClientRect(); const o = ctx.root.getBoundingClientRect(); return { x: box.x + g.left - o.left, y: box.y + g.top - o.top, w: box.w, h: box.h }; }; function openList(): boolean { const { r, c } = ctx.selection.활성; const rule = findValidation(ctx.sheet(), r, c); if (ctx.readOnly || !rule) return false; closeDrop(); drop = createValidationDropdown(ctx, r, c, rule, (value) => { closeDrop(); const key = toA1(r, c); const n = Number(value); const 값 = value !== "" && Number.isFinite(n) ? n : value; const 서식 = ctx.sheet().칸[key]?.서식; const cell = 서식 === undefined ? { 값 } : { 값, 서식 }; ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: { [key]: cell } }); back(); }); if (!drop) return false; const p = at(r, c); drop.style.left = `${p.x}px`; // cellBox(스크롤 전 내용 좌표) 자리를 화면 자리로 고침 drop.style.top = `${p.y + p.h}px`; ctx.root.append(drop); return true; } const onOutside = (e: MouseEvent): void => { if (drop && !drop.contains(e.target as Node) && e.target !== arrow) closeDrop(); }; document.addEventListener("mousedown", onOutside, true); /** 자동 합계 — 엑셀처럼 =SUM(잡은 범위) 로 편집을 열고 Enter 는 사용자 몫 */ function autosum(): void { const g = autoSumRange(ctx); ctx.editor.begin(g ? `=SUM(${rangeToA1(g)})` : "=SUM()"); } const buttons = ctx.readOnly ? [] : [ button("↑", TEXT.asc, () => void sortQuick(false), true), button("↓", TEXT.desc, () => void sortQuick(true), true), button("필터", TEXT.filter, () => toggleFilter(ctx)), button("Σ", TEXT.sum, autosum, true), button(TEXT.menu, TEXT.menu, openMenu, true), ]; return { buttons, keys: { filter: () => toggleFilter(ctx), autosum, dropdown() { const f = ctx.sheet().필터; const g = f && parseRange(f.범위); const { r, c } = ctx.selection.활성; if (g && r === g.r0 && c >= g.c0 && c <= g.c1) return (filter.open(c), true); return openList(); }, }, part: { root: null, refresh() { filter.refresh(); trace.refresh(); tip.refresh(); panel?.refresh(); closeDrop(); const { r, c } = ctx.selection.활성; const list = !ctx.readOnly && findValidation(ctx.sheet(), r, c)?.규칙.종류 === "목록"; arrow.hidden = !list || ctx.editor.editing(); if (!list) return; const p = at(r, c); arrow.style.left = `${p.x + p.w + 1}px`; arrow.style.top = `${p.y}px`; arrow.style.height = `${p.h}px`; }, destroy() { document.removeEventListener("mousedown", onOutside, true); closeSort?.(); closePanel(); closeDrop(); filter.destroy(); closeSteps?.(); trace.destroy(); tip.destroy(); menu.element.remove(); dialog.remove(); arrow.remove(); buttons.forEach((b) => b.remove()); }, }, }; }