/* ============================================================================= * spreadsheet_data_menu.ts (주인 D) * 데이터 단추 · 메뉴 잇기 — 수식 입력줄 앞 [↑] [↓] [필터] [데이터 ▾]. * 정렬 · 자동 필터(sub3 `spreadsheet_sort_dialog` · `spreadsheet_filter`) · * 조건부 서식 · 데이터 유효성 · 이름 관리 · 시트 숨김/탭 색(sub4 패널 — 부품 오른위 창에 띄움) · 눈금선 · * 수식 분석(sub3 — 선행 · 종속 추적 화살표 · 화살표 지우기 · 수식 계산 · 오류 ⚠ 풍선) · 자동 합계(Σ · Alt+=) · * 이동 · 틀 고정 · 칸 넣기/지우기 · 데이터 도구 · 윤곽(`spreadsheet_actions.ts`). * 유효성 목록 칸은 고르면 칸 옆 ▼ · 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 { isError } from "./spreadsheet_eval"; import type { FilterHandle } from "./spreadsheet_filter"; import { createCondFmtPanel } from "./spreadsheet_condfmt"; import { createSheetExtraPanel } from "./spreadsheet_sheet_extra"; import { mountActions } from "./spreadsheet_actions"; import type { ErrorTipHandle } from "./spreadsheet_errcheck"; import type { TraceHandle } 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 { // 자동 필터는 시트에 필터가 있거나 켤 때 불러옴(크기 예산) let filter: FilterHandle | null = null; let filterMod: Promise | null = null; const loadFilter = () => (filterMod ??= import("./spreadsheet_filter").then((m) => { filter = m.attachFilter(ctx); filter.refresh(); return m; })); const toggleFilter = (): void => void loadFilter().then((m) => m.toggleFilter(ctx)); // 수식 분석(추적 · ⚠ 풍선 · 수식 계산)은 처음 쓸 때 — 메뉴 · 오류 칸 · 글 숫자 칸을 고르면 type Audit = { trace: TraceHandle; tip: ErrorTipHandle }; let audit: Audit | null = null; let loading: Promise | null = null; const loadAudit = () => (loading ??= Promise.all([ import("./spreadsheet_trace"), import("./spreadsheet_errcheck"), ]).then(([t, e]) => { const trace = t.attachTrace(ctx); audit = { trace, tip: e.attachErrorTip(ctx, trace) }; audit.tip.refresh(); return audit; })); const warnAt = (): boolean => { const { r, c } = ctx.selection.활성; const cell = ctx.sheet().칸[toA1(r, c)]; if (!cell) return false; if (cell.식 !== undefined) return isError(ctx.engine.value(ctx.selection.시트, r, c)); return typeof cell.값 === "string" && /\d/.test(cell.값); }; let closeSteps: (() => void) | null = null; const traced = (more: boolean): void => { if (!more) showToast(TEXT.noMore, "info"); back(); }; const act = mountActions(ctx, 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); } async function evalSteps(): Promise { const m = await import("./spreadsheet_eval_steps"); closeSteps?.(); closeSteps = m.openEvalSteps(ctx); } function openMenu(anchor: HTMLElement): void { const r = anchor.getBoundingClientRect(); const items: MapContextMenuItem[] = [ [TEXT.sort, () => void sortDialog()], [TEXT.filterItem, () => (toggleFilter(), back())], [TEXT.condfmt, () => void openPanel(TEXT.condfmt, async () => createCondFmtPanel)], [TEXT.validation, () => void openPanel(TEXT.validation, async () => createValidationPanel)], [ TEXT.names, () => void openPanel( TEXT.names, async () => (await import("./spreadsheet_names")).createNameManagerPanel, ), ], ...(ctx.root.classList.contains("ss--notabs") // 시트 탭 없음(산출근거) — 시트 창도 없음 ? [] : [ [ TEXT.sheets, () => void openPanel(TEXT.sheets, async () => createSheetExtraPanel), ] as MapContextMenuItem, ]), [TEXT.grid, () => (toggleGridlines(), back())], ...act.items, { label: TEXT.audit, children: [ [TEXT.prec, () => void loadAudit().then((a) => traced(a.trace.precedents()))], [TEXT.dep, () => void loadAudit().then((a) => traced(a.trace.dependents()))], [TEXT.clear, () => (audit?.trace.clear(), back())], [TEXT.steps, () => void 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), button("Σ", TEXT.sum, autosum, true), button(TEXT.menu, TEXT.menu, openMenu, true), ]; return { buttons, keys: { filter: toggleFilter, autosum, more: act.key, dropdown() { const f = ctx.sheet().필터; const g = f && parseRange(f.범위); const { r, c } = ctx.selection.활성; if (g && filter && r === g.r0 && c >= g.c0 && c <= g.c1) return (filter.open(c), true); return openList(); }, }, part: { root: null, refresh() { filter?.refresh(); if (!filter && ctx.sheet().필터) void loadFilter(); audit?.trace.refresh(); audit?.tip.refresh(); if (!audit && warnAt()) void loadAudit(); 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?.(); audit?.trace.destroy(); audit?.tip.destroy(); menu.element.remove(); dialog.remove(); arrow.remove(); buttons.forEach((b) => b.remove()); }, }, }; }