Files
Aislo/A00_Common/spreadsheet/spreadsheet_data_menu.ts
T
eomsangdonandClaude Opus 5.5 b037073b10 style(spreadsheet): 셀 서식 · 메모 · 찾기 · 패널 창 · 오류 ⚠ 를 부품 모달 · 탭 · 떠 있는 판 · 단추로 (PLAN 553-6)
- 셀 서식 창 = 부품 모달(40rem) · 탭(32) · 칸 = 부품 입력 · 고르기 28 · 자체 판 · 탭 · 칸 CSS 지움
- 메모 편집 = 공용 확인창 판(.ui-confirm) · 글 칸 = 부품 입력
- 찾기 · 바꾸기 = 떠 있는 판(머리 없음) · 칸 24 · 단추 22 · 닫기 = 아이콘 단추
- 데이터 패널 창(조건부 서식 · 유효성 · 이름) = 떠 있는 판(머리 · 닫기 ✕)
- 오류 ⚠ = 아이콘 단추(선 18) · 골라 붙여넣기 · 정렬 칸 · 알약 찾기 칸 = 부품 단추 · 고르기 · 입력
- 옛 보라 별칭 · 날 검정 가림막 → 공통 값(hover · accent · scrim) · 창 닫힘 감시가 모달도 기다림
- 날 단추 23 → 15(남음 = 수식 줄 칸 단추 · 격자 안 ▼ · 알약) · 자체 꼴 규칙 95 → 77 · 격자 색 전후 같음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 22:11:14 +09:00

335 lines
13 KiB
TypeScript

/* =============================================================================
* 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 { createFloatPanel } from "@ui/ui_template_float_panel";
import { parseRange, rangeToA1, toA1 } from "./spreadsheet_address";
import { autoSumCells, 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: "눈금선 보이기 · 숨기기",
list: "값 목록(Alt+↓)",
sheets: "시트 숨기기 · 탭 색…",
audit: "수식 분석",
prec: "선행 참조 추적",
dep: "종속 참조 추적",
clear: "화살표 지우기",
steps: "수식 계산…",
noMore: "더 추적할 칸 없음",
sum: "자동 합계(Alt+=)",
};
export interface DataMenu {
/** 수식 입력줄 앞에 둘 단추들 */
buttons: HTMLElement[];
keys: Pick<KeyHooks, "filter" | "dropdown" | "autosum" | "more">;
part: PartHandle;
}
export function mountDataMenu(ctx: SpreadsheetContext, back: () => void): DataMenu {
// 자동 필터는 시트에 필터가 있거나 켤 때 불러옴(크기 예산)
let filter: FilterHandle | null = null;
let filterMod: Promise<typeof import("./spreadsheet_filter")> | 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<Audit> | 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);
ctx.dataMenu = (kind) => {
if (kind === "filter") return (toggleFilter(), back());
if (kind === "condfmt") return void openPanel(TEXT.condfmt, async () => createCondFmtPanel);
if (kind === "validation")
return void openPanel(TEXT.validation, async () => createValidationPanel);
void openPanel(
TEXT.names,
async () => (await import("./spreadsheet_names")).createNameManagerPanel,
);
};
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;
};
// ── 패널 창(조건부 서식 · 유효성 · 이름) — 한 번에 하나 ─────────────────────
// 떠 있는 판 부품(머리 · 닫기 ✕ · 몸 · 553-6) — 자리는 `.ss-dialog`
const { root: dialog, body } = createFloatPanel({
title: " ",
onClose: () => (closePanel(), back()),
});
dialog.classList.add("ss-dialog");
dialog.setAttribute("hidden", "");
const title = dialog.querySelector(".ui-float-panel__title") as HTMLElement;
ctx.root.append(dialog);
let panel: PartHandle | null = null;
const closePanel = (): void => {
panel?.destroy();
panel = null;
body.replaceChildren();
dialog.setAttribute("hidden", "");
};
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);
}
async function evalSteps(): Promise<void> {
const m = await import("./spreadsheet_eval_steps");
closeSteps?.();
closeSteps = m.openEvalSteps(ctx);
}
function openMenu(anchor: HTMLElement): void {
const a = anchor.getBoundingClientRect();
const o = ctx.root.getBoundingClientRect(); // 메뉴는 부품 뿌리 기준 자리(칸 우클릭 메뉴와 같음)
const r = { left: a.left - o.left, bottom: a.bottom - o.top };
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 cells = autoSumCells(ctx);
if (cells) return ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: cells });
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());
},
},
};
}