- 탭 줄을 안 그림 · Ctrl+PageDown/PageUp · Shift+F11 을 받지 않음(브라우저 몫) · [데이터 ▾] 의 시트 숨기기 · 탭 색 빠짐 - 산출근거(시트 하나)용 · 크기 화면 28,199 B(예산 28,672) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L6KXAabDTenEU7hrDQmCKY
326 lines
12 KiB
TypeScript
326 lines
12 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 { 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<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);
|
||
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);
|
||
}
|
||
async function evalSteps(): Promise<void> {
|
||
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());
|
||
},
|
||
},
|
||
};
|
||
}
|