feat(spreadsheet): D 잇기 — 데이터 메뉴(조건부 서식 · 유효성 · 이름 · 눈금선) · 유효성 검사 · 이름 상자 · 여러 범위

- spreadsheet_data_menu.ts(새) — [↑] [↓] [필터] [데이터 ▾] · 패널 창(sub4 조건부 서식 · 유효성 · 이름 관리) · 눈금선 · 유효성 목록 ▼ · Alt+↓
- spreadsheet_editor.ts — 확정 때 유효성 검사(막음 = 편집 유지 · 경고 = 알림) · 주소 상자에 이름 = 그 범위로 · 새 이름 = 지금 고름에 정의
- spreadsheet_mouse.ts · spreadsheet_selection.ts — Ctrl+누르기 여러 범위 · Shift 는 마지막 범위
- spreadsheet_keys.ts · spreadsheet.ts — 유효성에 막히면 이동 · 시트 바꿈 멈춤
- spreadsheet_extras.ts — 정렬 · 필터를 데이터 메뉴로 옮김

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L6KXAabDTenEU7hrDQmCKY
This commit is contained in:
2026-09-27 21:13:10 +09:00
co-authored by Claude Opus 5.5
parent 015c35ad0e
commit 61d9c0de58
8 changed files with 320 additions and 59 deletions
+49
View File
@@ -257,3 +257,52 @@
.ss-hint__item.is-on {
background: #ddf4ff;
}
/* 데이터 메뉴 — 패널 창 · 유효성 목록 ▼ */
.ss-dialog {
position: absolute;
top: 4px;
right: 16px;
z-index: 21;
max-height: calc(100% - 8px);
overflow: auto;
border: 1px solid #d0d7de;
border-radius: 6px;
background: #fff;
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
}
.ss-dialog[hidden],
.ss-list-arrow[hidden] {
display: none;
}
.ss-dialog__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 8px;
border-bottom: 1px solid #d0d7de;
font-weight: bold;
}
.ss-dialog__body {
padding: 8px;
}
.ss-list-arrow {
position: absolute;
z-index: 8;
width: 16px;
padding: 0;
border: 1px solid #8c959f;
background: #f6f8fa;
font-size: 9px;
cursor: pointer;
}
.ss-validation__dropdown {
position: absolute;
z-index: 20;
}
+2 -1
View File
@@ -74,7 +74,7 @@ export interface SpreadsheetHandle {
/** 움직이는 끝(닻에서 먼 쪽) — 행열 전체면 활성 칸 쪽 */
function farCorner(sel: Selection): { r: number; c: number } {
const g = sel.범위[0];
const g = sel.범위[sel.범위.length - 1];
const a = sel.기준;
const whole = (lo: number, hi: number, last: number): boolean => lo === 0 && hi === last;
return {
@@ -222,6 +222,7 @@ export function createSpreadsheet(
const sheet = ctx.book.시트.find((s) => s.id === sheetId);
if (!sheet || sheetId === ctx.selection.시트) return;
ed.commit();
if (ed.editing()) return; // 유효성에 막힘
ctx.book.활성 = sheetId;
ctx.selection = memory.get(sheetId) ?? selectCell(sheet, 0, 0);
grid.render();
@@ -0,0 +1,213 @@
/* =============================================================================
* spreadsheet_data_menu.ts (주인 D)
* 데이터 단추 · 메뉴 잇기 — 수식 입력줄 앞 [↑] [↓] [필터] [데이터 ▾].
* 정렬 · 자동 필터(sub3 `spreadsheet_sort_dialog` · `spreadsheet_filter`) ·
* 조건부 서식 · 데이터 유효성 · 이름 관리(sub4 패널 — 부품 오른위 창에 띄움) · 눈금선 보이기/숨기기.
* 유효성 목록 칸은 고르면 칸 옆 ▼ · Alt+↓ 로 값 목록(필터 머리 칸이면 필터 목록이 먼저).
* ========================================================================== */
import "./spreadsheet_stage3.css"; // sub4 패널 스타일(잇는 쪽이 붙임)
import { createMapContextMenu } from "@ui/ui_template_context_menu";
import { el } from "@ui/ui_template_elements";
import { parseRange, toA1 } from "./spreadsheet_address";
import { createCondFmtPanel } from "./spreadsheet_condfmt";
import { attachFilter, toggleFilter } from "./spreadsheet_filter";
import { createNameManagerPanel } from "./spreadsheet_names";
import { openSortDialog, sortSelection } from "./spreadsheet_sort_dialog";
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+↓)",
};
export interface DataMenu {
/** 수식 입력줄 앞에 둘 단추들 */
buttons: HTMLElement[];
keys: Pick<KeyHooks, "filter" | "dropdown">;
part: PartHandle;
}
export function mountDataMenu(ctx: SpreadsheetContext, back: () => void): DataMenu {
const filter = attachFilter(ctx);
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();
});
function openPanel(name: string, make: (c: SpreadsheetContext) => PartHandle): void {
closePanel();
panel = make(ctx);
title.textContent = name.replace("…", "");
if (panel.root) body.append(panel.root);
dialog.removeAttribute("hidden");
}
function openMenu(anchor: HTMLElement): void {
const r = anchor.getBoundingClientRect();
const items: [string, () => void][] = [
[TEXT.sort, () => ((closeSort = openSortDialog(ctx)), undefined)],
[TEXT.filterItem, () => (toggleFilter(ctx), back())],
[TEXT.condfmt, () => openPanel(TEXT.condfmt, createCondFmtPanel)],
[TEXT.validation, () => openPanel(TEXT.validation, createValidationPanel)],
[TEXT.names, () => openPanel(TEXT.names, createNameManagerPanel)],
[TEXT.grid, () => (toggleGridlines(), back())],
];
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);
const buttons = ctx.readOnly
? []
: [
button("↑", TEXT.asc, () => sortSelection(ctx, false)),
button("↓", TEXT.desc, () => sortSelection(ctx, true)),
button("필터", TEXT.filter, () => toggleFilter(ctx)),
button(TEXT.menu, TEXT.menu, openMenu, true),
];
return {
buttons,
keys: {
filter: () => toggleFilter(ctx),
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();
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();
menu.element.remove();
dialog.remove();
arrow.remove();
buttons.forEach((b) => b.remove());
},
},
};
}
+31 -6
View File
@@ -8,12 +8,14 @@
* 수식 입력줄(방향키가 글자 사이를 옮김). F2 가 둘을 바꿈.
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import { el, showToast } from "@ui/ui_template_elements";
import { moveFormula } from "./spreadsheet_refshift";
import { colName, parseA1, parseRange, rangeToA1, toA1 } from "./spreadsheet_address";
import { funcHelp, suggestFunctions, type FuncHelp } from "./spreadsheet_func_help";
import { defineName, resolveName } from "./spreadsheet_names";
import { tokenize } from "./spreadsheet_parser";
import { cellsOf, move, selectCell } from "./spreadsheet_selection";
import { checkValue, findValidation } from "./spreadsheet_validation";
import type { Cell, CellAddress, CellStyle, Sheet } from "./spreadsheet_types";
import type {
CellBox,
@@ -329,10 +331,12 @@ export function createEditor(ctx: SpreadsheetContext): Editor {
focus();
}
function write(cells: CellAddress[], value: string, from: CellAddress): void {
if (!st) return;
/** 칸에 적음 — 유효성(sub4) 「막음」 을 어기면 안 적고 false(편집 그대로 · 엑셀처럼 다시 치게) */
function write(cells: CellAddress[], value: string, from: CellAddress): boolean {
if (!st) return true;
const sheet = sheetOf(st.sheet);
const out: Record<string, Cell | null> = {};
const warn = new Set<string>();
for (const a of cells) {
let v = value;
if (v.length > 1 && v[0] === "=" && (a.r !== from.r || a.c !== from.c))
@@ -340,21 +344,29 @@ export function createEditor(ctx: SpreadsheetContext): Editor {
const key = toA1(a.r, a.c);
const next = textToCell(v, sheet.칸[key]);
if (next === null && !sheet.칸[key]) continue;
const rule = next?.값 !== undefined ? findValidation(sheet, a.r, a.c) : undefined;
const check = rule && checkValue(rule, next!.값!);
if (check && !check.ok) {
if (check.어기면 === "막음") return (showToast(check.메시지, "error"), false);
warn.add(check.메시지);
}
out[key] = next;
}
for (const m of warn) showToast(m, "warning");
if (Object.keys(out).length) ctx.dispatch({ 종류: "칸", 시트: sheet.id, 칸: out });
return true;
}
function commit(): void {
if (!st) return;
const value = text();
if (value !== st.original) write([st.at], value, st.at);
if (value !== st.original && !write([st.at], value, st.at)) return;
end();
}
function commitAll(): void {
if (!st) return;
write(cellsOf(sheetOf(st.sheet), ctx.selection), text(), st.at);
if (!write(cellsOf(sheetOf(st.sheet), ctx.selection), text(), st.at)) return;
end();
}
@@ -441,6 +453,19 @@ export function createEditor(ctx: SpreadsheetContext): Editor {
focus();
});
/** 이름 상자(sub4) — 있는 이름이면 그 범위로 · 없으면 지금 고름에 이름을 붙임(엑셀) */
function nameBox(name: string): void {
const hit = resolveName(ctx.book, ctx.selection.시트, name);
if (hit) {
if (hit.시트 !== ctx.selection.시트) ctx.showSheet(hit.시트);
const at = { r: hit.범위.r0, c: hit.범위.c0 };
return ctx.select({ 시트: hit.시트, 범위: [hit.범위], 활성: at, 기준: { ...at } }, true);
}
const why = ctx.readOnly ? null : defineName(ctx, name, ctx.selection.범위[0]);
if (why) showToast(why, "error");
sync();
}
function goTo(value: string): void {
const bang = value.lastIndexOf("!");
let sheet = ctx.sheet();
@@ -454,7 +479,7 @@ export function createEditor(ctx: SpreadsheetContext): Editor {
const body = value.slice(bang + 1).replace(/\$/g, "");
const one = parseA1(body);
const g = parseRange(body);
if (!g) return sync();
if (!g) return nameBox(value.trim());
if (one) return ctx.select(selectCell(sheet, one.r, one.c), true);
const at = { r: g.r0, c: g.c0 };
ctx.select({ 시트: sheet.id, 범위: [g], 활성: at, 기준: { ...at } }, true);
+7 -46
View File
@@ -2,22 +2,20 @@
* spreadsheet_extras.ts (주인 D)
* 2단계 부품 잇기(sub1 파일들의 「잇는 법」) — 찾기 패널 · 메모 풍선(마우스 올림) · 메모 편집(Shift+F2) ·
* 서식 붓(수식 입력줄 앞 단추 · 두 번 누르면 계속 · Esc 끔) · 골라 붙여넣기(Ctrl+Shift+V → 값만 · 서식만 · 수식만) ·
* 도구 모음을 누른 뒤 초점을 격자로 되돌림 · 정렬(↑ ↓ 정렬…) · 자동 필터(단추 · Ctrl+Shift+L · Alt+↓).
* 도구 모음을 누른 뒤 초점을 격자로 되돌림 · 데이터 단추 · 메뉴는 `spreadsheet_data_menu.ts`.
* 빨간 세모는 C 격자 · 우클릭 [메모 …] 는 E 메뉴 몫(브레인 배정).
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import { parseRange } from "./spreadsheet_address";
import { parseClipboard } from "./spreadsheet_clipboard";
import { attachComments, getComment, setComment } from "./spreadsheet_comments";
import { mountDataMenu } from "./spreadsheet_data_menu";
import type { Editor } from "./spreadsheet_editor";
import { attachFilter, toggleFilter } from "./spreadsheet_filter";
import { mountFindPanel } from "./spreadsheet_find_panel";
import { createFormatPainter } from "./spreadsheet_format_painter";
import type { KeyHooks } from "./spreadsheet_keys";
import type { MouseHooks } from "./spreadsheet_mouse";
import { pasteSpecial, type PasteSpecialMode } from "./spreadsheet_paste_special";
import { openSortDialog, sortSelection } from "./spreadsheet_sort_dialog";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
const TEXT = {
@@ -25,10 +23,6 @@ const TEXT = {
pasteTitle: "골라 붙여넣기",
modes: { 값: "값만", 서식: "서식만", 수식: "수식만" } as Record<PasteSpecialMode, string>,
memo: "메모 — Ctrl+Enter 또는 밖을 누르면 적용 · Esc 취소",
sortAsc: "오름차순 정렬(활성 칸 열 기준)",
sortDesc: "내림차순 정렬(활성 칸 열 기준)",
sortDialog: "정렬 — 기준 여럿 · 머리 행",
filter: "자동 필터 켜기 · 끄기(Ctrl+Shift+L · 머리 칸에서 Alt+↓ 값 목록)",
};
export interface Extras {
@@ -115,32 +109,9 @@ export function attachExtras(
});
if (!ctx.readOnly) ed.bar.prepend(brush);
// ── 정렬 · 필터(sub3) — 수식 입력줄 앞 데이터 단추 ───────────────────────
const filter = attachFilter(ctx);
let closeSort: (() => void) | null = null;
const dataButton = (text: string, title: string, run: () => void, dialog = false) => {
const b = el("button", { className: "ss-brush", text, attrs: { type: "button", title } });
b.addEventListener("click", () => {
run();
if (!dialog) back();
});
return b;
};
const data = [
dataButton("↑", TEXT.sortAsc, () => sortSelection(ctx, false)),
dataButton("↓", TEXT.sortDesc, () => sortSelection(ctx, true)),
dataButton(
"정렬…",
TEXT.sortDialog,
() => {
closeSort?.();
closeSort = openSortDialog(ctx);
},
true,
),
dataButton("필터", TEXT.filter, () => toggleFilter(ctx)),
];
if (!ctx.readOnly) brush.after(...data);
// ── 데이터 단추 · 메뉴(정렬 · 필터 · 조건부 서식 · 유효성 · 이름 · 눈금선) ─────────
const data = mountDataMenu(ctx, back);
brush.after(...data.buttons);
// ── 골라 붙여넣기 ─────────────────────────────────────────────────────────
let armed = false;
@@ -201,8 +172,6 @@ export function attachExtras(
root: null,
refresh: () => showBrush(),
destroy() {
closeSort?.();
data.forEach((b) => b.remove());
ctx.root.removeEventListener("paste", onPaste, true);
toolbarRoot?.removeEventListener("click", onToolbarClick);
toolbarRoot?.removeEventListener("change", back);
@@ -217,15 +186,7 @@ export function attachExtras(
find: (replace) => find.open(replace),
pasteSpecial: () => (armed = true),
comment: editMemo,
filter: () => toggleFilter(ctx),
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 false;
filter.open(c);
return true;
},
...data.keys,
escape() {
painter.clear();
sticky = false;
@@ -248,6 +209,6 @@ export function attachExtras(
put(comments.root!, hit.r, hit.c); // showBalloon 의 cellBox 자리 → 화면 자리로 고침
},
},
parts: [find, comments, filter, self],
parts: [find, comments, data.part, self],
};
}
+3 -1
View File
@@ -68,12 +68,14 @@ export function attachKeys(ctx: SpreadsheetContext, ed: Editor, hooks: KeyHooks
}
if (ctrl) return (ed.commitAll(), true);
ed.commit();
if (ed.editing()) return true; // 유효성에 막힘
next(e.shiftKey ? -1 : 1, 0);
return true;
}
case "Tab":
if (!e.shiftKey && ed.accept()) return true; // 함수 자동 완성
ed.commit();
if (ed.editing()) return true;
next(0, e.shiftKey ? -1 : 1);
return true;
case "Escape":
@@ -92,7 +94,7 @@ export function attachKeys(ctx: SpreadsheetContext, ed: Editor, hooks: KeyHooks
}
if (e.shiftKey || ctrl) return false; // 글자 고르기 · 낱말 건너기는 textarea 에 맡김
ed.commit();
go(move(ctx.sheet(), ctx.selection, arrow[0], arrow[1]));
if (!ed.editing()) go(move(ctx.sheet(), ctx.selection, arrow[0], arrow[1]));
return true;
}
+13 -3
View File
@@ -106,7 +106,7 @@ export function attachMouse(
function fillDrag(): void {
const sheet = ctx.sheet();
const src = ctx.selection.범위[0];
const src = ctx.selection.범위[ctx.selection.범위.length - 1];
let result = { target: src, clear: null as CellRange | null };
drag(
(p) => {
@@ -186,6 +186,7 @@ export function attachMouse(
return;
}
ed.commit();
if (ed.editing()) return; // 유효성에 막힘
}
ed.focus();
if (ctx.readOnly && hit.kind === "fillHandle") hit.kind = "cell";
@@ -205,9 +206,17 @@ export function attachMouse(
return drag((p) => ctx.select(selectCols(sheet, a, p.c, a)));
}
case "cell": {
const first = e.shiftKey
// Ctrl = 범위 더함(여러 범위) · Shift = 마지막 범위를 늘림
const keep =
e.ctrlKey || e.metaKey
? ctx.selection.범위
: e.shiftKey
? ctx.selection.범위.slice(0, -1)
: [];
const one = e.shiftKey
? selectSpan(sheet, ctx.selection.기준, hit)
: selectCell(sheet, hit.r, hit.c);
const first = { ...one, 범위: [...keep, ...one.범위] };
ctx.select(first);
const anchor = first.기준;
let last = `${hit.r},${hit.c}`;
@@ -215,7 +224,8 @@ export function attachMouse(
(p) => {
if (`${p.r},${p.c}` === last) return;
last = `${p.r},${p.c}`;
ctx.select(selectSpan(sheet, anchor, p));
const span = selectSpan(sheet, anchor, p);
ctx.select({ ...span, 범위: [...keep, ...span.범위] });
},
() => hooks.selected?.(),
);
@@ -202,7 +202,7 @@ export function move(
if (to.r === edge.r && to.c === edge.c) return selectCell(sheet, from.r, from.c);
return selectCell(sheet, to.r, to.c);
}
const g = sel.범위[0] ?? span(sel.활성, sel.활성);
const g = sel.범위[sel.범위.length - 1] ?? span(sel.활성, sel.활성); // 여러 범위면 마지막(활성) 범위
const anchor = sel.기준;
// 움직이는 끝 = 닻에서 먼 쪽
const far = {
@@ -218,7 +218,7 @@ export function move(
}
return {
시트: sheet.id,
범위: [expand(span(anchor, to), list)],
범위: [...sel.범위.slice(0, -1), expand(span(anchor, to), list)],
활성: { ...sel.활성 },
기준: { ...anchor },
};