- 셀 서식 창 = 부품 모달(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>
335 lines
13 KiB
TypeScript
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());
|
|
},
|
|
},
|
|
};
|
|
}
|