Merge remote-tracking branch 'origin/sub_laptop_4' into sub_laptop_3

# Conflicts:
#	A00_Common/spreadsheet/spreadsheet_types.ts
This commit is contained in:
2026-09-27 21:01:37 +09:00
8 changed files with 1142 additions and 2 deletions
@@ -0,0 +1,272 @@
/* =============================================================================
* spreadsheet_condfmt.ts (3단계 · 주인 sub_laptop_4)
* 조건부 서식 — 칸 값이 규칙에 맞으면 채움색 · 글자색을 덮어씀. 저장 자리 `Sheet.조건부서식`
* (0 계약 `spreadsheet_types.ts` 에 방금 더함 · 계약 더함).
*
* 규칙 차례 = 우선순위(배열 앞쪽이 먼저 이김 · 엑셀 「규칙 관리」 위쪽과 같음). 스타일은 `서식` 표
* 번호를 안 씀 — 규칙마다 채움 · 글자색을 직접 가져 표 번호 조율(A 몫)이 필요 없음. 「수식이 참」
* 조건은 임의 수식 글을 파싱·계산해야 해서(엔진 A) 이 파일은 `evalFormula` 콜백만 받고 엔진을 모름.
*
* 잇는 법(A · C · D · sub7 — 지금은 하나도 안 이어짐):
* 1) 격자(C)가 칸을 그릴 때 `resolveCondFmtStyle(sheet, r, c, env)` 로 덮어쓸 스타일을 얻어
* 지금 셀 스타일 위에 얹음(env.value 는 `ctx.engine.value` 를 수 · 글 · 참거짓 · null 로 바꿔 줌).
* 2) `env.evalFormula(식, r, c)` 구현 — `spreadsheet_parser.parse(식)` 뒤 `spreadsheet_eval` 로
* 풀어 참거짓으로(A 가 만들 자리 · 지금 없음).
* 3) 행 · 열 넣기 · 지우기 뒤 `remapCondFmtRanges` 를 그 시트 `조건부서식` 에 적용해 범위를 옮김
* (`spreadsheet_commands.insertDelete` 가 지금 이 규칙을 모름 — refshift 참조 옮김과 같은 자리에
* 걸어야 함 · A 가 채울 자리).
* 4) 도구 모음 · 우클릭 메뉴에 [조건부 서식] 항목을 두고 `createCondFmtPanel(ctx)` 를 띄움.
* ========================================================================== */
import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements";
import { inRange, rangeToA1 } from "./spreadsheet_address";
import type {
CellRange,
CondFmtCompareOp,
CondFmtCondition,
CondFmtRule,
CondFmtStyle,
Sheet,
} from "./spreadsheet_types";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
// css 는 아직 안 이어짐(잇는 법 머리 참고) — 실제로 붙일 때 `import "./spreadsheet_stage3.css";` 한 줄 더함.
export type { CondFmtCompareOp, CondFmtCondition, CondFmtRule, CondFmtStyle };
/** 칸 값을 이미 수 · 글 · 참거짓 · 빈칸으로 바꿔 넘김(Frac → 수는 호출부 몫 — 이 파일은 엔진을 모름). */
export type CondFmtValue = number | string | boolean | null;
function testCondition(
condition: CondFmtCondition,
value: CondFmtValue,
evalFormula: (식: string) => boolean,
): boolean {
switch (condition.종류) {
case "비교": {
if (typeof value !== "number") return false;
switch (condition.연산) {
case "보다큼":
return value > condition.값;
case "보다작음":
return value < condition.값;
case "크거나같음":
return value >= condition.값;
case "작거나같음":
return value <= condition.값;
case "같음":
return value === condition.값;
case "다름":
return value !== condition.값;
}
break;
}
case "사이":
return typeof value === "number" && value >= condition.아래 && value <= condition.위;
case "글포함":
return typeof value === "string" && value.includes(condition.글);
case "수식":
return evalFormula(condition.식);
}
return false;
}
export interface CondFmtEnv {
value(r: number, c: number): CondFmtValue;
/** 없으면 「수식이 참」 조건은 늘 거짓 */
evalFormula?(formula: string, r: number, c: number): boolean;
}
/** 칸 하나에 먹일 조건부 서식 — 처음 맞는 규칙(우선순위 위) · 없으면 null. */
export function resolveCondFmtStyle(
sheet: Sheet,
r: number,
c: number,
env: CondFmtEnv,
): CondFmtStyle | null {
for (const rule of sheet.조건부서식 ?? []) {
if (!rule.범위.some((range) => inRange(range, r, c))) continue;
const value = env.value(r, c);
const hit = testCondition(rule.조건, value, (식) => env.evalFormula?.(식, r, c) ?? false);
if (hit) return rule.스타일;
}
return null;
}
/** 행 · 열 넣기 · 지우기 뒤 범위 옮김 — `shift` 가 null 이면 그 범위는 버림(규칙이 범위를 다 잃으면 규칙째 버림). */
export function remapCondFmtRanges(
rules: CondFmtRule[],
shift: (range: CellRange) => CellRange | null,
): CondFmtRule[] {
const out: CondFmtRule[] = [];
for (const rule of rules) {
const moved = rule.범위.map(shift).filter((r): r is CellRange => r !== null);
if (moved.length) out.push({ ...rule, 범위: moved });
}
return out;
}
function applySheet(ctx: SpreadsheetContext, next: Sheet): void {
ctx.dispatch({ 종류: "시트통째", 시트: next });
}
let seq = 0;
const newId = (): string => `cf${Date.now().toString(36)}${seq++}`;
export function addCondFmtRule(ctx: SpreadsheetContext, rule: Omit<CondFmtRule, "id">): void {
const sheet = ctx.sheet();
applySheet(ctx, {
...sheet,
조건부서식: [...(sheet.조건부서식 ?? []), { ...rule, id: newId() }],
});
}
export function removeCondFmtRule(ctx: SpreadsheetContext, id: string): void {
const sheet = ctx.sheet();
applySheet(ctx, { ...sheet, 조건부서식: (sheet.조건부서식 ?? []).filter((r) => r.id !== id) });
}
/** 우선순위 한 칸 옮김(엑셀 「규칙 관리」 위 · 아래 화살표와 같음). */
export function moveCondFmtRule(ctx: SpreadsheetContext, id: string, dir: -1 | 1): void {
const sheet = ctx.sheet();
const list = [...(sheet.조건부서식 ?? [])];
const idx = list.findIndex((r) => r.id === id);
const swapWith = idx + dir;
if (idx < 0 || swapWith < 0 || swapWith >= list.length) return;
[list[idx], list[swapWith]] = [list[swapWith], list[idx]];
applySheet(ctx, { ...sheet, 조건부서식: list });
}
const conditionText = (c: CondFmtCondition): string => {
switch (c.종류) {
case "비교":
return `${c.연산} ${c.값}`;
case "사이":
return `${c.아래} ~ ${c.위} 사이`;
case "글포함":
return `「${c.글}」 포함`;
case "수식":
return `수식 =${c.식}`;
}
};
/** 규칙 관리 창 — 지금 시트 규칙 목록 + 고른 범위에 규칙 더하기. 잇는 법 머리 주석 참고. */
export function createCondFmtPanel(ctx: SpreadsheetContext): PartHandle {
const root = el("div", { className: "ss-condfmt" });
const list = el("ul", { className: "ss-condfmt__list" });
const compareOps: CondFmtCompareOp[] = [
"보다큼",
"보다작음",
"크거나같음",
"작거나같음",
"같음",
"다름",
];
const kindSelect = createSelectField({
label: "조건",
options: [
{ value: "비교", text: "값 비교" },
{ value: "사이", text: "사이" },
{ value: "글포함", text: "글 포함" },
{ value: "수식", text: "수식이 참" },
],
});
const opSelect = createSelectField({
label: "연산",
options: compareOps.map((op) => ({ value: op, text: op })),
});
const val1 = createInputField({ label: "값(또는 아래 · 글 · 수식)" });
const val2 = createInputField({ label: "위(사이일 때만)" });
const fill = createInputField({ label: "채움색", type: "text", placeholder: "#RRGGBB" });
const fontColor = createInputField({ label: "글자색", type: "text", placeholder: "#RRGGBB" });
const addBtn = createButton({ label: "규칙 더하기" });
addBtn.addEventListener("click", () => {
const 범위 = ctx.selection.범위;
if (!범위.length) return;
const 조건 = buildCondition(kindSelect.select.value, opSelect.select.value, val1, val2);
if (!조건) return;
const 스타일: CondFmtStyle = {};
if (fill.input.value) 스타일.채움 = fill.input.value;
if (fontColor.input.value) 스타일.글자색 = fontColor.input.value;
addCondFmtRule(ctx, { 범위: [...범위], 조건, 스타일 });
render();
});
function buildCondition(
kind: string,
op: string,
a: ReturnType<typeof createInputField>,
b: ReturnType<typeof createInputField>,
): CondFmtCondition | null {
if (kind === "비교") {
const n = Number(a.input.value);
return Number.isFinite(n) ? { 종류: "비교", 연산: op as CondFmtCompareOp, 값: n } : null;
}
if (kind === "사이") {
const 아래 = Number(a.input.value);
const 위 = Number(b.input.value);
return Number.isFinite(아래) && Number.isFinite(위) ? { 종류: "사이", 아래, 위 } : null;
}
if (kind === "글포함") return a.input.value ? { 종류: "글포함", 글: a.input.value } : null;
if (kind === "수식") return a.input.value ? { 종류: "수식", 식: a.input.value } : null;
return null;
}
function render(): void {
const rules = ctx.sheet().조건부서식 ?? [];
list.replaceChildren(
...rules.map((rule, idx) => {
const li = el("li", { className: "ss-condfmt__row" });
const swatch = el("span", { className: "ss-condfmt__swatch" });
if (rule.스타일.채움) swatch.style.background = rule.스타일.채움;
const label = el("span", {
text: `${rule.범위.map(rangeToA1).join(", ")} — ${conditionText(rule.조건)}`,
});
const up = createButton({ label: "↑", variant: "ghost" });
up.disabled = idx === 0;
up.addEventListener("click", () => {
moveCondFmtRule(ctx, rule.id, -1);
render();
});
const down = createButton({ label: "↓", variant: "ghost" });
down.disabled = idx === rules.length - 1;
down.addEventListener("click", () => {
moveCondFmtRule(ctx, rule.id, 1);
render();
});
const del = createButton({ label: "지움", variant: "danger" });
del.addEventListener("click", () => {
removeCondFmtRule(ctx, rule.id);
render();
});
li.append(swatch, label, up, down, del);
return li;
}),
);
}
const form = el("div", {
className: "ss-condfmt__form",
children: [
kindSelect.root,
opSelect.root,
val1.root,
val2.root,
fill.root,
fontColor.root,
addBtn,
],
});
root.append(el("h4", { text: "조건부 서식 규칙" }), list, form);
render();
return {
root,
refresh: render,
destroy(): void {
root.remove();
},
};
}
+201
View File
@@ -0,0 +1,201 @@
/* =============================================================================
* spreadsheet_names.ts (3단계 · 주인 sub_laptop_4)
* 이름 정의 — 범위에 이름을 붙이고 수식에서 `=단가*수량` 처럼 씀. 저장 자리 `Sheet.이름정의`(0 계약에
* 방금 더함) — 이 이름을 정의한 시트 안 범위만 가리킴(다른 시트 참조는 3단계 밖).
*
* 수식 나무는 이미 `{type:"name", name}` 갈래를 가짐(계약 `spreadsheet_types.ts` 3절 · 정의 없으면
* `#NAME?`). 이 파일은 이름을 저장 · 관리만 하고 실제로 식을 풀 때 이름을 찾는 일은 A 몫.
*
* 잇는 법(A · D · sub7 — 지금은 하나도 안 이어짐):
* 1) `spreadsheet_eval.ts` 가 `{type:"name"}` 나무를 만나면 `resolveName(book, 식이든시트, 이름)` 을
* 불러 범위를 얻고, 범위가 칸 하나면 그 칸 값 · 여러 칸이면 `RangeValue` 로 바꿔 씀(없으면 지금처럼 `#NAME?`).
* 2) 편집기 이름 상자(주소 상자)에서 범위를 고른 채 이름을 치고 Enter 하면 `defineName(ctx, 이름, 범위)`.
* 3) 도구 모음 · 메뉴에 [이름 관리] 항목을 두고 `createNameManagerPanel(ctx)` 를 띄움.
* ========================================================================== */
import { createButton, createInputField, el } from "@ui/ui_template_elements";
import { rangeToA1 } from "./spreadsheet_address";
import type { CellRange, NamedRange, Sheet, Workbook } from "./spreadsheet_types";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
// css 는 아직 안 이어짐(잇는 법 머리 참고) — 실제로 붙일 때 `import "./spreadsheet_stage3.css";` 한 줄 더함.
export type { NamedRange };
const CELL_LIKE = /^[A-Za-z]{1,3}\d+$/;
const RESERVED = /^(TRUE|FALSE)$/i;
const NAME_RE = /^[\p{L}_][\p{L}\p{N}_.]*$/u;
/** 이름 규칙 — 글자 · 밑줄로 시작 · 칸 주소처럼 안 보임 · 겹치지 않음. 어기면 까닭 글 · 되면 null. */
export function invalidNameReason(name: string, existing: string[]): string | null {
if (!name) return "이름이 비었음";
if (!NAME_RE.test(name)) return "글자 · 숫자 · 밑줄만(첫 글자는 글자 · 밑줄)";
if (CELL_LIKE.test(name)) return "칸 주소처럼 보이는 이름은 못 씀";
if (RESERVED.test(name)) return "TRUE · FALSE 는 못 씀";
if (existing.some((n) => n.toLowerCase() === name.toLowerCase())) return "이미 있는 이름";
return null;
}
export function listNames(book: Workbook): { 이름: string; 시트: string; 범위: CellRange }[] {
return book.시트.flatMap((sheet) =>
(sheet.이름정의 ?? []).map((n) => ({ 이름: n.이름, 시트: sheet.id, 범위: n.범위 })),
);
}
/** 식이 든 시트의 이름부터 찾고(자기 시트 우선) · 없으면 다른 시트 이름도(느슨한 전역 대체). */
export function resolveName(
book: Workbook,
sheetId: string,
name: string,
): { 시트: string; 범위: CellRange } | null {
const lower = name.toLowerCase();
const own = book.시트.find((s) => s.id === sheetId);
const local = own?.이름정의?.find((n) => n.이름.toLowerCase() === lower);
if (local) return { 시트: sheetId, 범위: local.범위 };
for (const sheet of book.시트) {
const found = sheet.이름정의?.find((n) => n.이름.toLowerCase() === lower);
if (found) return { 시트: sheet.id, 범위: found.범위 };
}
return null;
}
function applySheet(ctx: SpreadsheetContext, sheetId: string, next: Sheet): void {
ctx.dispatch({ 종류: "시트통째", 시트: { ...next, id: sheetId } });
}
/** 지금 활성 시트에 이름을 더함 — 규칙 어기면 아무 일도 안 하고 까닭을 돌려줌(호출부가 토스트로). */
export function defineName(ctx: SpreadsheetContext, name: string, 범위: CellRange): string | null {
const reason = invalidNameReason(
name,
listNames(ctx.book).map((n) => n.이름),
);
if (reason) return reason;
const sheet = ctx.sheet();
applySheet(ctx, sheet.id, {
...sheet,
이름정의: [...(sheet.이름정의 ?? []), { 이름: name, 범위 }],
});
return null;
}
/** 이름을 지움 — 그 이름을 쓰던 식은 그대로 남아 다음 풀이 때 `#NAME?`(계약대로). */
export function deleteName(ctx: SpreadsheetContext, name: string): void {
const lower = name.toLowerCase();
for (const sheet of ctx.book.시트) {
if (!sheet.이름정의?.some((n) => n.이름.toLowerCase() === lower)) continue;
applySheet(ctx, sheet.id, {
...sheet,
이름정의: sheet.이름정의!.filter((n) => n.이름.toLowerCase() !== lower),
});
return;
}
}
/** 이름을 바꿈 — 정의뿐 아니라 모든 시트 식 글 속 이름 글자도 같이 바꿈(시트이름 바꾸기와 같은 규칙). */
export function renameName(
ctx: SpreadsheetContext,
oldName: string,
newName: string,
): string | null {
const reason = invalidNameReason(
newName,
listNames(ctx.book)
.map((n) => n.이름)
.filter((n) => n.toLowerCase() !== oldName.toLowerCase()),
);
if (reason) return reason;
const commands = ctx.book.시트
.map((sheet) => renamedSheet(sheet, oldName, newName))
.filter((s): s is Sheet => s !== null)
.map((sheet) => ({ 종류: "시트통째" as const, 시트: sheet }));
if (!commands.length) return null;
ctx.dispatch(commands.length === 1 ? commands[0] : { 종류: "묶음", 명령: commands });
return null;
}
function renamedSheet(sheet: Sheet, oldName: string, newName: string): Sheet | null {
// 한글 이름은 `\w` 밖이라 `\b` 가 안 먹음 — 글자 · 숫자 · 밑줄이 아닌 자리를 경계로 직접 잡음.
const re = new RegExp(`(?<![\\p{L}\\p{N}_])${escapeReg(oldName)}(?![\\p{L}\\p{N}_])`, "giu");
let changed = false;
const 칸: Sheet["칸"] = { ...sheet.칸 };
for (const [key, cell] of Object.entries(칸)) {
if (cell.식 !== undefined && re.test(cell.식)) {
re.lastIndex = 0;
칸[key] = { ...cell, 식: cell.식.replace(re, newName) };
changed = true;
}
}
const 이름정의 = sheet.이름정의?.map((n) =>
n.이름.toLowerCase() === oldName.toLowerCase() ? { ...n, 이름: newName } : n,
);
if (이름정의 && JSON.stringify(이름정의) !== JSON.stringify(sheet.이름정의)) changed = true;
return changed ? { ...sheet, 칸, ...(이름정의 ? { 이름정의 } : {}) } : null;
}
function escapeReg(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/** 이름 관리 창 — 통합문서 전체 이름 목록 + 고른 범위에 새 이름. */
export function createNameManagerPanel(ctx: SpreadsheetContext): PartHandle {
const root = el("div", { className: "ss-names" });
const list = el("ul", { className: "ss-names__list" });
const nameInput = createInputField({ label: "새 이름", placeholder: "단가" });
const addBtn = createButton({ label: "이름 더하기" });
let error: HTMLElement | null = null;
const showError = (msg: string | null): void => {
error?.remove();
error = null;
if (!msg) return;
error = el("p", { text: msg });
error.style.color = "var(--color-danger, red)";
root.append(error);
};
addBtn.addEventListener("click", () => {
const 범위 = ctx.selection.범위[0];
if (!범위) return;
const reason = defineName(ctx, nameInput.input.value.trim(), 범위);
showError(reason);
if (!reason) {
nameInput.input.value = "";
render();
}
});
function render(): void {
list.replaceChildren(
...listNames(ctx.book).map(({ 이름, 시트, 범위 }) => {
const li = el("li", { className: "ss-names__row" });
const label = el("span", { text: `${이름} = ${시트}!${rangeToA1(범위)}` });
const rename = createButton({ label: "이름 바꾸기", variant: "ghost" });
rename.addEventListener("click", () => {
const next = window.prompt("새 이름", 이름);
if (!next || next === 이름) return;
showError(renameName(ctx, 이름, next));
render();
});
const del = createButton({ label: "지움", variant: "danger" });
del.addEventListener("click", () => {
deleteName(ctx, 이름);
render();
});
li.append(label, rename, del);
return li;
}),
);
}
const form = el("div", { className: "ss-names__form", children: [nameInput.root, addBtn] });
root.append(el("h4", { text: "이름 관리" }), list, form);
render();
return {
root,
refresh: render,
destroy(): void {
root.remove();
},
};
}
@@ -0,0 +1,87 @@
/* =============================================================================
* spreadsheet_stage3.css (주인 sub_laptop_4)
* 조건부 서식 · 데이터 유효성 · 이름 정의 관리 창 — 화면 스타일 한 벌. 색 · 간격은 테마 토큰만.
* ========================================================================== */
.ss-condfmt,
.ss-validation,
.ss-names {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-8);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 8px;
min-width: 280px;
}
.ss-condfmt__list,
.ss-validation__list,
.ss-names__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.ss-condfmt__row,
.ss-validation__row,
.ss-names__row {
display: flex;
align-items: center;
gap: var(--spacing-4);
padding: var(--spacing-4);
border: 1px solid var(--color-border);
border-radius: 4px;
font-size: 0.85rem;
}
.ss-condfmt__row > span:first-of-type,
.ss-validation__row > span:first-of-type,
.ss-names__row > span:first-of-type {
flex: 1;
}
.ss-condfmt__swatch {
display: inline-block;
width: 14px;
height: 14px;
border: 1px solid var(--color-border);
border-radius: 2px;
flex-shrink: 0;
}
.ss-condfmt__form,
.ss-validation__form,
.ss-names__form {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-4);
align-items: flex-end;
border-top: 1px solid var(--color-border);
padding-top: var(--spacing-8);
}
.ss-validation__dropdown {
position: absolute;
z-index: 30;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
max-height: 160px;
overflow-y: auto;
}
.ss-validation__dropdown-item {
padding: 4px 8px;
cursor: pointer;
font-size: 0.85rem;
}
.ss-validation__dropdown-item:hover {
background: var(--color-mist-violet);
}
+58 -2
View File
@@ -1,5 +1,4 @@
/* =============================================================================
* spreadsheet_types.ts
/* ====================================================================== * spreadsheet_types.ts
* 산출근거 스프레드시트 계약 — 저장 문서 · 칸 · 서식 · 수식 나무 · 값 · 명령 · 계산 엔진 모양.
* 근거 `tmp/M02_분석/9_엑셀검토.md` · PLAN 11-8. 이 파일 · `spreadsheet_view_types.ts` · 견본 JSON 은
* 0 계약 주인만 고침 — 다른 창은 브레인에게 고침을 청함. 타입만 둠(서버 Node · 시험이 그대로 씀).
@@ -53,6 +52,10 @@
* E spreadsheet_menu.ts 우클릭 메뉴 ~300
* E spreadsheet_clipboard.ts 복사 · 잘라내기 · 붙여넣기(TSV · HTML) ~550
* E spreadsheet_toolbar.css ~150
* 3 spreadsheet_condfmt.ts 조건부 서식 규칙 · 관리 창(주인 sub_laptop_4 · 안 이어짐) ~260
* 3 spreadsheet_validation.ts 데이터 유효성 규칙 · 관리 창(주인 sub_laptop_4 · 안 이어짐) ~230
* 3 spreadsheet_names.ts 이름 정의 · 이름 관리 창(주인 sub_laptop_4 · 안 이어짐) ~220
* 3 spreadsheet_stage3.css 위 셋 화면 스타일 한 벌 ~90
* F common_util/common_util_spreadsheet_node.ts Node 진입(재계산 · 폴더 밖) ~40
* F common_util/common_util_spreadsheet.py 파이썬 껍데기(common_util_node_bundle) ~60
* F package.json `build:spreadsheet` · M02 `basis` 종류(Store · Layers) · 옛 산출근거 옮김 ·
@@ -162,6 +165,13 @@ export interface Sheet {
comments?: Record<string, string>;
/** 자동 필터(시트에 하나) */
필터?: AutoFilter;
/** 조건부 서식 — 배열 앞쪽이 우선순위 위(`spreadsheet_condfmt.ts`). */
조건부서식?: CondFmtRule[];
/** 데이터 유효성(`spreadsheet_validation.ts`). */
유효성?: DataValidationRule[];
/** 이 시트에서 정의한 이름 — 범위는 이 시트 안(`spreadsheet_names.ts`). 식 나무의 `{type:"name"}` 는
* A 의 eval 이 `resolveName` 으로 풀어야 함(지금 안 이어짐 — 8-3 절 참고). */
이름정의?: NamedRange[];
}
/** 새 시트 기본값 — 값은 사용자 협의로 확정(9_엑셀검토 8장 · 임의 확정 금지). 없으면 A 의 임시값. */
@@ -452,3 +462,49 @@ export interface ClipBlock {
원점: CellAddress | null;
출처: "aislo" | "google" | "excel" | "text";
}
// ═════════════════════════════════════════════════════════════════════════════
// 9. 3단계 — 조건부 서식 · 데이터 유효성 · 이름 정의 (Sheet 확장 · sub_laptop_4 계약 더함 · 안 이어짐)
// ═════════════════════════════════════════════════════════════════════════════
export type CondFmtCompareOp =
"보다큼" | "보다작음" | "크거나같음" | "작거나같음" | "같음" | "다름";
export type CondFmtCondition =
| { 종류: "비교"; 연산: CondFmtCompareOp; 값: number }
| { 종류: "사이"; 아래: number; 위: number }
| { 종류: "글포함"; 글: string }
/** 임의 수식 글 — A 의 파서 · eval 이 있어야 풀림(`spreadsheet_condfmt.ts` 머리 「잇는 법」). */
| { 종류: "수식"; 식: string };
/** 조건부 서식이 먹이는 스타일 — `서식` 표 번호를 안 씀(규칙마다 직접 가짐). */
export interface CondFmtStyle {
채움?: string;
글자색?: string;
굵게?: boolean;
}
export interface CondFmtRule {
id: string;
범위: CellRange[];
조건: CondFmtCondition;
스타일: CondFmtStyle;
}
export type DataValidationKind =
{ 종류: "목록"; 값들: string[] } | { 종류: "수범위"; 아래?: number; 위?: number };
export interface DataValidationRule {
id: string;
범위: CellRange[];
규칙: DataValidationKind;
/** 어긴 값을 넣으려 하면 막을지 · 경고만 하고 넣을지(둘 다 D 편집기 잇기 몫 · 지금 안 이어짐). */
어기면: "막음" | "경고";
메시지?: string;
}
/** 이름 정의 — 이 이름을 정의한 시트 안 범위만 가리킴(다른 시트를 가리키는 이름은 3단계 밖). */
export interface NamedRange {
이름: string;
범위: CellRange;
}
@@ -0,0 +1,219 @@
/* =============================================================================
* spreadsheet_validation.ts (3단계 · 주인 sub_laptop_4)
* 데이터 유효성 — 목록(드롭다운) · 수 범위. 어기면 막거나(칸에 못 들어감) 경고만(그래도 들어감).
* 저장 자리 `Sheet.유효성`(0 계약에 방금 더함). 규칙은 범위 하나에 하나만 먹임(엑셀과 같이 — 겹치면
* 배열 앞쪽이 이김).
*
* 잇는 법(D · sub7 — 지금은 하나도 안 이어짐):
* 1) 편집기(`spreadsheet_editor.ts`)가 칸 편집을 커밋하기 전에 `checkValue(rule, candidate)` 를
* 불러 `어기면 === "막음"` 이면 `dispatch` 를 안 부르고(입력 취소) 메시지를 토스트로 · `"경고"` 면
* 토스트만 띄우고 그대로 진행.
* 2) 칸 편집이 시작될 때(`editor.begin`) `findValidation(sheet, r, c)` 가 「목록」 규칙이면
* `createValidationDropdown(ctx, r, c, rule)` 을 칸 옆에 띄우고 고르면 그 값을 칸에 넣음.
* 3) 행 · 열 넣기 · 지우기 뒤 `remapValidationRanges` 를 그 시트 `유효성` 에 적용(조건부 서식과 같은
* 자리 · A 몫 · 지금 안 이어짐).
* 4) 도구 모음 · 메뉴에 [데이터 유효성] 항목을 두고 `createValidationPanel(ctx)` 를 띄움.
* ========================================================================== */
import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements";
import { inRange, rangeToA1 } from "./spreadsheet_address";
import type {
CellInput,
CellRange,
DataValidationKind,
DataValidationRule,
Sheet,
} from "./spreadsheet_types";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
// css 는 아직 안 이어짐(잇는 법 머리 참고) — 실제로 붙일 때 `import "./spreadsheet_stage3.css";` 한 줄 더함.
export type { DataValidationKind, DataValidationRule };
/** 칸이 든 범위를 덮는 첫 규칙(배열 앞쪽 우선) — 없으면 undefined. */
export function findValidation(sheet: Sheet, r: number, c: number): DataValidationRule | undefined {
return (sheet.유효성 ?? []).find((rule) => rule.범위.some((range) => inRange(range, r, c)));
}
export interface ValidationCheck {
ok: boolean;
어기면?: "막음" | "경고";
메시지: string;
}
function defaultMessage(kind: DataValidationKind): string {
if (kind.종류 === "목록") return `목록 안 값만 됨(${kind.값들.join(", ")})`;
const 아래 = kind.아래 ?? -Infinity;
const 위 = kind.위 ?? Infinity;
return `${아래 === -Infinity ? "" : 아래}${위 === Infinity ? " 이상" : ` ~ ${위}`} 수만 됨`;
}
function matchesKind(kind: DataValidationKind, candidate: CellInput): boolean {
if (kind.종류 === "목록")
return typeof candidate !== "boolean" && kind.값들.includes(String(candidate));
if (typeof candidate !== "number") return false;
if (kind.아래 !== undefined && candidate < kind.아래) return false;
if (kind.위 !== undefined && candidate > kind.위) return false;
return true;
}
/** 후보 값이 규칙을 지키는지 — 어기면 `어기면`(막음 · 경고)과 안내 글을 돌려줌. */
export function checkValue(rule: DataValidationRule, candidate: CellInput): ValidationCheck {
if (matchesKind(rule.규칙, candidate)) return { ok: true, 메시지: "" };
return { ok: false, 어기면: rule.어기면, 메시지: rule.메시지 || defaultMessage(rule.규칙) };
}
/** 행 · 열 넣기 · 지우기 뒤 범위 옮김 — `shift` 가 null 이면 그 범위는 버림(규칙이 범위를 다 잃으면 규칙째 버림). */
export function remapValidationRanges(
rules: DataValidationRule[],
shift: (range: CellRange) => CellRange | null,
): DataValidationRule[] {
const out: DataValidationRule[] = [];
for (const rule of rules) {
const moved = rule.범위.map(shift).filter((r): r is CellRange => r !== null);
if (moved.length) out.push({ ...rule, 범위: moved });
}
return out;
}
function applySheet(ctx: SpreadsheetContext, next: Sheet): void {
ctx.dispatch({ 종류: "시트통째", 시트: next });
}
let seq = 0;
const newId = (): string => `dv${Date.now().toString(36)}${seq++}`;
export function addValidationRule(
ctx: SpreadsheetContext,
rule: Omit<DataValidationRule, "id">,
): void {
const sheet = ctx.sheet();
applySheet(ctx, { ...sheet, 유효성: [...(sheet.유효성 ?? []), { ...rule, id: newId() }] });
}
export function removeValidationRule(ctx: SpreadsheetContext, id: string): void {
const sheet = ctx.sheet();
applySheet(ctx, { ...sheet, 유효성: (sheet.유효성 ?? []).filter((r) => r.id !== id) });
}
/** 목록 규칙일 때 칸 옆에 띄울 작은 드롭다운(고르면 `onPick` 이 불림 — 실제 `dispatch` 는 부른 쪽 몫). */
export function createValidationDropdown(
ctx: SpreadsheetContext,
r: number,
c: number,
rule: DataValidationRule,
onPick: (value: string) => void,
): HTMLElement | null {
if (rule.규칙.종류 !== "목록") return null;
const box = ctx.grid.cellBox(r, c);
const dropdown = el("div", { className: "ss-validation__dropdown" });
dropdown.style.left = `${box.x}px`;
dropdown.style.top = `${box.y + box.h}px`;
dropdown.style.minWidth = `${box.w}px`;
dropdown.replaceChildren(
...rule.규칙.값들.map((value) => {
const item = el("div", { className: "ss-validation__dropdown-item", text: value });
item.addEventListener("click", () => onPick(value));
return item;
}),
);
return dropdown;
}
const kindText = (k: DataValidationKind): string =>
k.종류 === "목록" ? `목록: ${k.값들.join(", ")}` : `수범위: ${k.아래 ?? "−∞"} ~ ${k.위 ?? "+∞"}`;
/** 유효성 관리 창 — 지금 시트 규칙 목록 + 고른 범위에 규칙 더하기. */
export function createValidationPanel(ctx: SpreadsheetContext): PartHandle {
const root = el("div", { className: "ss-validation" });
const list = el("ul", { className: "ss-validation__list" });
const kindSelect = createSelectField({
label: "규칙",
options: [
{ value: "목록", text: "목록(드롭다운)" },
{ value: "수범위", text: "수 범위" },
],
});
const listValues = createInputField({ label: "목록 값(쉼표로 나눔)" });
const min = createInputField({ label: "아래(수범위)" });
const max = createInputField({ label: "위(수범위)" });
const onBreak = createSelectField({
label: "어기면",
options: [
{ value: "막음", text: "막음" },
{ value: "경고", text: "경고" },
],
});
const message = createInputField({ label: "안내 글(생략 가능)" });
const addBtn = createButton({ label: "규칙 더하기" });
addBtn.addEventListener("click", () => {
const 범위 = ctx.selection.범위;
if (!범위.length) return;
const 규칙 = build규칙();
if (!규칙) return;
addValidationRule(ctx, {
범위: [...범위],
규칙,
어기면: onBreak.select.value as "막음" | "경고",
메시지: message.input.value || undefined,
});
render();
});
function build규칙(): DataValidationKind | null {
if (kindSelect.select.value === "목록") {
const 값들 = listValues.input.value
.split(",")
.map((s) => s.trim())
.filter(Boolean);
return 값들.length ? { 종류: "목록", 값들 } : null;
}
const 아래 = min.input.value ? Number(min.input.value) : undefined;
const 위 = max.input.value ? Number(max.input.value) : undefined;
return 아래 === undefined && 위 === undefined ? null : { 종류: "수범위", 아래, 위 };
}
function render(): void {
const rules = ctx.sheet().유효성 ?? [];
list.replaceChildren(
...rules.map((rule) => {
const li = el("li", { className: "ss-validation__row" });
const label = el("span", {
text: `${rule.범위.map(rangeToA1).join(", ")} — ${kindText(rule.규칙)}(${rule.어기면})`,
});
const del = createButton({ label: "지움", variant: "danger" });
del.addEventListener("click", () => {
removeValidationRule(ctx, rule.id);
render();
});
li.append(label, del);
return li;
}),
);
}
const form = el("div", {
className: "ss-validation__form",
children: [
kindSelect.root,
listValues.root,
min.root,
max.root,
onBreak.root,
message.root,
addBtn,
],
});
root.append(el("h4", { text: "데이터 유효성 규칙" }), list, form);
render();
return {
root,
refresh: render,
destroy(): void {
root.remove();
},
};
}
@@ -0,0 +1,157 @@
/* =============================================================================
* test_stage3_condfmt.ts — spreadsheet_condfmt 시험. 돌리기: npx tsx <이 파일>.
* ========================================================================== */
import {
addCondFmtRule,
moveCondFmtRule,
remapCondFmtRanges,
removeCondFmtRule,
resolveCondFmtStyle,
} from "../../../A00_Common/spreadsheet/spreadsheet_condfmt.ts";
import { assertEqual, makeBook, makeCtx, makeSheet, v } from "./test_stage2_helpers.ts";
import type { Sheet } from "../../../A00_Common/spreadsheet/spreadsheet_types.ts";
const range = { r0: 0, c0: 0, r1: 0, c1: 0 };
const sheet = makeSheet(
"s1",
{ A1: v(120) },
{
조건부서식: [
{
id: "cf1",
범위: [range],
조건: { 종류: "비교", 연산: "보다큼", 값: 100 },
스타일: { 채움: "#ffcccc" },
},
],
},
) as Sheet;
const book = makeBook([sheet]);
const env = { value: () => 120 };
assertEqual(
resolveCondFmtStyle(sheet, 0, 0, env),
{ 채움: "#ffcccc" },
"값 100 넘으면 스타일 먹임",
);
assertEqual(resolveCondFmtStyle(sheet, 5, 5, env), null, "범위 밖은 안 먹임");
const envLow = { value: () => 50 };
assertEqual(resolveCondFmtStyle(sheet, 0, 0, envLow), null, "조건 안 맞으면 null");
// 「사이」 · 「글포함」 · 「수식」 조건
const between = {
id: "b",
범위: [range],
조건: { 종류: "사이" as const, 아래: 10, 위: 20 },
스타일: {},
};
assertEqual(
resolveCondFmtStyle({ ...sheet, 조건부서식: [between] }, 0, 0, { value: () => 15 }),
{},
"사이 조건 참",
);
const contains = {
id: "c",
범위: [range],
조건: { 종류: "글포함" as const, 글: "돌" },
스타일: {},
};
assertEqual(
resolveCondFmtStyle({ ...sheet, 조건부서식: [contains] }, 0, 0, { value: () => "돌뒷길이" }),
{},
"글포함 조건 참",
);
const formula = { id: "f", 범위: [range], 조건: { 종류: "수식" as const, 식: "A1>0" }, 스타일: {} };
assertEqual(
resolveCondFmtStyle({ ...sheet, 조건부서식: [formula] }, 0, 0, {
value: () => 1,
evalFormula: (식) => 식 === "A1>0",
}),
{},
"수식 조건 — evalFormula 콜백으로 참",
);
assertEqual(
resolveCondFmtStyle({ ...sheet, 조건부서식: [formula] }, 0, 0, { value: () => 1 }),
null,
"evalFormula 없으면 수식 조건은 늘 거짓",
);
// 우선순위 — 배열 앞쪽이 이김
const two = makeSheet(
"s2",
{ A1: v(1) },
{
조건부서식: [
{
id: "first",
범위: [range],
조건: { 종류: "비교" as const, 연산: "같음" as const, 값: 1 },
스타일: { 채움: "red" },
},
{
id: "second",
범위: [range],
조건: { 종류: "비교" as const, 연산: "같음" as const, 값: 1 },
스타일: { 채움: "blue" },
},
],
},
) as Sheet;
assertEqual(
resolveCondFmtStyle(two, 0, 0, { value: () => 1 }),
{ 채움: "red" },
"먼저 나온 규칙이 이김",
);
// CRUD — 시트통째로 나감
const ctx = makeCtx(book, "s1");
addCondFmtRule(ctx, { 범위: [range], 조건: { 종류: "글포함", 글: "x" }, 스타일: { 굵게: true } });
assertEqual(ctx.applied.at(-1)?.종류, "시트통째", "addCondFmtRule 은 시트통째로 나감");
assertEqual(book.시트[0].조건부서식?.length, 2, "규칙이 더해짐");
const addedId = book.시트[0].조건부서식![1].id;
moveCondFmtRule(ctx, addedId, -1);
assertEqual(book.시트[0].조건부서식![0].id, addedId, "우선순위 위로 옮김");
removeCondFmtRule(ctx, "cf1");
assertEqual(
book.시트[0].조건부서식?.map((r) => r.id),
[addedId],
"규칙 지움",
);
// 행 · 열 넣기 뒤 범위 옮김
const shifted = remapCondFmtRanges(
[
{
id: "a",
범위: [{ r0: 0, c0: 0, r1: 0, c1: 0 }],
조건: { 종류: "글포함", 글: "x" },
스타일: {},
},
],
(r) => ({ ...r, r0: r.r0 + 1, r1: r.r1 + 1 }),
);
assertEqual(
shifted[0].범위[0],
{ r0: 1, c0: 0, r1: 1, c1: 0 },
"remapCondFmtRanges — 행 하나 밀림",
);
const dropped = remapCondFmtRanges(
[
{
id: "a",
범위: [{ r0: 0, c0: 0, r1: 0, c1: 0 }],
조건: { 종류: "글포함", 글: "x" },
스타일: {},
},
],
() => null,
);
assertEqual(dropped, [], "remapCondFmtRanges — 범위를 다 잃으면 규칙째 버림");
if (process.exitCode) process.exit(process.exitCode);
console.log("spreadsheet_condfmt 시험 끝");
@@ -0,0 +1,71 @@
/* =============================================================================
* test_stage3_names.ts — spreadsheet_names 시험. 돌리기: npx tsx <이 파일>.
* ========================================================================== */
import {
defineName,
deleteName,
invalidNameReason,
listNames,
renameName,
resolveName,
} from "../../../A00_Common/spreadsheet/spreadsheet_names.ts";
import { assertEqual, makeBook, makeCtx, makeSheet, f, v } from "./test_stage2_helpers.ts";
assertEqual(invalidNameReason("단가", []), null, "정상 이름은 통과");
assertEqual(invalidNameReason("", []), "이름이 비었음", "빈 이름 거절");
assertEqual(invalidNameReason("A1", []), "칸 주소처럼 보이는 이름은 못 씀", "칸 주소 꼴 거절");
assertEqual(
invalidNameReason("1단가", []),
"글자 · 숫자 · 밑줄만(첫 글자는 글자 · 밑줄)",
"숫자로 시작 거절",
);
assertEqual(invalidNameReason("TRUE", []), "TRUE · FALSE 는 못 씀", "예약어 거절");
assertEqual(
invalidNameReason("단가", ["단가"]),
"이미 있는 이름",
"겹치는 이름 거절(대소문자 안 가림)",
);
const range = { r0: 0, c0: 0, r1: 0, c1: 0 };
const s1 = makeSheet("s1", { A1: v(100) });
const s2 = makeSheet("s2", { B2: f("A1*2") });
const book = makeBook([s1, s2]);
const ctx = makeCtx(book, "s1");
assertEqual(defineName(ctx, "단가", range), null, "defineName 성공 — 까닭 없음");
assertEqual(ctx.applied.at(-1)?.종류, "시트통째", "defineName 은 시트통째로 나감");
assertEqual(listNames(book), [{ 이름: "단가", 시트: "s1", 범위: range }], "listNames 로 보임");
assertEqual(defineName(ctx, "단가", range), "이미 있는 이름", "같은 이름 두 번은 거절");
assertEqual(
resolveName(book, "s1", "단가"),
{ 시트: "s1", 범위: range },
"resolveName 제 시트에서 찾음",
);
assertEqual(
resolveName(book, "s2", "단가"),
{ 시트: "s1", 범위: range },
"resolveName 다른 시트에서도 느슨히 찾음",
);
assertEqual(resolveName(book, "s1", "없는이름"), null, "없는 이름은 null");
// 이름 바꾸기 — 정의 + 다른 시트 식 글 속 이름도 같이
const s2f = makeSheet("s2b", { B2: f("단가*2") });
const book2 = makeBook([s1, s2f]);
const ctx2 = makeCtx(book2, "s1");
defineName(ctx2, "단가", range);
const reason = renameName(ctx2, "단가", "단가2");
assertEqual(reason, null, "renameName 성공");
assertEqual(book2.시트[0].이름정의?.[0].이름, "단가2", "정의 이름 바뀜");
assertEqual(book2.시트[1].칸.B2.식, "단가2*2", "다른 시트 식 글 속 이름도 바뀜");
assertEqual(renameName(ctx2, "단가2", "TRUE"), "TRUE · FALSE 는 못 씀", "바꿀 이름도 규칙을 지킴");
deleteName(ctx2, "단가2");
assertEqual(book2.시트[0].이름정의, [], "deleteName 으로 지움");
assertEqual(book2.시트[1].칸.B2.식, "단가2*2", "지워도 식 글은 그대로 남음(#NAME? 은 풀이 때)");
if (process.exitCode) process.exit(process.exitCode);
console.log("spreadsheet_names 시험 끝");
@@ -0,0 +1,77 @@
/* =============================================================================
* test_stage3_validation.ts — spreadsheet_validation 시험. 돌리기: npx tsx <이 파일>.
* ========================================================================== */
import {
addValidationRule,
checkValue,
findValidation,
remapValidationRanges,
removeValidationRule,
} from "../../../A00_Common/spreadsheet/spreadsheet_validation.ts";
import { assertEqual, makeBook, makeCtx, makeSheet, v } from "./test_stage2_helpers.ts";
import type { Sheet } from "../../../A00_Common/spreadsheet/spreadsheet_types.ts";
const range = { r0: 0, c0: 0, r1: 0, c1: 0 };
const sheet = makeSheet(
"s1",
{ A1: v("사과") },
{
유효성: [
{ id: "v1", 범위: [range], 규칙: { 종류: "목록", 값들: ["사과", "배"] }, 어기면: "막음" },
],
},
) as Sheet;
const book = makeBook([sheet]);
assertEqual(findValidation(sheet, 0, 0)?.id, "v1", "findValidation 있음");
assertEqual(findValidation(sheet, 9, 9), undefined, "범위 밖은 없음");
const rule = sheet.유효성![0];
assertEqual(checkValue(rule, "배"), { ok: true, 메시지: "" }, "목록 안 값은 통과");
assertEqual(checkValue(rule, "감").ok, false, "목록 밖 값은 막힘");
assertEqual(checkValue(rule, "감").어기면, "막음", "어기면 = 막음 그대로 옴");
const rangeRule = {
id: "v2",
범위: [range],
규칙: { 종류: "수범위" as const, 아래: 0, 위: 100 },
어기면: "경고" as const,
메시지: "0~100 사이만",
};
assertEqual(checkValue(rangeRule, 50), { ok: true, 메시지: "" }, "수범위 안 값은 통과");
assertEqual(
checkValue(rangeRule, 150),
{ ok: false, 어기면: "경고", 메시지: "0~100 사이만" },
"수범위 밖 · 안내 글 그대로",
);
assertEqual(checkValue(rangeRule, "글").ok, false, "글은 수범위 규칙에서 늘 거짓");
// CRUD — 시트통째로 나감
const ctx = makeCtx(book, "s1");
addValidationRule(ctx, { 범위: [range], 규칙: { 종류: "수범위", 위: 10 }, 어기면: "경고" });
assertEqual(ctx.applied.at(-1)?.종류, "시트통째", "addValidationRule 은 시트통째로 나감");
assertEqual(book.시트[0].유효성?.length, 2, "규칙이 더해짐");
removeValidationRule(ctx, "v1");
assertEqual(book.시트[0].유효성?.map((r) => r.id).includes("v1"), false, "지운 규칙은 없음");
// 행 · 열 넣기 뒤 범위 옮김
const shifted = remapValidationRanges(
[{ id: "a", 범위: [{ r0: 0, c0: 0, r1: 0, c1: 0 }], 규칙: { 종류: "수범위" }, 어기면: "막음" }],
(r) => ({ ...r, c0: r.c0 + 1, c1: r.c1 + 1 }),
);
assertEqual(
shifted[0].범위[0],
{ r0: 0, c0: 1, r1: 0, c1: 1 },
"remapValidationRanges — 열 하나 밀림",
);
const dropped = remapValidationRanges(
[{ id: "a", 범위: [{ r0: 0, c0: 0, r1: 0, c1: 0 }], 규칙: { 종류: "수범위" }, 어기면: "막음" }],
() => null,
);
assertEqual(dropped, [], "remapValidationRanges — 범위를 다 잃으면 규칙째 버림");
if (process.exitCode) process.exit(process.exitCode);
console.log("spreadsheet_validation 시험 끝");