/* ============================================================================= * spreadsheet_data_menu.ts (주인 D) * 데이터 단추 · 메뉴 잇기 — 수식 입력줄 앞 [↑] [↓] [필터] [데이터 ▾]. * 정렬 · 자동 필터(sub3 `spreadsheet_sort_dialog` · `spreadsheet_filter`) · * 조건부 서식 · 데이터 유효성 · 이름 관리 · 시트 숨김/탭 색(sub4 패널 — 부품 오른위 창에 띄움) · 눈금선 · * 수식 분석(sub3 — 선행 · 종속 추적 화살표 · 화살표 지우기 · 수식 계산 · 오류 ⚠ 풍선) · 자동 합계(Σ · Alt+=). * 유효성 목록 칸은 고르면 칸 옆 ▼ · Alt+↓ 로 값 목록(필터 머리 칸이면 필터 목록이 먼저). * ========================================================================== */ 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 { createCondFmtPanel } from "./spreadsheet_condfmt"; import { attachErrorTip } from "./spreadsheet_errcheck"; import { openEvalSteps } from "./spreadsheet_eval_steps"; import { attachFilter, toggleFilter } from "./spreadsheet_filter"; import { createNameManagerPanel } from "./spreadsheet_names"; import { createSheetExtraPanel } from "./spreadsheet_sheet_extra"; import { openSortDialog, sortSelection } from "./spreadsheet_sort_dialog"; 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(); }); function openPanel(name: string, make: (c: SpreadsheetContext) => PartHandle): void { closePanel(); panel = make(ctx); title.textContent = name.replace("…", ""); if (panel.root) body.append(panel.root); dialog.removeAttribute("hidden"); } function openMenu(anchor: HTMLElement): void { const r = anchor.getBoundingClientRect(); const items: MapContextMenuItem[] = [ [TEXT.sort, () => ((closeSort = openSortDialog(ctx)), undefined)], [TEXT.filterItem, () => (toggleFilter(ctx), back())], [TEXT.condfmt, () => openPanel(TEXT.condfmt, createCondFmtPanel)], [TEXT.validation, () => openPanel(TEXT.validation, createValidationPanel)], [TEXT.names, () => openPanel(TEXT.names, createNameManagerPanel)], [TEXT.sheets, () => openPanel(TEXT.sheets, 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, () => (closeSteps?.(), (closeSteps = openEvalSteps(ctx)))], ], }, ]; 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, () => sortSelection(ctx, false)), button("↓", TEXT.desc, () => sortSelection(ctx, 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()); }, }, }; }