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
286 lines
11 KiB
TypeScript
286 lines
11 KiB
TypeScript
/* =============================================================================
|
|
* 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,
|
|
showToast,
|
|
} 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 showToast("조건 값을 넣을 것", "warning");
|
|
const colors = [fill.input.value.trim(), fontColor.input.value.trim()];
|
|
if (colors.some((c) => c && !/^#[0-9a-f]{6}$/i.test(c)))
|
|
return showToast("색은 #RRGGBB 꼴로", "warning");
|
|
if (!colors[0] && !colors[1]) return showToast("채움색이나 글자색을 넣을 것", "warning");
|
|
const 스타일: CondFmtStyle = {};
|
|
if (colors[0]) 스타일.채움 = colors[0];
|
|
if (colors[1]) 스타일.글자색 = colors[1];
|
|
addCondFmtRule(ctx, { 범위: [...범위], 조건, 스타일 });
|
|
render();
|
|
});
|
|
|
|
function buildCondition(
|
|
kind: string,
|
|
op: string,
|
|
a: ReturnType<typeof createInputField>,
|
|
b: ReturnType<typeof createInputField>,
|
|
): CondFmtCondition | null {
|
|
const blank = (f: typeof a): boolean => f.input.value.trim() === "";
|
|
if (kind === "비교") {
|
|
if (blank(a)) return null;
|
|
const n = Number(a.input.value);
|
|
return Number.isFinite(n) ? { 종류: "비교", 연산: op as CondFmtCompareOp, 값: n } : null;
|
|
}
|
|
if (kind === "사이") {
|
|
if (blank(a) || blank(b)) return null;
|
|
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();
|
|
},
|
|
};
|
|
}
|