Files
Aislo/A00_Common/spreadsheet/spreadsheet_data_menu.ts
T
eomsangdonandClaude Opus 5.5 c6e826fe1f perf(spreadsheet): D — 누를 때만 쓰는 창을 늦게 불러옴(크기 예산 화면 28 KB 안)
- spreadsheet_data_menu.ts — 정렬(↑ ↓ · 정렬…) · 조건부 서식 · 시트 숨김/탭 색 창은 import()
- spreadsheet_extras.ts — 셀 서식 창(Ctrl+1 · [서식…]) import()
- 크기 시험: 엔진 31.4 · 화면 27.2 · css 3.9 · 합 62.6 KB

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L6KXAabDTenEU7hrDQmCKY
2026-09-27 21:23:54 +09:00

287 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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<KeyHooks, "filter" | "dropdown" | "autosum">;
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<void> {
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<void> {
(await sorting()).sortSelection(ctx, desc);
back();
}
async function sortDialog(): Promise<void> {
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());
},
},
};
}