/* ============================================================================= * 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): 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, b: ReturnType, ): 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(); }, }; }