M02 면적산출 SA01010100 에서 도구 막대 · 데이터 메뉴 · 우클릭 · 셀 서식 여섯 탭 · 찾기 · 메모 · 이름 관리 · 조건부 서식 · 유효성 · 이동 · 틀 고정 · 칸 넣기/지우기 · 데이터 도구 · 윤곽 · 수식 분석 · 되돌리기/다시 · 저장 안 하고 떠나기 · 다시 열기를 빈 값 · 같은 이름 · 긴 이름 · 취소 · Esc · Enter · 밝은/어두운 테마 예외까지 돌려 찾은 흠. - 격자(C): 틀고정이 있으면 본문 판이 그릴 행열을 틀고정 폭만큼 덜 셈 — 아래 · 오른쪽 끝이 빈 칸으로 남던 것(29 행 고정 때 1 행만 그림) · visibleRange 도 같음 · reveal 은 바로 그림 - 도구 막대: 세로 정렬 기본 표시 「위」 → 「아래」(칸 실제와 맞춤) · 크기 409 넘으면 409 · 테두리 · 단위 판이 단추 위로 떠 머리까지 덮던 것 → 단추 아래 · ✕ · 수식 안내 · 확대 글 대비(흐림 투명도 없앰 · --color-text-secondary · 밝은 5.88 · 어두운 7.91) - 병합(도구 막대 · 우클릭): 왼위 밖 값이 말없이 지워지던 것 → 엑셀처럼 먼저 물음(confirmMerge) - 셀 서식 · 이동 · 칸 넣기/지우기 · 텍스트 나누기 · 중복 제거 · 연속 채우기 · 메모 창: 열 때 초점이 창 밖(BODY)이라 Esc · Enter 가 안 먹던 것 → dialogKeys(창 안 초점 · Esc 닫기 · Enter 실행) · 셀 서식 창 탭마다 높이가 널뛰던 것 → 본문 최소 높이 - 우클릭 메뉴(ui_template 공용): Esc 로 닫힘 · 화면 밖이면 안쪽으로 당김 · 데이터 ▾ 메뉴와 알약 우클릭 메뉴가 화면 좌표를 부품 기준 자리로 써 오른쪽 아래로 떨어지던 것 고침 · 행 · 열 머리 메뉴에 잘라내기 · 붙여넣기(안내) 더함 - 자동 필터 ▼ 가 칸 밑에 깔려 눌리지 않던 것(z-index) · 값 목록이 격자 아래 끝에서 잘리면 위로 뒤집음 - Σ: 여러 칸을 고르면 첫 칸에 =SUM() 편집을 열어 Enter 로 값을 덮던 것 → 엑셀처럼 열마다 바로 아래(또는 고름 끝 빈 칸)에 합계를 바로 넣음(autoSumCells) - 조건부 서식: 빈 값이 「보다큼 0」 규칙이 되던 것 · #RRGGBB 아닌 색 · 색 없는 규칙 → 안내 후 막음 - 이름 관리: 255 자 넘는 이름 막음 · 긴 이름에 창이 화면 끝까지 늘던 것 · 시트 id(s1) 대신 시트 이름 · 「이름 바꾸기」 가 브라우저 prompt(자동 취소됨)라 안 되던 것 → 줄 안에서 고침 - 표시 형식 「@」 를 수에 걸면 빈 칸이 되던 것 → 엑셀처럼 일반으로 보임 - 연속 데이터 채우기 · 텍스트 나누기가 칸 서식을 지우던 것 → 서식 유지 - 틀 고정 「지금 칸 기준」 을 먼 칸(Z1000)에서 하면 고정 판이 격자를 다 덮어 못 움직이던 것 → 화면 안쪽 칸에서만(안내) ORCA(IP http://100.72.32.27:5181 · 메인 폴더 탭 · 판정은 수치): 목록 80 항목 밝은 80/80 · 어두운 80/80 · 사각형 겹침 · 잘림 · 화면 밖 · 대비 3 미만 0 건. 시험 — test_stage3_autosum (여러 칸 넷 더함) · test_stage2_data_tools · test_stage2_format_code · spreadsheet_b_test_entry 197/197(@ 하나 더함) · test_spreadsheet_size(css gzip 3121 · 합 67403) 통과. 시험으로 바꾼 칸은 저장 안 하고 「다시 열기」 로 버림. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W9YEkMnYQnNnRZRPXoxFsw
330 lines
13 KiB
TypeScript
330 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 { 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: "눈금선 보이기 · 숨기기",
|
||
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 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());
|
||
},
|
||
},
|
||
};
|
||
}
|