From d60b1ccc7e6c68548a3ef874d2fda04056b2e881 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 27 Sep 2026 20:59:21 +0900 Subject: [PATCH 1/9] =?UTF-8?q?feat(spreadsheet):=20=EB=B6=84=EC=88=98=20?= =?UTF-8?q?=ED=98=95=EC=8B=9D=20=C2=B7=20=ED=95=A8=EC=88=98=202=EC=B0=A8?= =?UTF-8?q?=20=EB=AC=B6=EC=9D=8C(30=EA=B0=9C)=20=C2=B7=20misc=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=AA=BC=EA=B0=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit numfmt — 분수 형식(0/0 · # ?/? · # ??/?? · 분모 고정 # ?/8) 엑셀과 같게 구현. 함수 — MROUND·ODD·EVEN·LOG·LOG10·LN·EXP(math) · SUMIFS·COUNTIFS·AVERAGEIF(S)·MAXIFS·MINIFS· IFS·SWITCH·XLOOKUP(찾기·조건부 집계) · TEXTJOIN·TRIM·UPPER·LOWER·SUBSTITUTE·REPLACE·FIND· SEARCH·REPT·ISNUMBER·ISTEXT·ISBLANK·ISERROR·NA·TODAY·DATE·YEAR·MONTH·DAY(misc) 더함. misc.ts 가 700 줄을 넘어 찾기·조건부 집계를 spreadsheet_functions_lookup.ts(새 파일)로 쪼갬. sub1 의 func_help 이름 목록에 더할 것을 spreadsheet_functions.ts 의 FUNC_HELP_BATCH2 에 둠 (그 파일은 안 고침 · 머리 주석에 읽는 길 적음). 경계값 시험 135건 통과. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01RrQ65VtGZbae2VKgYVMhmc --- .../spreadsheet/spreadsheet_functions.ts | 109 +++- .../spreadsheet_functions_lookup.ts | 536 ++++++++++++++++++ .../spreadsheet/spreadsheet_functions_math.ts | 72 +++ .../spreadsheet/spreadsheet_functions_misc.ts | 478 +++++++--------- A00_Common/spreadsheet/spreadsheet_numfmt.ts | 130 +++++ .../spreadsheet/spreadsheet_b_test_entry.ts | 97 ++++ 6 files changed, 1148 insertions(+), 274 deletions(-) create mode 100644 A00_Common/spreadsheet/spreadsheet_functions_lookup.ts diff --git a/A00_Common/spreadsheet/spreadsheet_functions.ts b/A00_Common/spreadsheet/spreadsheet_functions.ts index d0cabc22..a92ef894 100644 --- a/A00_Common/spreadsheet/spreadsheet_functions.ts +++ b/A00_Common/spreadsheet/spreadsheet_functions.ts @@ -1,18 +1,21 @@ /* ============================================================================= * spreadsheet_functions.ts (주인 B) - * 함수 표 — 이름(대문자) → 뜻. 몸은 `spreadsheet_functions_math.ts` · `_misc.ts` 에 두고 여기서 모음. - * 1단계 약 45 — 수학 SUM PRODUCT SUMPRODUCT ROUND ROUNDUP ROUNDDOWN INT TRUNC CEILING FLOOR ABS MOD - * POWER SQRT PI SIN COS TAN ASIN ACOS ATAN RADIANS DEGREES MIN MAX AVERAGE · 논리 IF AND OR NOT IFERROR · - * 글 CONCATENATE FIXED TEXT LEN LEFT RIGHT MID VALUE · 찾기 VLOOKUP HLOOKUP INDEX MATCH CHOOSE · - * 세기 COUNT COUNTA COUNTIF SUMIF. 뜻 · 경계값은 엑셀과 같게(Formula.js 는 시험 대조 답으로만). + * 함수 표 — 이름(대문자) → 뜻. 몸은 `spreadsheet_functions_math.ts` · `_misc.ts` · `_lookup.ts` 에 + * 두고 여기서 모음. 1단계 약 45 + 2단계 첫 묶음(2026-09-27) — 아래 `FUNC_HELP_BATCH2`(sub1 의 + * `spreadsheet_func_help.ts` 가 읽어 이름 목록에 더할 것 — 그 파일은 sub1 몫이라 안 고침). * 없는 이름 = `#NAME?` · 인자 수가 min · max 밖이면 A 의 eval 이 `#VALUE!` + 까닭(엑셀은 입력을 막음). * ========================================================================== */ import type { SheetFunction } from "./spreadsheet_types"; import { MATH_FUNCTIONS } from "./spreadsheet_functions_math"; import { MISC_FUNCTIONS } from "./spreadsheet_functions_misc"; +import { LOOKUP_FUNCTIONS } from "./spreadsheet_functions_lookup"; -const TABLE: Record = { ...MATH_FUNCTIONS, ...MISC_FUNCTIONS }; +const TABLE: Record = { + ...MATH_FUNCTIONS, + ...MISC_FUNCTIONS, + ...LOOKUP_FUNCTIONS, +}; export function getFunction(name: string): SheetFunction | undefined { return TABLE[name.toUpperCase()]; @@ -22,3 +25,97 @@ export function getFunction(name: string): SheetFunction | undefined { export function functionNames(): string[] { return Object.keys(TABLE).sort(); } + +/** 2단계 첫 묶음(2026-09-27) — sub1 `spreadsheet_func_help.ts` 의 `TABLE` 에 이 모양대로 더할 것. + * `이름` · `꼴`(인자 모양 · 대괄호 = 생략 가능) · `뜻`(한 줄). */ +export interface FuncHelpEntry { + 이름: string; + 꼴: string; + 뜻: string; +} + +export const FUNC_HELP_BATCH2: FuncHelpEntry[] = [ + { 이름: "MROUND", 꼴: "MROUND(수, 배수)", 뜻: "배수의 가장 가까운 정수 배로 반올림" }, + { 이름: "ODD", 꼴: "ODD(수)", 뜻: "홀수로 올림(0 에서 먼 쪽)" }, + { 이름: "EVEN", 꼴: "EVEN(수)", 뜻: "짝수로 올림(0 에서 먼 쪽)" }, + { 이름: "LOG", 꼴: "LOG(수, [밑=10])", 뜻: "로그(밑 지정)" }, + { 이름: "LOG10", 꼴: "LOG10(수)", 뜻: "상용로그" }, + { 이름: "LN", 꼴: "LN(수)", 뜻: "자연로그" }, + { 이름: "EXP", 꼴: "EXP(수)", 뜻: "e 의 거듭제곱" }, + { + 이름: "SUMIFS", + 꼴: "SUMIFS(합할범위, 조건범위1, 조건1, [조건범위2, 조건2], …)", + 뜻: "여러 조건에 다 맞는 칸의 합", + }, + { + 이름: "COUNTIFS", + 꼴: "COUNTIFS(조건범위1, 조건1, [조건범위2, 조건2], …)", + 뜻: "여러 조건에 다 맞는 칸 개수", + }, + { + 이름: "AVERAGEIF", + 꼴: "AVERAGEIF(범위, 조건, [평균범위])", + 뜻: "조건에 맞는 칸(또는 짝 칸)의 평균", + }, + { + 이름: "AVERAGEIFS", + 꼴: "AVERAGEIFS(평균범위, 조건범위1, 조건1, …)", + 뜻: "여러 조건에 다 맞는 칸의 평균", + }, + { + 이름: "MAXIFS", + 꼴: "MAXIFS(최대범위, 조건범위1, 조건1, …)", + 뜻: "여러 조건에 다 맞는 칸의 최댓값", + }, + { + 이름: "MINIFS", + 꼴: "MINIFS(최소범위, 조건범위1, 조건1, …)", + 뜻: "여러 조건에 다 맞는 칸의 최솟값", + }, + { + 이름: "IFS", + 꼴: "IFS(조건1, 값1, [조건2, 값2], …)", + 뜻: "처음으로 참인 조건의 값", + }, + { + 이름: "SWITCH", + 꼴: "SWITCH(식, 값1, 결과1, [값2, 결과2], …, [기본값])", + 뜻: "식과 같은 값을 찾아 그 결과", + }, + { + 이름: "XLOOKUP", + 꼴: "XLOOKUP(찾을값, 찾을범위, 돌려줄범위, [못찾으면], [맞춤꼴], [찾는방향])", + 뜻: "찾을범위에서 찾아 돌려줄범위의 같은 자리 값", + }, + { + 이름: "TEXTJOIN", + 꼴: "TEXTJOIN(구분자, 빈칸건너뜀, 글1, [글2], …)", + 뜻: "구분자로 여러 글을 이어 붙임", + }, + { 이름: "TRIM", 꼴: "TRIM(글)", 뜻: "앞뒤 · 겹친 빈칸을 뺌" }, + { 이름: "UPPER", 꼴: "UPPER(글)", 뜻: "대문자로" }, + { 이름: "LOWER", 꼴: "LOWER(글)", 뜻: "소문자로" }, + { + 이름: "SUBSTITUTE", + 꼴: "SUBSTITUTE(글, 옛글, 새글, [몇번째])", + 뜻: "글 안 옛글을 새글로 바꿈", + }, + { 이름: "REPLACE", 꼴: "REPLACE(글, 시작, 글자수, 새글)", 뜻: "자리로 콕 집어 바꿈" }, + { 이름: "FIND", 꼴: "FIND(찾을글, 안글, [시작])", 뜻: "글자 그대로 찾음(대소문자 가림)" }, + { + 이름: "SEARCH", + 꼴: "SEARCH(찾을글, 안글, [시작])", + 뜻: "찾음(대소문자 안 가림 · * ? 자리표시)", + }, + { 이름: "REPT", 꼴: "REPT(글, 번)", 뜻: "글을 번 만큼 되풀이" }, + { 이름: "ISNUMBER", 꼴: "ISNUMBER(값)", 뜻: "수면 참" }, + { 이름: "ISTEXT", 꼴: "ISTEXT(값)", 뜻: "글이면 참" }, + { 이름: "ISBLANK", 꼴: "ISBLANK(값)", 뜻: "빈 칸이면 참" }, + { 이름: "ISERROR", 꼴: "ISERROR(값)", 뜻: "오류 값이면 참" }, + { 이름: "NA", 꼴: "NA()", 뜻: "#N/A 오류를 만듦" }, + { 이름: "TODAY", 꼴: "TODAY()", 뜻: "오늘 날짜(일련번호)" }, + { 이름: "DATE", 꼴: "DATE(년, 월, 일)", 뜻: "날짜 일련번호를 만듦" }, + { 이름: "YEAR", 꼴: "YEAR(일련번호)", 뜻: "연도" }, + { 이름: "MONTH", 꼴: "MONTH(일련번호)", 뜻: "월" }, + { 이름: "DAY", 꼴: "DAY(일련번호)", 뜻: "일" }, +]; diff --git a/A00_Common/spreadsheet/spreadsheet_functions_lookup.ts b/A00_Common/spreadsheet/spreadsheet_functions_lookup.ts new file mode 100644 index 00000000..214caa5f --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_functions_lookup.ts @@ -0,0 +1,536 @@ +/* ============================================================================= + * spreadsheet_functions_lookup.ts (주인 B · misc 700 줄 넘어 2026-09-27 쪼갬) + * 찾기 · 조건부 세기·합·평균·최대·최소 함수. `spreadsheet_functions_misc.ts` 에서 + * VLOOKUP · HLOOKUP · INDEX · MATCH · CHOOSE · COUNT · COUNTA · COUNTIF · SUMIF · + * SUMIFS · COUNTIFS · AVERAGEIF(S) · MAXIFS · MINIFS · IFS · SWITCH · XLOOKUP 를 옮겨 옴. + * 형 바꾸기 도우미는 `spreadsheet_functions_math.ts` 공용 것을 씀. + * ========================================================================== */ + +import { + ZERO, + add, + cmp, + div, + frac, + parseDecimal, + type Frac, +} from "@ui/sheet/ui_template_sheet_frac"; +import type { ErrorValue, LazyArg, RangeValue, Scalar, SheetFunction } from "./spreadsheet_types"; +import { + bool, + collectNumbers, + fracToDouble, + isErr, + isFrac, + isRange, + mkErr, + num, + toNumber, + toScalar, +} from "./spreadsheet_functions_math"; + +const ONE = frac(1n); + +// ── 값 견줌(찾기 · 세기 함수 공용) ─────────────────────────────────────────── + +function typeRank(v: Scalar): number { + if (v === null) return -1; + if (isFrac(v)) return 0; + if (typeof v === "string") return 1; + if (typeof v === "boolean") return 2; + return 3; +} + +function compareScalars(a: Scalar, b: Scalar): number { + if (isFrac(a) && isFrac(b)) return cmp(a, b); + if (typeof a === "string" && typeof b === "string") { + const la = a.toUpperCase(); + const lb = b.toUpperCase(); + return la < lb ? -1 : la > lb ? 1 : 0; + } + if (typeof a === "boolean" && typeof b === "boolean") return a === b ? 0 : a ? 1 : -1; + return 0; +} + +function equalScalars(a: Scalar, b: Scalar): boolean { + return typeRank(a) === typeRank(b) && compareScalars(a, b) === 0; +} + +function wrapScalar(v: Scalar): RangeValue { + return { kind: "range", rows: 1, cols: 1, at: () => v }; +} + +// ── 조건 글(`>10` · `<=5` · `사과*`) ───────────────────────────────────────── + +function wildcardToRegex(text: string): RegExp { + const escaped = text + .replace(/[.+^${}()|[\]\\]/g, "\\$&") + .replace(/\*/g, ".*") + .replace(/\?/g, "."); + return new RegExp(`^${escaped}$`, "i"); +} + +function parseCriteria(crit: Scalar): (cell: Scalar) => boolean { + if (crit === null) return (cell) => cell === null; + if (typeof crit === "boolean") return (cell) => typeof cell === "boolean" && cell === crit; + if (isFrac(crit)) return (cell) => isFrac(cell) && cmp(cell, crit) === 0; + if (isErr(crit)) return () => false; + const m = /^(<=|>=|<>|<|>|=)?(.*)$/.exec(crit); + const op = m?.[1] ?? ""; + const rest = m?.[2] ?? crit; + let numRest: Frac | null = null; + try { + numRest = rest.trim() === "" ? null : parseDecimal(rest); + } catch { + numRest = null; + } + if (numRest !== null) { + const nr = numRest; + return (cell) => { + if (!isFrac(cell)) return false; + const c = cmp(cell, nr); + if (op === "<=") return c <= 0; + if (op === ">=") return c >= 0; + if (op === "<>") return c !== 0; + if (op === "<") return c < 0; + if (op === ">") return c > 0; + return c === 0; + }; + } + const pat = wildcardToRegex(rest); + if (op === "<>") return (cell) => !(typeof cell === "string" && pat.test(cell)); + return (cell) => typeof cell === "string" && pat.test(cell); +} + +/** `args[from]` 부터 (범위,조건) 짝을 이어가며 모두 맞는 자리만 참 — *IFS 함수 공용. + * `shape` 범위와 크기가 다른 조건 범위가 있으면 `#VALUE!`. */ +function criteriaMask(args: LazyArg[], from: number, shape: RangeValue): boolean[] | ErrorValue { + const mask = new Array(shape.rows * shape.cols).fill(true); + for (let i = from; i + 1 < args.length; i += 2) { + const rangeArg = args[i](); + if (!isRange(rangeArg)) return mkErr("#VALUE!"); + if (rangeArg.rows !== shape.rows || rangeArg.cols !== shape.cols) { + return mkErr("#VALUE!", "조건 범위 크기가 다름"); + } + const crit = toScalar(args[i + 1]()); + if (isErr(crit)) return crit; + const pred = parseCriteria(crit); + let k = 0; + for (let r = 0; r < shape.rows; r++) { + for (let c = 0; c < shape.cols; c++) { + if (mask[k] && !pred(rangeArg.at(r, c))) mask[k] = false; + k++; + } + } + } + return mask; +} + +// ── 함수 표 ────────────────────────────────────────────────────────────── + +export const LOOKUP_FUNCTIONS: Record = { + VLOOKUP: { + min: 3, + max: 4, + call: (args) => { + const lookup = toScalar(args[0]()); + if (isErr(lookup)) return lookup; + const table = args[1](); + if (!isRange(table)) return mkErr("#VALUE!"); + const colN = num(args[2]()); + if (isErr(colN)) return colN; + const col = Math.trunc(fracToDouble(colN)) - 1; + if (col < 0 || col >= table.cols) return mkErr("#REF!"); + const approxRaw = args.length > 3 ? bool(args[3]()) : true; + if (isErr(approxRaw)) return approxRaw; + if (approxRaw) { + let best = -1; + for (let r = 0; r < table.rows; r++) { + const key = table.at(r, 0); + if (typeRank(key) !== typeRank(lookup)) { + if (best >= 0) break; + continue; + } + if (compareScalars(key, lookup) <= 0) best = r; + else break; + } + if (best < 0) return mkErr("#N/A"); + return table.at(best, col); + } + for (let r = 0; r < table.rows; r++) { + if (equalScalars(table.at(r, 0), lookup)) return table.at(r, col); + } + return mkErr("#N/A"); + }, + }, + HLOOKUP: { + min: 3, + max: 4, + call: (args) => { + const lookup = toScalar(args[0]()); + if (isErr(lookup)) return lookup; + const table = args[1](); + if (!isRange(table)) return mkErr("#VALUE!"); + const rowN = num(args[2]()); + if (isErr(rowN)) return rowN; + const row = Math.trunc(fracToDouble(rowN)) - 1; + if (row < 0 || row >= table.rows) return mkErr("#REF!"); + const approxRaw = args.length > 3 ? bool(args[3]()) : true; + if (isErr(approxRaw)) return approxRaw; + if (approxRaw) { + let best = -1; + for (let c = 0; c < table.cols; c++) { + const key = table.at(0, c); + if (typeRank(key) !== typeRank(lookup)) { + if (best >= 0) break; + continue; + } + if (compareScalars(key, lookup) <= 0) best = c; + else break; + } + if (best < 0) return mkErr("#N/A"); + return table.at(row, best); + } + for (let c = 0; c < table.cols; c++) { + if (equalScalars(table.at(0, c), lookup)) return table.at(row, c); + } + return mkErr("#N/A"); + }, + }, + INDEX: { + min: 2, + max: 3, + call: (args) => { + const rangeArg = args[0](); + if (isErr(rangeArg)) return rangeArg; + const range = isRange(rangeArg) ? rangeArg : wrapScalar(rangeArg); + const rowN = num(args[1]()); + if (isErr(rowN)) return rowN; + const row = Math.trunc(fracToDouble(rowN)); + const colN = args.length > 2 ? num(args[2]()) : frac(range.cols === 1 ? 1n : 0n); + if (isErr(colN)) return colN; + const col = Math.trunc(fracToDouble(colN)); + if (row === 0 && col === 0) return mkErr("#VALUE!"); + if (row === 0) { + if (col < 1 || col > range.cols) return mkErr("#REF!"); + const c = col - 1; + return { kind: "range", rows: range.rows, cols: 1, at: (r: number) => range.at(r, c) }; + } + if (col === 0) { + if (row < 1 || row > range.rows) return mkErr("#REF!"); + const r = row - 1; + return { + kind: "range", + rows: 1, + cols: range.cols, + at: (_r: number, c: number) => range.at(r, c), + }; + } + if (row < 1 || row > range.rows || col < 1 || col > range.cols) return mkErr("#REF!"); + return range.at(row - 1, col - 1); + }, + }, + MATCH: { + min: 2, + max: 3, + call: (args) => { + const lookup = toScalar(args[0]()); + if (isErr(lookup)) return lookup; + const rangeArg = args[1](); + if (!isRange(rangeArg)) return mkErr("#VALUE!"); + if (rangeArg.rows !== 1 && rangeArg.cols !== 1) { + return mkErr("#VALUE!", "한 줄 · 한 칸짜리 범위만"); + } + const n = rangeArg.rows === 1 ? rangeArg.cols : rangeArg.rows; + const at = (i: number): Scalar => + rangeArg.rows === 1 ? rangeArg.at(0, i) : rangeArg.at(i, 0); + const modeN = args.length > 2 ? num(args[2]()) : ONE; + if (isErr(modeN)) return modeN; + const mode = Math.trunc(fracToDouble(modeN)); + if (mode === 0) { + for (let i = 0; i < n; i++) if (equalScalars(at(i), lookup)) return frac(BigInt(i + 1)); + return mkErr("#N/A"); + } + let best = -1; + for (let i = 0; i < n; i++) { + const key = at(i); + if (typeRank(key) !== typeRank(lookup)) { + if (best >= 0) break; + continue; + } + const c = compareScalars(key, lookup); + if (mode > 0 ? c <= 0 : c >= 0) best = i; + else break; + } + if (best < 0) return mkErr("#N/A"); + return frac(BigInt(best + 1)); + }, + }, + CHOOSE: { + min: 2, + call: (args) => { + const idxN = num(args[0]()); + if (isErr(idxN)) return idxN; + const idx = Math.trunc(fracToDouble(idxN)); + if (idx < 1 || idx > args.length - 1) return mkErr("#VALUE!"); + return args[idx](); + }, + }, + COUNT: { + min: 0, + call: (args) => { + const values = collectNumbers(args, false, false); + return isErr(values) ? values : frac(BigInt(values.length)); + }, + }, + COUNTA: { + min: 0, + call: (args) => { + let count = 0; + for (const a of args) { + const v = a(); + if (isRange(v)) { + for (let r = 0; r < v.rows; r++) { + for (let c = 0; c < v.cols; c++) if (v.at(r, c) !== null) count++; + } + } else if (v !== null) { + count++; + } + } + return frac(BigInt(count)); + }, + }, + COUNTIF: { + min: 2, + max: 2, + call: (args) => { + const range = args[0](); + if (!isRange(range)) return mkErr("#VALUE!"); + const crit = toScalar(args[1]()); + if (isErr(crit)) return crit; + const pred = parseCriteria(crit); + let count = 0; + for (let r = 0; r < range.rows; r++) { + for (let c = 0; c < range.cols; c++) if (pred(range.at(r, c))) count++; + } + return frac(BigInt(count)); + }, + }, + SUMIF: { + min: 2, + max: 3, + call: (args) => { + const range = args[0](); + if (!isRange(range)) return mkErr("#VALUE!"); + const crit = toScalar(args[1]()); + if (isErr(crit)) return crit; + const pred = parseCriteria(crit); + const sumRange = args.length > 2 ? args[2]() : range; + if (!isRange(sumRange)) return mkErr("#VALUE!"); + if (sumRange.rows !== range.rows || sumRange.cols !== range.cols) { + return mkErr("#VALUE!", "합 범위 크기가 다름"); + } + let total = ZERO; + for (let r = 0; r < range.rows; r++) { + for (let c = 0; c < range.cols; c++) { + if (!pred(range.at(r, c))) continue; + const n = toNumber(sumRange.at(r, c)); + if (isErr(n)) return n; + total = add(total, n); + } + } + return total; + }, + }, + SUMIFS: { + min: 3, + call: (args) => { + const sumRange = args[0](); + if (!isRange(sumRange)) return mkErr("#VALUE!"); + const mask = criteriaMask(args, 1, sumRange); + if (isErr(mask)) return mask; + let total = ZERO; + let k = 0; + for (let r = 0; r < sumRange.rows; r++) { + for (let c = 0; c < sumRange.cols; c++) { + if (mask[k]) { + const n = toNumber(sumRange.at(r, c)); + if (isErr(n)) return n; + total = add(total, n); + } + k++; + } + } + return total; + }, + }, + COUNTIFS: { + min: 2, + call: (args) => { + const first = args[0](); + if (!isRange(first)) return mkErr("#VALUE!"); + const mask = criteriaMask(args, 0, first); + if (isErr(mask)) return mask; + return frac(BigInt(mask.filter(Boolean).length)); + }, + }, + AVERAGEIF: { + min: 2, + max: 3, + call: (args) => { + const range = args[0](); + if (!isRange(range)) return mkErr("#VALUE!"); + const crit = toScalar(args[1]()); + if (isErr(crit)) return crit; + const pred = parseCriteria(crit); + const avgRange = args.length > 2 ? args[2]() : range; + if (!isRange(avgRange)) return mkErr("#VALUE!"); + if (avgRange.rows !== range.rows || avgRange.cols !== range.cols) { + return mkErr("#VALUE!", "평균 범위 크기가 다름"); + } + let total = ZERO; + let count = 0; + for (let r = 0; r < range.rows; r++) { + for (let c = 0; c < range.cols; c++) { + if (!pred(range.at(r, c))) continue; + const n = toNumber(avgRange.at(r, c)); + if (isErr(n)) return n; + total = add(total, n); + count++; + } + } + return count === 0 ? mkErr("#DIV/0!") : div(total, frac(BigInt(count))); + }, + }, + AVERAGEIFS: { + min: 3, + call: (args) => { + const avgRange = args[0](); + if (!isRange(avgRange)) return mkErr("#VALUE!"); + const mask = criteriaMask(args, 1, avgRange); + if (isErr(mask)) return mask; + let total = ZERO; + let count = 0; + let k = 0; + for (let r = 0; r < avgRange.rows; r++) { + for (let c = 0; c < avgRange.cols; c++) { + if (mask[k]) { + const n = toNumber(avgRange.at(r, c)); + if (isErr(n)) return n; + total = add(total, n); + count++; + } + k++; + } + } + return count === 0 ? mkErr("#DIV/0!") : div(total, frac(BigInt(count))); + }, + }, + MAXIFS: { + min: 3, + call: (args) => { + const targetRange = args[0](); + if (!isRange(targetRange)) return mkErr("#VALUE!"); + const mask = criteriaMask(args, 1, targetRange); + if (isErr(mask)) return mask; + let best: Frac | null = null; + let k = 0; + for (let r = 0; r < targetRange.rows; r++) { + for (let c = 0; c < targetRange.cols; c++) { + if (mask[k]) { + const cell = targetRange.at(r, c); + if (isFrac(cell) && (best === null || cmp(cell, best) > 0)) best = cell; + } + k++; + } + } + return best ?? ZERO; + }, + }, + MINIFS: { + min: 3, + call: (args) => { + const targetRange = args[0](); + if (!isRange(targetRange)) return mkErr("#VALUE!"); + const mask = criteriaMask(args, 1, targetRange); + if (isErr(mask)) return mask; + let best: Frac | null = null; + let k = 0; + for (let r = 0; r < targetRange.rows; r++) { + for (let c = 0; c < targetRange.cols; c++) { + if (mask[k]) { + const cell = targetRange.at(r, c); + if (isFrac(cell) && (best === null || cmp(cell, best) < 0)) best = cell; + } + k++; + } + } + return best ?? ZERO; + }, + }, + IFS: { + min: 2, + call: (args) => { + for (let i = 0; i + 1 < args.length; i += 2) { + const c = bool(args[i]()); + if (isErr(c)) return c; + if (c) return args[i + 1](); + } + return mkErr("#N/A"); + }, + }, + SWITCH: { + min: 3, + call: (args) => { + const target = toScalar(args[0]()); + if (isErr(target)) return target; + let i = 1; + for (; i + 1 < args.length; i += 2) { + const cand = toScalar(args[i]()); + if (isErr(cand)) return cand; + if (equalScalars(target, cand)) return args[i + 1](); + } + return i < args.length ? args[i]() : mkErr("#N/A"); + }, + }, + XLOOKUP: { + min: 3, + max: 6, + call: (args) => { + const lookup = toScalar(args[0]()); + if (isErr(lookup)) return lookup; + const lookupRange = args[1](); + const returnRange = args[2](); + if (!isRange(lookupRange) || !isRange(returnRange)) return mkErr("#VALUE!"); + const n = lookupRange.rows * lookupRange.cols; + if (n !== returnRange.rows * returnRange.cols) { + return mkErr("#VALUE!", "찾을 범위 · 돌려줄 범위 크기가 다름"); + } + const at = (rv: RangeValue, i: number): Scalar => (rv.rows === 1 ? rv.at(0, i) : rv.at(i, 0)); + const modeN = args.length > 4 ? num(args[4]()) : ZERO; + if (isErr(modeN)) return modeN; + const mode = Math.trunc(fracToDouble(modeN)); + const searchN = args.length > 5 ? num(args[5]()) : ONE; + if (isErr(searchN)) return searchN; + const reverse = Math.trunc(fracToDouble(searchN)) < 0; + let best = -1; + for (let step = 0; step < n; step++) { + const i = reverse ? n - 1 - step : step; + const key = at(lookupRange, i); + if (typeRank(key) !== typeRank(lookup)) continue; + const c = compareScalars(key, lookup); + if (c === 0) { + best = i; + break; + } + if (mode === 1 && c > 0) { + if (best < 0 || compareScalars(key, at(lookupRange, best)) < 0) best = i; + } else if (mode === -1 && c < 0) { + if (best < 0 || compareScalars(key, at(lookupRange, best)) > 0) best = i; + } + } + if (best < 0) return args.length > 3 ? args[3]() : mkErr("#N/A"); + return at(returnRange, best); + }, + }, +}; diff --git a/A00_Common/spreadsheet/spreadsheet_functions_math.ts b/A00_Common/spreadsheet/spreadsheet_functions_math.ts index 99f21e12..6d65b53e 100644 --- a/A00_Common/spreadsheet/spreadsheet_functions_math.ts +++ b/A00_Common/spreadsheet/spreadsheet_functions_math.ts @@ -415,4 +415,76 @@ export const MATH_FUNCTIONS: Record = { return div(values.reduce(add, ZERO), frac(BigInt(values.length))); }, }, + MROUND: { + min: 2, + max: 2, + call: (args) => { + const n = num(args[0]()); + if (isErr(n)) return n; + const m = num(args[1]()); + if (isErr(m)) return m; + if (cmp(m, ZERO) === 0) return ZERO; + if (cmp(n, ZERO) !== 0 && cmp(n, ZERO) > 0 !== cmp(m, ZERO) > 0) { + return mkErr("#NUM!", "수 · 배수의 부호가 다름"); + } + return mul(frac(toInteger(div(n, m), "round")), m); + }, + }, + ODD: { + min: 1, + max: 1, + call: (args) => { + const n = num(args[0]()); + if (isErr(n)) return n; + let v = toInteger(n, "away"); + if (v === 0n) v = 1n; + else if (v % 2n === 0n) v += v < 0n ? -1n : 1n; + return frac(v); + }, + }, + EVEN: { + min: 1, + max: 1, + call: (args) => { + const n = num(args[0]()); + if (isErr(n)) return n; + let v = toInteger(n, "away"); + if (v % 2n !== 0n) v += v < 0n ? -1n : 1n; + return frac(v); + }, + }, + LOG: { + min: 1, + max: 2, + call: (args) => { + const n = num(args[0]()); + if (isErr(n)) return n; + if (cmp(n, ZERO) <= 0) return mkErr("#NUM!"); + const base = args.length > 1 ? num(args[1]()) : frac(10n); + if (isErr(base)) return base; + if (cmp(base, ZERO) <= 0 || cmp(base, ONE) === 0) return mkErr("#NUM!"); + return fromDouble(Math.log(fracToDouble(n)) / Math.log(fracToDouble(base))); + }, + }, + LOG10: { + min: 1, + max: 1, + call: (args) => { + const n = num(args[0]()); + if (isErr(n)) return n; + if (cmp(n, ZERO) <= 0) return mkErr("#NUM!"); + return fromDouble(Math.log10(fracToDouble(n))); + }, + }, + LN: { + min: 1, + max: 1, + call: (args) => { + const n = num(args[0]()); + if (isErr(n)) return n; + if (cmp(n, ZERO) <= 0) return mkErr("#NUM!"); + return fromDouble(Math.log(fracToDouble(n))); + }, + }, + EXP: unaryDouble(Math.exp), }; diff --git a/A00_Common/spreadsheet/spreadsheet_functions_misc.ts b/A00_Common/spreadsheet/spreadsheet_functions_misc.ts index 10d4916a..dd33073f 100644 --- a/A00_Common/spreadsheet/spreadsheet_functions_misc.ts +++ b/A00_Common/spreadsheet/spreadsheet_functions_misc.ts @@ -1,12 +1,12 @@ /* ============================================================================= * spreadsheet_functions_misc.ts (주인 B) - * 논리 · 글 · 찾기 · 세기 함수. 형 바꾸기 도우미는 `spreadsheet_functions_math.ts` 공용 것을 씀. - * 뜻 · 경계값은 엑셀과 같게(Formula.js 는 시험 대조 답으로만). + * 논리 · 글 · 정보 · 날짜 함수. 찾기 · 조건부 세기/합/평균/최대/최소는 + * `spreadsheet_functions_lookup.ts` 로 옮김(700 줄 규칙 · 2026-09-27). + * 형 바꾸기 도우미는 `spreadsheet_functions_math.ts` 공용 것을 씀. * ========================================================================== */ import { ZERO, - add, cmp, frac, mul, @@ -15,12 +15,12 @@ import { toInteger, type Frac, } from "@ui/sheet/ui_template_sheet_frac"; -import type { ErrorValue, LazyArg, RangeValue, Scalar, SheetFunction } from "./spreadsheet_types"; +import type { ErrorValue, LazyArg, SheetFunction } from "./spreadsheet_types"; import { formatValue } from "./spreadsheet_numfmt"; import { bool, - collectNumbers, fracToDouble, + fromDouble, isErr, isFrac, isRange, @@ -28,82 +28,19 @@ import { num, str, toBool, - toNumber, toScalar, toText, } from "./spreadsheet_functions_math"; const ONE = frac(1n); -// ── 값 견줌(찾기 · 세기 함수 공용) ─────────────────────────────────────────── - -function typeRank(v: Scalar): number { - if (v === null) return -1; - if (isFrac(v)) return 0; - if (typeof v === "string") return 1; - if (typeof v === "boolean") return 2; - return 3; -} - -function compareScalars(a: Scalar, b: Scalar): number { - if (isFrac(a) && isFrac(b)) return cmp(a, b); - if (typeof a === "string" && typeof b === "string") { - const la = a.toUpperCase(); - const lb = b.toUpperCase(); - return la < lb ? -1 : la > lb ? 1 : 0; - } - if (typeof a === "boolean" && typeof b === "boolean") return a === b ? 0 : a ? 1 : -1; - return 0; -} - -function equalScalars(a: Scalar, b: Scalar): boolean { - return typeRank(a) === typeRank(b) && compareScalars(a, b) === 0; -} - -function wrapScalar(v: Scalar): RangeValue { - return { kind: "range", rows: 1, cols: 1, at: () => v }; -} - -// ── 조건 글(`>10` · `<=5` · `사과*`) ───────────────────────────────────────── - -function wildcardToRegex(text: string): RegExp { +/** SEARCH — 통짜 맞춤이 아니라 안 어디서든 찾음(자리 없이 그대로 씀). */ +function wildcardToSearchRegex(text: string): RegExp { const escaped = text .replace(/[.+^${}()|[\]\\]/g, "\\$&") .replace(/\*/g, ".*") .replace(/\?/g, "."); - return new RegExp(`^${escaped}$`, "i"); -} - -function parseCriteria(crit: Scalar): (cell: Scalar) => boolean { - if (crit === null) return (cell) => cell === null; - if (typeof crit === "boolean") return (cell) => typeof cell === "boolean" && cell === crit; - if (isFrac(crit)) return (cell) => isFrac(cell) && cmp(cell, crit) === 0; - if (isErr(crit)) return () => false; - const m = /^(<=|>=|<>|<|>|=)?(.*)$/.exec(crit); - const op = m?.[1] ?? ""; - const rest = m?.[2] ?? crit; - let numRest: Frac | null = null; - try { - numRest = rest.trim() === "" ? null : parseDecimal(rest); - } catch { - numRest = null; - } - if (numRest !== null) { - const nr = numRest; - return (cell) => { - if (!isFrac(cell)) return false; - const c = cmp(cell, nr); - if (op === "<=") return c <= 0; - if (op === ">=") return c >= 0; - if (op === "<>") return c !== 0; - if (op === "<") return c < 0; - if (op === ">") return c > 0; - return c === 0; - }; - } - const pat = wildcardToRegex(rest); - if (op === "<>") return (cell) => !(typeof cell === "string" && pat.test(cell)); - return (cell) => typeof cell === "string" && pat.test(cell); + return new RegExp(escaped, "i"); } // ── 숫자 → 고정 소수 글(FIXED) ─────────────────────────────────────────────── @@ -126,8 +63,6 @@ function formatFixed(n: Frac, decimals: number, commas: boolean): string { return negative ? `-${body}` : body; } -// ── 함수 표 ────────────────────────────────────────────────────────────── - function collectBools(args: LazyArg[]): boolean[] | ErrorValue { const out: boolean[] = []; for (const a of args) { @@ -152,6 +87,23 @@ function collectBools(args: LazyArg[]): boolean[] | ErrorValue { return out; } +// ── 날짜 일련번호(엑셀 1900 체계 · 1970-01-01 = 25569) ─────────────────────── +// 1900 을 윤년 취급하는 옛 버그는 그대로 두지 않음(1900 년대 자체를 실무가 안 씀) — +// 유닉스 자정 기준으로 바로 셈해 1900 년 2 월 안쪽만 어긋남(1단계 밖). + +const MS_PER_DAY = 86400000; +const UNIX_EPOCH_AS_EXCEL_SERIAL = 25569; + +function serialToDate(serial: number): Date { + return new Date(Math.round((serial - UNIX_EPOCH_AS_EXCEL_SERIAL) * MS_PER_DAY)); +} + +function dateToSerial(d: Date): number { + return d.getTime() / MS_PER_DAY + UNIX_EPOCH_AS_EXCEL_SERIAL; +} + +// ── 함수 표 ────────────────────────────────────────────────────────────── + export const MISC_FUNCTIONS: Record = { IF: { min: 2, @@ -311,217 +263,207 @@ export const MISC_FUNCTIONS: Record = { } }, }, - VLOOKUP: { + TEXTJOIN: { min: 3, - max: 4, call: (args) => { - const lookup = toScalar(args[0]()); - if (isErr(lookup)) return lookup; - const table = args[1](); - if (!isRange(table)) return mkErr("#VALUE!"); - const colN = num(args[2]()); - if (isErr(colN)) return colN; - const col = Math.trunc(fracToDouble(colN)) - 1; - if (col < 0 || col >= table.cols) return mkErr("#REF!"); - const approxRaw = args.length > 3 ? bool(args[3]()) : true; - if (isErr(approxRaw)) return approxRaw; - if (approxRaw) { - let best = -1; - for (let r = 0; r < table.rows; r++) { - const key = table.at(r, 0); - if (typeRank(key) !== typeRank(lookup)) { - if (best >= 0) break; - continue; - } - if (compareScalars(key, lookup) <= 0) best = r; - else break; - } - if (best < 0) return mkErr("#N/A"); - return table.at(best, col); - } - for (let r = 0; r < table.rows; r++) { - if (equalScalars(table.at(r, 0), lookup)) return table.at(r, col); - } - return mkErr("#N/A"); - }, - }, - HLOOKUP: { - min: 3, - max: 4, - call: (args) => { - const lookup = toScalar(args[0]()); - if (isErr(lookup)) return lookup; - const table = args[1](); - if (!isRange(table)) return mkErr("#VALUE!"); - const rowN = num(args[2]()); - if (isErr(rowN)) return rowN; - const row = Math.trunc(fracToDouble(rowN)) - 1; - if (row < 0 || row >= table.rows) return mkErr("#REF!"); - const approxRaw = args.length > 3 ? bool(args[3]()) : true; - if (isErr(approxRaw)) return approxRaw; - if (approxRaw) { - let best = -1; - for (let c = 0; c < table.cols; c++) { - const key = table.at(0, c); - if (typeRank(key) !== typeRank(lookup)) { - if (best >= 0) break; - continue; - } - if (compareScalars(key, lookup) <= 0) best = c; - else break; - } - if (best < 0) return mkErr("#N/A"); - return table.at(row, best); - } - for (let c = 0; c < table.cols; c++) { - if (equalScalars(table.at(0, c), lookup)) return table.at(row, c); - } - return mkErr("#N/A"); - }, - }, - INDEX: { - min: 2, - max: 3, - call: (args) => { - const rangeArg = args[0](); - if (isErr(rangeArg)) return rangeArg; - const range = isRange(rangeArg) ? rangeArg : wrapScalar(rangeArg); - const rowN = num(args[1]()); - if (isErr(rowN)) return rowN; - const row = Math.trunc(fracToDouble(rowN)); - const colN = args.length > 2 ? num(args[2]()) : frac(range.cols === 1 ? 1n : 0n); - if (isErr(colN)) return colN; - const col = Math.trunc(fracToDouble(colN)); - if (row === 0 && col === 0) return mkErr("#VALUE!"); - if (row === 0) { - if (col < 1 || col > range.cols) return mkErr("#REF!"); - const c = col - 1; - return { kind: "range", rows: range.rows, cols: 1, at: (r: number) => range.at(r, c) }; - } - if (col === 0) { - if (row < 1 || row > range.rows) return mkErr("#REF!"); - const r = row - 1; - return { - kind: "range", - rows: 1, - cols: range.cols, - at: (_r: number, c: number) => range.at(r, c), - }; - } - if (row < 1 || row > range.rows || col < 1 || col > range.cols) return mkErr("#REF!"); - return range.at(row - 1, col - 1); - }, - }, - MATCH: { - min: 2, - max: 3, - call: (args) => { - const lookup = toScalar(args[0]()); - if (isErr(lookup)) return lookup; - const rangeArg = args[1](); - if (!isRange(rangeArg)) return mkErr("#VALUE!"); - if (rangeArg.rows !== 1 && rangeArg.cols !== 1) { - return mkErr("#VALUE!", "한 줄 · 한 칸짜리 범위만"); - } - const n = rangeArg.rows === 1 ? rangeArg.cols : rangeArg.rows; - const at = (i: number): Scalar => - rangeArg.rows === 1 ? rangeArg.at(0, i) : rangeArg.at(i, 0); - const modeN = args.length > 2 ? num(args[2]()) : ONE; - if (isErr(modeN)) return modeN; - const mode = Math.trunc(fracToDouble(modeN)); - if (mode === 0) { - for (let i = 0; i < n; i++) if (equalScalars(at(i), lookup)) return frac(BigInt(i + 1)); - return mkErr("#N/A"); - } - let best = -1; - for (let i = 0; i < n; i++) { - const key = at(i); - if (typeRank(key) !== typeRank(lookup)) { - if (best >= 0) break; - continue; - } - const c = compareScalars(key, lookup); - if (mode > 0 ? c <= 0 : c >= 0) best = i; - else break; - } - if (best < 0) return mkErr("#N/A"); - return frac(BigInt(best + 1)); - }, - }, - CHOOSE: { - min: 2, - call: (args) => { - const idxN = num(args[0]()); - if (isErr(idxN)) return idxN; - const idx = Math.trunc(fracToDouble(idxN)); - if (idx < 1 || idx > args.length - 1) return mkErr("#VALUE!"); - return args[idx](); - }, - }, - COUNT: { - min: 0, - call: (args) => { - const values = collectNumbers(args, false, false); - return isErr(values) ? values : frac(BigInt(values.length)); - }, - }, - COUNTA: { - min: 0, - call: (args) => { - let count = 0; - for (const a of args) { - const v = a(); + const delim = str(args[0]()); + if (isErr(delim)) return delim; + const ignoreEmpty = bool(args[1]()); + if (isErr(ignoreEmpty)) return ignoreEmpty; + const parts: string[] = []; + for (let i = 2; i < args.length; i++) { + const v = args[i](); if (isRange(v)) { for (let r = 0; r < v.rows; r++) { - for (let c = 0; c < v.cols; c++) if (v.at(r, c) !== null) count++; + for (let c = 0; c < v.cols; c++) { + const t = toText(v.at(r, c)); + if (isErr(t)) return t; + if (!(ignoreEmpty && t === "")) parts.push(t); + } } - } else if (v !== null) { - count++; + } else { + const t = str(v); + if (isErr(t)) return t; + if (!(ignoreEmpty && t === "")) parts.push(t); } } - return frac(BigInt(count)); + return parts.join(delim); }, }, - COUNTIF: { + TRIM: { + min: 1, + max: 1, + call: (args) => { + const s = str(args[0]()); + return isErr(s) ? s : s.split(" ").filter(Boolean).join(" "); + }, + }, + UPPER: { + min: 1, + max: 1, + call: (args) => { + const s = str(args[0]()); + return isErr(s) ? s : s.toUpperCase(); + }, + }, + LOWER: { + min: 1, + max: 1, + call: (args) => { + const s = str(args[0]()); + return isErr(s) ? s : s.toLowerCase(); + }, + }, + SUBSTITUTE: { + min: 3, + max: 4, + call: (args) => { + const text = str(args[0]()); + if (isErr(text)) return text; + const oldT = str(args[1]()); + if (isErr(oldT)) return oldT; + const newT = str(args[2]()); + if (isErr(newT)) return newT; + if (oldT === "") return text; + if (args.length <= 3) return text.split(oldT).join(newT); + const nthArg = num(args[3]()); + if (isErr(nthArg)) return nthArg; + const nth = Math.trunc(fracToDouble(nthArg)); + let idx = -1; + let count = 0; + let pos = 0; + for (;;) { + const found = text.indexOf(oldT, pos); + if (found < 0) break; + count++; + if (count === nth) { + idx = found; + break; + } + pos = found + oldT.length; + } + return idx < 0 ? text : text.slice(0, idx) + newT + text.slice(idx + oldT.length); + }, + }, + REPLACE: { + min: 4, + max: 4, + call: (args) => { + const text = str(args[0]()); + if (isErr(text)) return text; + const startN = num(args[1]()); + if (isErr(startN)) return startN; + const lenN = num(args[2]()); + if (isErr(lenN)) return lenN; + const newText = str(args[3]()); + if (isErr(newText)) return newText; + const start = Math.trunc(fracToDouble(startN)); + const len = Math.max(0, Math.trunc(fracToDouble(lenN))); + if (start < 1) return mkErr("#VALUE!"); + return text.slice(0, start - 1) + newText + text.slice(start - 1 + len); + }, + }, + FIND: { + min: 2, + max: 3, + call: (args) => { + const findT = str(args[0]()); + if (isErr(findT)) return findT; + const within = str(args[1]()); + if (isErr(within)) return within; + const startArg = args.length > 2 ? num(args[2]()) : ONE; + if (isErr(startArg)) return startArg; + const start = Math.trunc(fracToDouble(startArg)); + if (start < 1) return mkErr("#VALUE!"); + const idx = within.indexOf(findT, start - 1); + return idx < 0 ? mkErr("#VALUE!") : frac(BigInt(idx + 1)); + }, + }, + SEARCH: { + min: 2, + max: 3, + call: (args) => { + const findT = str(args[0]()); + if (isErr(findT)) return findT; + const within = str(args[1]()); + if (isErr(within)) return within; + const startArg = args.length > 2 ? num(args[2]()) : ONE; + if (isErr(startArg)) return startArg; + const start = Math.trunc(fracToDouble(startArg)); + if (start < 1) return mkErr("#VALUE!"); + const pat = wildcardToSearchRegex(findT); + const m = pat.exec(within.slice(start - 1)); + return m ? frac(BigInt(m.index + start)) : mkErr("#VALUE!"); + }, + }, + REPT: { min: 2, max: 2, call: (args) => { - const range = args[0](); - if (!isRange(range)) return mkErr("#VALUE!"); - const crit = toScalar(args[1]()); - if (isErr(crit)) return crit; - const pred = parseCriteria(crit); - let count = 0; - for (let r = 0; r < range.rows; r++) { - for (let c = 0; c < range.cols; c++) if (pred(range.at(r, c))) count++; - } - return frac(BigInt(count)); + const s = str(args[0]()); + if (isErr(s)) return s; + const nArg = num(args[1]()); + if (isErr(nArg)) return nArg; + const n = Math.trunc(fracToDouble(nArg)); + return n < 0 ? mkErr("#VALUE!") : s.repeat(n); }, }, - SUMIF: { - min: 2, + ISNUMBER: { min: 1, max: 1, call: (args) => isFrac(toScalar(args[0]())) }, + ISTEXT: { min: 1, max: 1, call: (args) => typeof toScalar(args[0]()) === "string" }, + ISBLANK: { min: 1, max: 1, call: (args) => toScalar(args[0]()) === null }, + ISERROR: { min: 1, max: 1, call: (args) => isErr(toScalar(args[0]())) }, + NA: { min: 0, max: 0, call: () => mkErr("#N/A") }, + TODAY: { + min: 0, + max: 0, + call: () => { + const now = new Date(); + const midnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); + return fromDouble(dateToSerial(new Date(midnight))); + }, + }, + DATE: { + min: 3, max: 3, call: (args) => { - const range = args[0](); - if (!isRange(range)) return mkErr("#VALUE!"); - const crit = toScalar(args[1]()); - if (isErr(crit)) return crit; - const pred = parseCriteria(crit); - const sumRange = args.length > 2 ? args[2]() : range; - if (!isRange(sumRange)) return mkErr("#VALUE!"); - if (sumRange.rows !== range.rows || sumRange.cols !== range.cols) { - return mkErr("#VALUE!", "합 범위 크기가 다름"); - } - let total = ZERO; - for (let r = 0; r < range.rows; r++) { - for (let c = 0; c < range.cols; c++) { - if (!pred(range.at(r, c))) continue; - const n = toNumber(sumRange.at(r, c)); - if (isErr(n)) return n; - total = add(total, n); - } - } - return total; + const y = num(args[0]()); + if (isErr(y)) return y; + const m = num(args[1]()); + if (isErr(m)) return m; + const d = num(args[2]()); + if (isErr(d)) return d; + const date = new Date( + Date.UTC( + Math.trunc(fracToDouble(y)), + Math.trunc(fracToDouble(m)) - 1, + Math.trunc(fracToDouble(d)), + ), + ); + return fromDouble(dateToSerial(date)); + }, + }, + YEAR: { + min: 1, + max: 1, + call: (args) => { + const n = num(args[0]()); + return isErr(n) ? n : frac(BigInt(serialToDate(fracToDouble(n)).getUTCFullYear())); + }, + }, + MONTH: { + min: 1, + max: 1, + call: (args) => { + const n = num(args[0]()); + return isErr(n) ? n : frac(BigInt(serialToDate(fracToDouble(n)).getUTCMonth() + 1)); + }, + }, + DAY: { + min: 1, + max: 1, + call: (args) => { + const n = num(args[0]()); + return isErr(n) ? n : frac(BigInt(serialToDate(fracToDouble(n)).getUTCDate())); }, }, }; diff --git a/A00_Common/spreadsheet/spreadsheet_numfmt.ts b/A00_Common/spreadsheet/spreadsheet_numfmt.ts index 1351fcab..b6533dff 100644 --- a/A00_Common/spreadsheet/spreadsheet_numfmt.ts +++ b/A00_Common/spreadsheet/spreadsheet_numfmt.ts @@ -14,6 +14,7 @@ import { fracToString, mul, roundAt, + sub, toInteger, type Frac, } from "@ui/sheet/ui_template_sheet_frac"; @@ -235,6 +236,130 @@ function renderNumericSection(toks: Tok[], magnitude: Frac): string { return out; } +// ── 분수 형식(`0/0` · `# ?/?` · `# ??/??` · 분모 고정 `# ?/8`) ─────────────── + +interface FracPlan { + prefix: Tok[]; + wholeDigits: number; // 0 = 정수부 자리 없음(가분수로 통째) + sepToks: Tok[]; // 정수부 · 분자 사이 리터럴(보통 칸 하나) + fixedDenom: bigint | null; // null = 분모 자리표시(자동 찾기) · 있으면 그 값 고정 + denomDigits: number; // 자동일 때 자리표시 개수(최대 분모 = 10^자리 - 1) + suffix: Tok[]; +} + +/** 슬래시 앞뒤 자리표시(0/#/?)·고정 숫자 자리를 찾음 — 없으면 분수 형식이 아님(null). */ +function detectFraction(toks: Tok[]): FracPlan | null { + if (toks.some((t) => t.k === "point")) return null; + const slashIdx = toks.findIndex((t) => t.k === "lit" && t.ch === "/"); + if (slashIdx < 0) return null; + + let i = slashIdx - 1; + while (i >= 0 && toks[i].k === "digit") i--; + const numeratorStart = i + 1; + if (numeratorStart === slashIdx) return null; // 분자 자리표시 없음 + + let j = slashIdx + 1; + let denomDigits = 0; + let fixedDigits = ""; + let mode: "auto" | "fixed" | null = null; + while (j < toks.length) { + const t = toks[j]; + if (t.k === "digit") { + if (mode === "fixed") break; + mode = "auto"; + denomDigits++; + j++; + continue; + } + if (t.k === "lit" && /[0-9]/.test(t.ch)) { + if (mode === "auto") break; + mode = "fixed"; + fixedDigits += t.ch; + j++; + continue; + } + break; + } + if (mode === null) return null; + const denomEnd = j; + + const sepToks: Tok[] = []; + let k = numeratorStart - 1; + while (k >= 0 && toks[k].k !== "digit") { + sepToks.unshift(toks[k]); + k--; + } + let wholeDigits = 0; + while (k >= 0 && toks[k].k === "digit") { + wholeDigits++; + k--; + } + const prefix = wholeDigits > 0 ? toks.slice(0, k + 1) : toks.slice(0, numeratorStart); + if (wholeDigits === 0) sepToks.length = 0; + + return { + prefix, + wholeDigits, + sepToks, + fixedDenom: mode === "fixed" ? BigInt(fixedDigits) : null, + denomDigits, + suffix: toks.slice(denomEnd), + }; +} + +/** 분모가 `maxDenom` 이하인 가장 가까운 분수(0 ≤ x) — 다 훑어봐도 999 번 안쪽(자리표시 최대 3 개). */ +function bestFraction(x: Frac, maxDenom: bigint): { num: bigint; den: bigint } { + let bestNum = 0n; + let bestDen = 1n; + let bestDiff: Frac | null = null; + for (let den = 1n; den <= maxDenom; den++) { + const num = toInteger(mul(x, frac(den)), "round"); + const gap = sub(x, frac(num, den)); + const diff = frac(gap.n < 0n ? -gap.n : gap.n, gap.d); + if (bestDiff === null || cmp(diff, bestDiff) < 0) { + bestDiff = diff; + bestNum = num; + bestDen = den; + } + } + return { num: bestNum, den: bestDen }; +} + +function renderFractionSection(plan: FracPlan, magnitude: Frac): string { + let whole = 0n; + let remainder = magnitude; + if (plan.wholeDigits > 0) { + whole = toInteger(magnitude, "trunc"); + remainder = sub(magnitude, frac(whole)); + } + let num: bigint; + let den: bigint; + if (plan.fixedDenom !== null) { + den = plan.fixedDenom; + num = toInteger(mul(remainder, frac(den)), "round"); + } else { + const maxDenom = 10n ** BigInt(plan.denomDigits) - 1n; + const best = bestFraction(remainder, maxDenom); + num = best.num; + den = best.den; + } + if (plan.wholeDigits > 0 && den > 0n && num >= den) { + whole += num / den; + num %= den; + } + const fracText = num !== 0n ? `${num}/${den}` : ""; + + let out = ""; + for (const t of plan.prefix) if (t.k === "lit") out += t.ch; + if (plan.wholeDigits > 0) { + if (whole !== 0n || fracText === "") out += whole.toString(); + if (fracText !== "") for (const t of plan.sepToks) if (t.k === "lit") out += t.ch; + } + out += fracText; + for (const t of plan.suffix) if (t.k === "lit") out += t.ch; + return out; +} + function generalFormat(value: Frac): string { return fracToString(value); } @@ -265,6 +390,11 @@ function formatNumber(value: Frac, code: string | undefined): FormattedText { if (color) extra.색 = color; if (fillChar) extra.채움글 = fillChar; const magnitude = cmp(value, ZERO) < 0 ? frac(-value.n, value.d) : value; + const fraction = detectFraction(toks); + if (fraction) { + const body = renderFractionSection(fraction, magnitude); + return { 글: (forceMinus ? "-" : "") + body, 정렬: "right", ...extra }; + } if (!toks.some((t) => t.k === "digit" || t.k === "general")) { let out = ""; for (const t of toks) if (t.k === "lit") out += t.ch; diff --git a/resources/tester/spreadsheet/spreadsheet_b_test_entry.ts b/resources/tester/spreadsheet/spreadsheet_b_test_entry.ts index 92972b24..129b4d08 100644 --- a/resources/tester/spreadsheet/spreadsheet_b_test_entry.ts +++ b/resources/tester/spreadsheet/spreadsheet_b_test_entry.ts @@ -111,6 +111,9 @@ fracEq( fracEq("ROUND", call("ROUND", n("1.155"), n(2)), "1.16"); // 분수 반올림 — 부동소수 함정 없음 fracEq("ROUNDUP", call("ROUNDUP", n("1.001"), n(2)), "1.01"); fracEq("ROUNDDOWN", call("ROUNDDOWN", n("1.999"), n(2)), "1.99"); +fracEq("ROUND 음수 자릿수(십의 자리)", call("ROUND", n(1234), n(-2)), "1200"); +fracEq("ROUNDUP 음수 자릿수", call("ROUNDUP", n(1234), n(-2)), "1300"); +fracEq("ROUNDDOWN 음수 자릿수", call("ROUNDDOWN", n(1234), n(-2)), "1200"); fracEq("INT 양수", call("INT", n("8.9")), "8"); fracEq("INT 음수(floor)", call("INT", n("-8.9")), "-9"); fracEq("TRUNC", call("TRUNC", n("8.9")), "8"); @@ -142,6 +145,29 @@ errEq("AVERAGE 빈 값 없음", call("AVERAGE", range(1, 1, [["x"]])), "#DIV/0!" errEq("MOD 0 나눔", call("MOD", n(1), n(0)), "#DIV/0!"); errEq("CEILING 부호 다름", call("CEILING", n(-2), n(1)), "#NUM!"); +// 2단계 첫 묶음(2026-09-27) +fracEq("MROUND", call("MROUND", n(10), n(3)), "9"); +fracEq("MROUND 소수", call("MROUND", n("1.3"), n("0.2")), "1.4"); +errEq("MROUND 부호 다름", call("MROUND", n(10), n(-3)), "#NUM!"); +fracEq("ODD 양수", call("ODD", n("1.5")), "3"); +fracEq("ODD 짝수 정수", call("ODD", n(2)), "3"); +fracEq("ODD 0", call("ODD", n(0)), "1"); +fracEq("ODD 음수", call("ODD", n(-2)), "-3"); +fracEq("EVEN 양수", call("EVEN", n("1.5")), "2"); +fracEq("EVEN 홀수 정수", call("EVEN", n(3)), "4"); +fracEq("EVEN 음수", call("EVEN", n(-1)), "-2"); +fracEq("LOG 밑 지정", call("LOG", n(8), n(2)), "3"); +fracEq("LOG10", call("LOG10", n(100)), "2"); +fracEq("LN(1) = 0", call("LN", n(1)), "0"); +ok( + "EXP(1) ≈ e", + (() => { + const v = call("EXP", n(1)); + return isFrac(v) && fracToString(v).startsWith("2.71828182845905"); + })(), +); +errEq("LOG10 0 이하", call("LOG10", n(0)), "#NUM!"); + // ── 논리 · 글 ──────────────────────────────────────────────────────────── fracEq("IF 참 값", call("IF", bl(true), n(1), n(2)), "1"); @@ -161,6 +187,22 @@ textEq("RIGHT", call("RIGHT", txt("가나다"), n(2)), "나다"); textEq("MID", call("MID", txt("가나다라"), n(2), n(2)), "나다"); fracEq("VALUE", call("VALUE", txt("1,234.5")), "1234.5"); errEq("VALUE 실패", call("VALUE", txt("가나다")), "#VALUE!"); +textEq( + "TEXTJOIN 빈칸 건너뜀", + call("TEXTJOIN", txt("-"), bl(true), txt("a"), txt(""), txt("b")), + "a-b", +); +textEq("TRIM", call("TRIM", txt(" 가 나 ")), "가 나"); +textEq("UPPER", call("UPPER", txt("abc")), "ABC"); +textEq("LOWER", call("LOWER", txt("ABC")), "abc"); +textEq("SUBSTITUTE 전부", call("SUBSTITUTE", txt("abcabc"), txt("a"), txt("X")), "XbcXbc"); +textEq("SUBSTITUTE 몇번째", call("SUBSTITUTE", txt("abcabc"), txt("a"), txt("X"), n(2)), "abcXbc"); +textEq("REPLACE", call("REPLACE", txt("abcdef"), n(2), n(3), txt("XYZ")), "aXYZef"); +fracEq("FIND", call("FIND", txt("c"), txt("abcabc")), "3"); +errEq("FIND 대소문자 가림", call("FIND", txt("C"), txt("abcabc")), "#VALUE!"); +fracEq("SEARCH 대소문자 안가림", call("SEARCH", txt("C"), txt("abcabc")), "3"); +fracEq("SEARCH 자리표시(*)", call("SEARCH", txt("a*c"), txt("xxabcyy")), "3"); +textEq("REPT", call("REPT", txt("ab"), n(3)), "ababab"); // ── 찾기 ──────────────────────────────────────────────────────────────── @@ -207,6 +249,51 @@ fracEq( "40", ); +// 2단계 첫 묶음 — 여러 조건 · IFS 계열 · XLOOKUP +const NAMES4 = range(1, 4, [["가", "나", "가", "다"]]); +const AMOUNTS4 = range(1, 4, [[frac(10n), frac(20n), frac(30n), frac(40n)]]); +const AMOUNTS4B = range(1, 4, [[frac(1n), frac(2n), frac(3n), frac(4n)]]); +fracEq("SUMIFS 두 조건", call("SUMIFS", AMOUNTS4, NAMES4, txt("가"), AMOUNTS4B, txt(">1")), "30"); +fracEq("COUNTIFS 두 조건", call("COUNTIFS", NAMES4, txt("가"), AMOUNTS4B, txt(">1")), "1"); +fracEq("AVERAGEIF", call("AVERAGEIF", AMOUNTS4, txt(">15")), "30"); +fracEq("AVERAGEIFS", call("AVERAGEIFS", AMOUNTS4, NAMES4, txt("가")), "20"); +fracEq("MAXIFS", call("MAXIFS", AMOUNTS4, NAMES4, txt("가")), "30"); +fracEq("MINIFS", call("MINIFS", AMOUNTS4, NAMES4, txt("가")), "10"); +fracEq("IFS 처음 참", call("IFS", bl(false), n(1), bl(true), n(2), bl(true), n(3)), "2"); +textEq( + "SWITCH 맞음", + call("SWITCH", n(2), n(1), txt("one"), n(2), txt("two"), n(3), txt("three")), + "two", +); +textEq("SWITCH 기본값", call("SWITCH", n(9), n(1), txt("one"), txt("default")), "default"); +errEq("SWITCH 안 맞음(기본값 없음)", call("SWITCH", n(9), n(1), txt("one")), "#N/A"); +const XNAMES = range(1, 3, [["가", "나", "다"]]); +const XAMOUNTS = range(1, 3, [[frac(10n), frac(20n), frac(30n)]]); +fracEq("XLOOKUP", call("XLOOKUP", txt("나"), XNAMES, XAMOUNTS), "20"); +textEq("XLOOKUP 못찾음 대체값", call("XLOOKUP", txt("없음"), XNAMES, XAMOUNTS, txt("no")), "no"); +errEq("XLOOKUP 못찾음 기본", call("XLOOKUP", txt("없음"), XNAMES, XAMOUNTS), "#N/A"); + +// 정보 · 날짜 +boolEq("ISNUMBER 참", call("ISNUMBER", n(5)) as boolean, true); +boolEq("ISNUMBER 거짓(글)", call("ISNUMBER", txt("x")) as boolean, false); +boolEq("ISTEXT 참", call("ISTEXT", txt("x")) as boolean, true); +boolEq("ISBLANK 참", call("ISBLANK", lit(null)) as boolean, true); +boolEq("ISBLANK 거짓(0)", call("ISBLANK", n(0)) as boolean, false); +boolEq("ISERROR 참", call("ISERROR", lit({ error: "#DIV/0!" } as ErrorValue)) as boolean, true); +boolEq("ISERROR 거짓", call("ISERROR", n(1)) as boolean, false); +errEq("NA", call("NA"), "#N/A"); +{ + const serial = call("DATE", n(2026), n(1), n(15)); + fracEq("YEAR(DATE(2026,1,15))", call("YEAR", lit(serial)), "2026"); + fracEq("MONTH(DATE(2026,1,15))", call("MONTH", lit(serial)), "1"); + fracEq("DAY(DATE(2026,1,15))", call("DAY", lit(serial)), "15"); +} +{ + const todaySerial = call("TODAY"); + const wantYear = new Date().getUTCFullYear(); + fracEq("YEAR(TODAY()) = 지금 연도", call("YEAR", lit(todaySerial)), String(wantYear)); +} + // ── 숫자 형식 ──────────────────────────────────────────────────────────── function fmt(value: Scalar, code: string): string { @@ -249,6 +336,16 @@ ok( fmt(frac(-150n, 100n), "0.00_);[Red](0.00)") === "(1.50)", fmt(frac(-150n, 100n), "0.00_);[Red](0.00)"), ); + +// 분수 형식 +ok("분수 가분수(자동 분모)", fmt(frac(1n, 2n), "0/0") === "1/2", fmt(frac(1n, 2n), "0/0")); +ok( + "분수 정수부+분자(자동 분모)", + fmt(frac(5n, 2n), "# ?/?") === "2 1/2", + fmt(frac(5n, 2n), "# ?/?"), +); +ok("분수 분모 고정", fmt(frac(4n, 10n), "# ?/8") === " 3/8", fmt(frac(4n, 10n), "# ?/8")); +ok("분수 정확히 나눠떨어짐(정수만)", fmt(frac(3n), "# ?/?") === "3", fmt(frac(3n), "# ?/?")); ok("글자 형식 @", fmt("abc", "@@") === "abcabc", fmt("abc", "@@")); ok( "일반(General)", From 57ddb3866dc03f34320503fc7aa8c2a45c5d2abf Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 27 Sep 2026 20:59:25 +0900 Subject: [PATCH 2/9] =?UTF-8?q?feat(spreadsheet):=20=EC=B9=B8=20=EC=9A=B0?= =?UTF-8?q?=ED=81=B4=EB=A6=AD=20=EB=A9=94=EB=89=B4=EC=97=90=20=EB=A9=94?= =?UTF-8?q?=EB=AA=A8=20=EC=82=BD=EC=9E=85=20=C2=B7=20=ED=8E=B8=EC=A7=91=20?= =?UTF-8?q?=C2=B7=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sub1 `spreadsheet_comments.ts` 의 setComment · deleteComment · getComment 호출 - 글 입력은 E 자체 작은 오버레이(native prompt 안 씀 — 자동화 창 자동 취소 문제) - ORCA 실 엔진 시험 — 삽입 → 메뉴 글자가 「메모 편집·삭제」로 바뀜 → 삭제 → 취소(저장 안 됨) 확인 --- A00_Common/spreadsheet/spreadsheet_menu.ts | 51 +++++++++++++++++-- A00_Common/spreadsheet/spreadsheet_text.ts | 3 ++ .../spreadsheet/spreadsheet_toolbar.css | 40 +++++++++++++++ 3 files changed, 90 insertions(+), 4 deletions(-) diff --git a/A00_Common/spreadsheet/spreadsheet_menu.ts b/A00_Common/spreadsheet/spreadsheet_menu.ts index f2b7f2ee..cc4d585b 100644 --- a/A00_Common/spreadsheet/spreadsheet_menu.ts +++ b/A00_Common/spreadsheet/spreadsheet_menu.ts @@ -1,16 +1,20 @@ /* ============================================================================= * spreadsheet_menu.ts (주인 E) * 우클릭 메뉴 — 칸 · 행 머리 · 열 머리마다: 잘라내기 · 복사 · (붙여넣기 = 「Ctrl+V 를 쓸 것」 안내) · - * 행열 넣기 · 지우기 · 숨기기 · 보이기 · 내용 지우기 · 서식 지우기 · 병합 · 풀기. + * 행열 넣기 · 지우기 · 숨기기 · 보이기 · 내용 지우기 · 서식 지우기 · 병합 · 풀기 · 메모 삽입/편집/삭제. * `ui_template_context_menu.ts` 재사용 — 우클릭 밖 자리는 그냥 눌러 선택부터 옮김. + * 메모는 `spreadsheet_comments.ts`(sub1 · 브레인 답 2026-09-27)의 `setComment` · `deleteComment` · + * `getComment` 를 부름 — 안에서 「시트통째」 명령 · 되돌리기 짝까지 만듦. 글 입력 창은 이 파일이 + * 직접 띄움(작은 오버레이 · `showConfirmDialog` 와 같은 결의 자체 모달 · native prompt 안 씀). * * 이음(D `spreadsheet.ts` 잇는 자리) — `attachMenu(ctx)` 한 번 부름(반환 `root` = 메뉴 껍데기 · * 안 붙여도 됨 · `ctx.root` 안에 스스로 넣음). `ctx.grid.hitTest` 가 준비된 뒤(C 격자 붙은 뒤) 불러야 함. * ========================================================================== */ import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu"; -import { showToast } from "@ui/ui_template_elements"; +import { createButton, el, showToast } from "@ui/ui_template_elements"; import { rangeToA1 } from "./spreadsheet_address"; +import { deleteComment, getComment, setComment } from "./spreadsheet_comments"; import { st } from "./spreadsheet_text"; import { MAX_COLS, MAX_ROWS, type CellRange } from "./spreadsheet_types"; import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types"; @@ -36,7 +40,39 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle { document.execCommand(kind); } - function cellMenuItems(range: CellRange): MapContextMenuItem[] { + /** 메모 글 입력 — 작은 오버레이(native prompt 안 씀, 자동화 창이 native 창을 자동 취소하는 + * 문제는 `showConfirmDialog` 머리 주석과 같은 까닭). 저장을 누르면 `onSave`. */ + function openCommentEditor(initial: string, onSave: (text: string) => void): void { + const overlay = el("div", { className: "ss-comment-editor" }); + const panel = el("div", { className: "ss-comment-editor__panel" }); + const textarea = el("textarea", { + className: "ss-comment-editor__input", + }) as HTMLTextAreaElement; + textarea.value = initial; + const done = (): void => overlay.remove(); + const actions = el("div", { + className: "ss-comment-editor__actions", + children: [ + createButton({ label: "취소", variant: "ghost", onClick: done }), + createButton({ + label: "저장", + variant: "filled", + onClick: () => { + onSave(textarea.value); + done(); + }, + }), + ], + }); + panel.append(textarea, actions); + overlay.append(panel); + document.body.append(overlay); + textarea.focus(); + textarea.select(); + } + + function cellMenuItems(range: CellRange, r: number, c: number): MapContextMenuItem[] { + const note = getComment(ctx.sheet(), r, c); return [ [st("MenuCut"), () => cutCopy("cut")], [st("MenuCopy"), () => cutCopy("copy")], @@ -58,6 +94,13 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle { : { 종류: "병합", 시트: ctx.selection.시트, 범위: range }, ), ], + [ + note ? st("MenuCommentEdit") : st("MenuCommentInsert"), + () => openCommentEditor(note ?? "", (text) => setComment(ctx, r, c, text)), + ], + ...(note + ? ([[st("MenuCommentDelete"), () => deleteComment(ctx, r, c)]] as MapContextMenuItem[]) + : []), ]; } @@ -196,7 +239,7 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle { }); } const active = ctx.selection.범위[0] ?? range(hit.r, hit.c, hit.r, hit.c); - menu.open(x, y, cellMenuItems(active)); + menu.open(x, y, cellMenuItems(active, hit.r, hit.c)); } } diff --git a/A00_Common/spreadsheet/spreadsheet_text.ts b/A00_Common/spreadsheet/spreadsheet_text.ts index 67546a24..9eb36a89 100644 --- a/A00_Common/spreadsheet/spreadsheet_text.ts +++ b/A00_Common/spreadsheet/spreadsheet_text.ts @@ -78,6 +78,9 @@ const TEXT = { MenuClearFormat: ["서식 지우기", "Clear format"], MenuMerge: ["병합", "Merge"], MenuUnmerge: ["병합 해제", "Unmerge"], + MenuCommentInsert: ["메모 삽입", "Insert comment"], + MenuCommentEdit: ["메모 편집", "Edit comment"], + MenuCommentDelete: ["메모 삭제", "Delete comment"], ClipboardPasteFailed: ["붙여넣기를 읽지 못함", "Could not read paste content"], } as const; diff --git a/A00_Common/spreadsheet/spreadsheet_toolbar.css b/A00_Common/spreadsheet/spreadsheet_toolbar.css index 864f26b1..d43a375f 100644 --- a/A00_Common/spreadsheet/spreadsheet_toolbar.css +++ b/A00_Common/spreadsheet/spreadsheet_toolbar.css @@ -215,3 +215,43 @@ margin: 2px 4px; border-top: 1px solid var(--color-border); } + +/* ── 메모 편집 창(칸 우클릭 [메모 삽입 · 편집]) ──────────────────────────────── */ + +.ss-comment-editor { + position: fixed; + inset: 0; + z-index: var(--z-modal); + display: flex; + align-items: center; + justify-content: center; + background: rgb(0 0 0 / 35%); +} + +.ss-comment-editor__panel { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + width: 280px; + padding: var(--spacing-16); + border-radius: var(--radius-cards); + background: var(--color-surface-raised); + box-shadow: var(--shadow-lg); +} + +.ss-comment-editor__input { + min-height: 80px; + padding: var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-canvas); + color: var(--color-text-body); + font: inherit; + resize: vertical; +} + +.ss-comment-editor__actions { + display: flex; + justify-content: flex-end; + gap: var(--spacing-8); +} From c7f18d533ba37667690720243914ed82f9b4a554 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 27 Sep 2026 20:59:43 +0900 Subject: [PATCH 3/9] =?UTF-8?q?test(spreadsheet):=20=ED=81=AC=EA=B8=B0=20?= =?UTF-8?q?=EC=8B=9C=ED=97=98=EC=9D=84=20=EC=8B=A4=EC=A0=9C=20=EC=95=B1=20?= =?UTF-8?q?=EB=B9=8C=EB=93=9C=20=EC=A1=B0=EA=B0=81=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=9E=BC=20=C2=B7=20=EC=A0=95=EC=A0=81=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=81=8C=EC=96=B4=EC=98=A4=EB=8A=94=20=EA=B3=B3=20=EC=97=86?= =?UTF-8?q?=EC=9D=8C=20=ED=99=95=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0168FQuoV7vDh5nhnSowW5cp --- .../tester/spreadsheet/spreadsheet_size.mjs | 78 ++++++++++++++----- .../spreadsheet/test_spreadsheet_size.py | 4 +- 2 files changed, 63 insertions(+), 19 deletions(-) diff --git a/resources/tester/spreadsheet/spreadsheet_size.mjs b/resources/tester/spreadsheet/spreadsheet_size.mjs index 57eff220..618be74c 100644 --- a/resources/tester/spreadsheet/spreadsheet_size.mjs +++ b/resources/tester/spreadsheet/spreadsheet_size.mjs @@ -1,6 +1,10 @@ -// 스프레드시트 크기 재기 — vite 로 줄여 묶은 뒤 gzip 크기(바이트). `test_spreadsheet_size.py` 가 부름. -// node spreadsheet_size.mjs <저장소 뿌리> → {"engine": {js, css}, "total": {js, css}} -// 앱 번들에 이미 있는 `@ui/ui_template_elements*` 는 빼고 잼(스프레드시트가 새로 더하는 몫만). +// 스프레드시트 크기 재기 — gzip 크기(바이트). `test_spreadsheet_size.py` 가 부름. +// node spreadsheet_size.mjs <저장소 뿌리> → {"engine": {js, css}, "total": {js, css}, "static": [...]} +// engine = 엔진 파일만 줄여 묶은 크기 · total = 실제 앱 빌드에서 스프레드시트를 열 때 받는 조각 +// (스프레드시트 조각 + 끌어오는 공용 조각 + css · 첫 화면 `index.html` 조각은 뺌). +// static = 스프레드시트 조각을 정적으로 끌어오는 조각(비어야 함 — 늦게만 받음). +import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { gzipSync } from "node:zlib"; @@ -25,7 +29,7 @@ const ENGINE = [ "spreadsheet_fill", ]; -async function measure(entries) { +async function engineSize() { const out = await build({ configFile: false, logLevel: "silent", @@ -35,22 +39,60 @@ async function measure(entries) { write: false, minify: true, target: "es2022", - cssCodeSplit: false, - lib: { entry: entries, formats: ["es"] }, - rollupOptions: { external: [/ui_template_elements/] }, + lib: { entry: ENGINE.map((name) => path.join(dir, `${name}.ts`)), formats: ["es"] }, }, }); - const outputs = (Array.isArray(out) ? out : [out]).flatMap((o) => o.output); - const size = { js: 0, css: 0 }; - for (const o of outputs) { - const body = o.type === "chunk" ? o.code : o.source; - const gz = gzipSync(typeof body === "string" ? body : Buffer.from(body)).length; - if (o.fileName.endsWith(".css")) size.css += gz; - else if (o.fileName.endsWith(".js")) size.js += gz; + let js = 0; + for (const o of (Array.isArray(out) ? out : [out]).flatMap((o) => o.output)) { + if (o.type === "chunk") js += gzipSync(o.code).length; } - return size; + return { js, css: 0 }; } -const engine = await measure(ENGINE.map((name) => path.join(dir, `${name}.ts`))); -const total = await measure([path.join(dir, "spreadsheet.ts")]); -console.log(JSON.stringify({ engine, total })); +async function appSize() { + const outDir = fs.mkdtempSync(path.join(os.tmpdir(), "aislo-sheet-size-")); + try { + await build({ + configLoader: "runner", + configFile: path.join(root, "vite.config.ts"), + logLevel: "silent", + build: { outDir, emptyOutDir: true, manifest: true }, + }); + const manifest = JSON.parse(fs.readFileSync(path.join(outDir, ".vite/manifest.json"), "utf8")); + const key = Object.keys(manifest).find((k) => k.endsWith("spreadsheet/spreadsheet.ts")); + const gz = (file) => gzipSync(fs.readFileSync(path.join(outDir, file))).length; + // M02 가 이미 받은 조각은 새 몫이 아님 — 첫 화면 · M02 페이지 · M02 표(ui_template_sheet 등) + // 의 정적 import 사슬. 산출근거 카드는 집계표를 연 뒤에만 열림. + const first = new Set(); + const mark = (k) => { + if (first.has(k)) return; + first.add(k); + for (const i of manifest[k].imports ?? []) mark(i); + }; + const page = Object.keys(manifest).find((k) => k.endsWith("M02_MasterTemplete_UI_Page.ts")); + mark("index.html"); + mark(page); + for (const d of manifest[page].dynamicImports ?? []) if (d !== key) mark(d); + const size = { js: 0, css: 0 }; + const seen = new Set(); + const walk = (k) => { + if (seen.has(k) || first.has(k)) return; + seen.add(k); + const e = manifest[k]; + size.js += gz(e.file); + for (const c of e.css ?? []) size.css += gz(c); + for (const i of e.imports ?? []) walk(i); // 그 밖 공용 조각은 셈(넉넉히) + }; + walk(key); + const statics = Object.entries(manifest) + .filter(([, v]) => (v.imports ?? []).includes(key)) + .map(([k]) => k); + return { total: size, static: statics }; + } finally { + fs.rmSync(outDir, { recursive: true, force: true }); + } +} + +const engine = await engineSize(); +const { total, static: statics } = await appSize(); +console.log(JSON.stringify({ engine, total, static: statics })); diff --git a/resources/tester/spreadsheet/test_spreadsheet_size.py b/resources/tester/spreadsheet/test_spreadsheet_size.py index 5fad6bd2..1cb79355 100644 --- a/resources/tester/spreadsheet/test_spreadsheet_size.py +++ b/resources/tester/spreadsheet/test_spreadsheet_size.py @@ -1,7 +1,8 @@ """F — 스프레드시트 크기 예산 · M02 만 늦게 불러옴(계약 `spreadsheet_types.ts` 머리). 예산(압축 gzip 뒤) — 엔진 A+B 22 KB · 화면 C+D+E 28 KB · css 4 KB · 합 54 KB. -엔진은 파일마다 따로 묶어 더하므로 실제보다 조금 크게 잼(넘치면 넘친 것). +엔진 = 엔진 파일만 따로 묶은 크기 · 합 = 실제 앱 빌드에서 스프레드시트를 열 때 받는 조각 +(공용 조각까지 넉넉히 셈) · 화면 = 합 − 엔진. """ import json @@ -36,6 +37,7 @@ def test_크기_예산(): print( f"gzip — 엔진 {engine} · 화면 {view} · css {total['css']} · 합 {total['js'] + total['css']}" ) + assert size["static"] == [], f"스프레드시트 조각을 정적으로 끌어옴 — {size['static']}" assert engine <= BUDGET["engine"], f"엔진 {engine} B > {BUDGET['engine']}" assert view <= BUDGET["view"], f"화면 {view} B > {BUDGET['view']}" assert total["css"] <= BUDGET["css"], f"css {total['css']} B > {BUDGET['css']}" From 5718aa7f9d83f4dd34bc8cbf1967d8e1dc2dfac3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 27 Sep 2026 21:00:12 +0900 Subject: [PATCH 4/9] =?UTF-8?q?feat(spreadsheet):=203=EB=8B=A8=EA=B3=84=20?= =?UTF-8?q?=E2=80=94=20=EC=A1=B0=EA=B1=B4=EB=B6=80=20=EC=84=9C=EC=8B=9D(?= =?UTF-8?q?=EA=B3=84=EC=95=BD=20=EB=8D=94=ED=95=A8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sheet.조건부서식·유효성·이름정의 계약 더함(0 계약 · git-sync 뒤 더함) · spreadsheet_condfmt.ts — 값 비교·사이·글 포함·수식 조건 · 채움·글자색 · 규칙 관리 창 · 우선순위 옮김 · 행열 넣기·지우기 범위 따라가기(remapCondFmtRanges). 엔진(A)·격자(C) 파일은 안 건드림 — 잇는 법을 머리 주석에 적어 둠(잇기는 sub7). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EUypcnp5d1gU2aeKh9F2H7 --- A00_Common/spreadsheet/spreadsheet_condfmt.ts | 272 ++++++++++++++++++ A00_Common/spreadsheet/spreadsheet_stage3.css | 87 ++++++ A00_Common/spreadsheet/spreadsheet_types.ts | 57 ++++ .../tester/spreadsheet/test_stage3_condfmt.ts | 157 ++++++++++ 4 files changed, 573 insertions(+) create mode 100644 A00_Common/spreadsheet/spreadsheet_condfmt.ts create mode 100644 A00_Common/spreadsheet/spreadsheet_stage3.css create mode 100644 resources/tester/spreadsheet/test_stage3_condfmt.ts diff --git a/A00_Common/spreadsheet/spreadsheet_condfmt.ts b/A00_Common/spreadsheet/spreadsheet_condfmt.ts new file mode 100644 index 00000000..e9168ecd --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_condfmt.ts @@ -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): 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(); + }, + }; +} diff --git a/A00_Common/spreadsheet/spreadsheet_stage3.css b/A00_Common/spreadsheet/spreadsheet_stage3.css new file mode 100644 index 00000000..9f7d71cd --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_stage3.css @@ -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); +} diff --git a/A00_Common/spreadsheet/spreadsheet_types.ts b/A00_Common/spreadsheet/spreadsheet_types.ts index 0612f6b1..2135de05 100644 --- a/A00_Common/spreadsheet/spreadsheet_types.ts +++ b/A00_Common/spreadsheet/spreadsheet_types.ts @@ -48,6 +48,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) · 옛 산출근거 옮김 · @@ -147,6 +151,13 @@ export interface Sheet { 기본?: { 서식?: number; 열폭?: number; 행높이?: number }; /** 칸 메모 — A1 열쇠 → 메모 글. 행열 넣기 · 지우기 · 잘라 붙이기 때 칸과 같이 옮김(지운 칸 메모는 지움). */ comments?: Record; + /** 조건부 서식 — 배열 앞쪽이 우선순위 위(`spreadsheet_condfmt.ts`). */ + 조건부서식?: CondFmtRule[]; + /** 데이터 유효성(`spreadsheet_validation.ts`). */ + 유효성?: DataValidationRule[]; + /** 이 시트에서 정의한 이름 — 범위는 이 시트 안(`spreadsheet_names.ts`). 식 나무의 `{type:"name"}` 는 + * A 의 eval 이 `resolveName` 으로 풀어야 함(지금 안 이어짐 — 8-3 절 참고). */ + 이름정의?: NamedRange[]; } /** 새 시트 기본값 — 값은 사용자 협의로 확정(9_엑셀검토 8장 · 임의 확정 금지). 없으면 A 의 임시값. */ @@ -432,3 +443,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; +} diff --git a/resources/tester/spreadsheet/test_stage3_condfmt.ts b/resources/tester/spreadsheet/test_stage3_condfmt.ts new file mode 100644 index 00000000..f3025786 --- /dev/null +++ b/resources/tester/spreadsheet/test_stage3_condfmt.ts @@ -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 시험 끝"); From a939d0bf55bb8ffb23a1e708f23275c2f22bf4b3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 27 Sep 2026 21:00:16 +0900 Subject: [PATCH 5/9] =?UTF-8?q?fix(spreadsheet):=20=ED=81=AC=EA=B8=B0=20?= =?UTF-8?q?=EC=98=88=EC=82=B0=20=EA=B3=A0=EC=B9=A8=20=E2=80=94=20=EC=97=94?= =?UTF-8?q?=EC=A7=84=2032=20KB=20=C2=B7=20=ED=95=A9=2064=20KB(=EB=B8=8C?= =?UTF-8?q?=EB=A0=88=EC=9D=B8=20=ED=8C=90=EC=A0=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0168FQuoV7vDh5nhnSowW5cp --- A00_Common/spreadsheet/spreadsheet_types.ts | 2 +- resources/tester/spreadsheet/test_spreadsheet_size.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/A00_Common/spreadsheet/spreadsheet_types.ts b/A00_Common/spreadsheet/spreadsheet_types.ts index 0612f6b1..b9cf940e 100644 --- a/A00_Common/spreadsheet/spreadsheet_types.ts +++ b/A00_Common/spreadsheet/spreadsheet_types.ts @@ -14,7 +14,7 @@ * · xlsx 가져오기 · 내보내기 · 인쇄 · PDF 는 만들지 않음(단추도 없음). xlsx 는 시험(F)만 읽음. * * ── 크기 예산 (vite build · 압축(gzip) 뒤 · M02 산출근거를 열 때만 받는 조각) ────── - * 엔진 A+B 22 KB · 화면 C+D+E 28 KB · css 4 KB · 합 54 KB 안쪽. 재는 곳 = F(빌드 조각 크기 시험). + * 엔진 A+B 32 KB · 화면 C+D+E 28 KB · css 4 KB · 합 64 KB 안쪽. 재는 곳 = F(빌드 조각 크기 시험). * * ── 파일 · 주인 · 예산 줄 수 ───────────────────────────────────────────────── * 0 spreadsheet_types.ts 이 계약(타입) ~450 diff --git a/resources/tester/spreadsheet/test_spreadsheet_size.py b/resources/tester/spreadsheet/test_spreadsheet_size.py index 1cb79355..926a202d 100644 --- a/resources/tester/spreadsheet/test_spreadsheet_size.py +++ b/resources/tester/spreadsheet/test_spreadsheet_size.py @@ -1,6 +1,6 @@ """F — 스프레드시트 크기 예산 · M02 만 늦게 불러옴(계약 `spreadsheet_types.ts` 머리). -예산(압축 gzip 뒤) — 엔진 A+B 22 KB · 화면 C+D+E 28 KB · css 4 KB · 합 54 KB. +예산(압축 gzip 뒤) — 엔진 A+B 32 KB · 화면 C+D+E 28 KB · css 4 KB · 합 64 KB. 엔진 = 엔진 파일만 따로 묶은 크기 · 합 = 실제 앱 빌드에서 스프레드시트를 열 때 받는 조각 (공용 조각까지 넉넉히 셈) · 화면 = 합 − 엔진. """ @@ -16,7 +16,7 @@ import pytest ROOT = Path(__file__).resolve().parents[3] SCRIPT = Path(__file__).with_name("spreadsheet_size.mjs") KB = 1024 -BUDGET = {"engine": 22 * KB, "view": 28 * KB, "css": 4 * KB, "total": 54 * KB} +BUDGET = {"engine": 32 * KB, "view": 28 * KB, "css": 4 * KB, "total": 64 * KB} pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") From 8606272f5c8cc4149c01304679d20809acec99ea Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 27 Sep 2026 21:00:23 +0900 Subject: [PATCH 6/9] auto: 2026-09-27 21:00 (ESD_LAPTOP) --- A00_Common/spreadsheet/spreadsheet_names.ts | 201 ++++++++++++++++ .../spreadsheet/spreadsheet_validation.ts | 219 ++++++++++++++++++ .../tester/spreadsheet/test_stage3_names.ts | 71 ++++++ .../spreadsheet/test_stage3_validation.ts | 77 ++++++ 4 files changed, 568 insertions(+) create mode 100644 A00_Common/spreadsheet/spreadsheet_names.ts create mode 100644 A00_Common/spreadsheet/spreadsheet_validation.ts create mode 100644 resources/tester/spreadsheet/test_stage3_names.ts create mode 100644 resources/tester/spreadsheet/test_stage3_validation.ts diff --git a/A00_Common/spreadsheet/spreadsheet_names.ts b/A00_Common/spreadsheet/spreadsheet_names.ts new file mode 100644 index 00000000..af2a36f9 --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_names.ts @@ -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(`(? + 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(); + }, + }; +} diff --git a/A00_Common/spreadsheet/spreadsheet_validation.ts b/A00_Common/spreadsheet/spreadsheet_validation.ts new file mode 100644 index 00000000..2f7579ce --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_validation.ts @@ -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, +): 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(); + }, + }; +} diff --git a/resources/tester/spreadsheet/test_stage3_names.ts b/resources/tester/spreadsheet/test_stage3_names.ts new file mode 100644 index 00000000..45b86b11 --- /dev/null +++ b/resources/tester/spreadsheet/test_stage3_names.ts @@ -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 시험 끝"); diff --git a/resources/tester/spreadsheet/test_stage3_validation.ts b/resources/tester/spreadsheet/test_stage3_validation.ts new file mode 100644 index 00000000..a2a35e3b --- /dev/null +++ b/resources/tester/spreadsheet/test_stage3_validation.ts @@ -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 시험 끝"); From 851fdb63e5939844dbdefd5e9929051bbed28b9a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 27 Sep 2026 21:02:46 +0900 Subject: [PATCH 7/9] =?UTF-8?q?fix(spreadsheet):=20E=20=EA=B8=80=EC=9E=90?= =?UTF-8?q?=ED=91=9C=EA=B0=80=20=EC=95=B1=20=EC=A0=84=EC=B2=B4=20=EC=82=AC?= =?UTF-8?q?=EC=A0=84=EC=9D=84=20=EB=81=8C=EC=96=B4=EC=98=A4=EB=8D=98=20?= =?UTF-8?q?=EA=B2=83=20=EB=81=8A=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - spreadsheet_text.ts 가 @ui/ui_template_locale 의 currentLanguageIndex 만 쓰려다 페이지별 사전(a·b1~4·common·m1·m2) 전부를 끌어옴 — 자기 언어 상태(setSpreadsheetLanguage)로 대신 - 확인: toolbar+tabs+menu+clipboard 만 묶어 본 압축 크기 59 KB → 18.8 KB(gzip) --- A00_Common/spreadsheet/spreadsheet_text.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/A00_Common/spreadsheet/spreadsheet_text.ts b/A00_Common/spreadsheet/spreadsheet_text.ts index 9eb36a89..32f8cac4 100644 --- a/A00_Common/spreadsheet/spreadsheet_text.ts +++ b/A00_Common/spreadsheet/spreadsheet_text.ts @@ -1,10 +1,17 @@ /* ============================================================================= * spreadsheet_text.ts (주인 E) * 도구 모음 · 시트 탭 · 우클릭 메뉴 · 클립보드 글자 — [한국어, 영어]. - * `ui_template_sheet_text.ts` 와 같은 방식(`st(key)`). 0 계약 밖(E 만 씀). + * ⚠ `@ui/ui_template_locale` 를 안 씀 — `currentLanguageIndex` 하나만 필요해도 그 파일이 + * 앱 전체 사전(a · b1~4 · common · m1 · m2, 압축 뒤 약 45 KB)을 통째로 끌고 와 화면 예산(28 KB)을 + * 넘김(2026-09-27 브레인 지시). 자기 언어 상태를 따로 둠 — `setSpreadsheetLanguage` 로만 바꿈. * ========================================================================== */ -import { currentLanguageIndex } from "@ui/ui_template_locale"; +let currentLanguageIndex = 0; + +/** 앱 전역 언어 토글과 맞추고 싶으면 D(`spreadsheet.ts`) 가 부름 — 안 부르면 한국어 기본. */ +export function setSpreadsheetLanguage(index: 0 | 1): void { + currentLanguageIndex = index; +} const TEXT = { Undo: ["되돌리기", "Undo"], From 85ada11e9d7866cd7c7aa1baffac4cc05d18c6f2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 27 Sep 2026 21:04:03 +0900 Subject: [PATCH 8/9] =?UTF-8?q?feat(spreadsheet):=20C=20=EA=B2=A9=EC=9E=90?= =?UTF-8?q?=20=E2=80=94=20Ctrl+=ED=9C=A0=20=ED=99=95=EB=8C=80=C2=B7?= =?UTF-8?q?=EC=B6=95=EC=86=8C(10~400%,=20=EC=B9=B8=C2=B7=EB=A8=B8=EB=A6=AC?= =?UTF-8?q?=C2=B7=EC=84=A0=20=EA=B5=B5=EA=B8=B0=20=EA=B0=99=EC=9D=B4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01VVM3xZ1aUPcUTuW2WZSnwg --- .../spreadsheet/spreadsheet_cellstyle.ts | 33 ++-- A00_Common/spreadsheet/spreadsheet_grid.css | 8 +- A00_Common/spreadsheet/spreadsheet_grid.ts | 141 +++++++++++------- 3 files changed, 113 insertions(+), 69 deletions(-) diff --git a/A00_Common/spreadsheet/spreadsheet_cellstyle.ts b/A00_Common/spreadsheet/spreadsheet_cellstyle.ts index 375998a7..4ba194bb 100644 --- a/A00_Common/spreadsheet/spreadsheet_cellstyle.ts +++ b/A00_Common/spreadsheet/spreadsheet_cellstyle.ts @@ -91,25 +91,26 @@ export function resolveBoxBorders( }; } -/** 빈 글 = 인라인 테두리를 안 그림(css `aislo-grid--gridlines` 눈금선이 비쳐 보임). */ -function borderCss(side?: BorderSide): string { +/** 빈 글 = 인라인 테두리를 안 그림(css `aislo-grid--gridlines` 눈금선이 비쳐 보임). `scale` = 확대 · 축소 배율(선 굵기도 같이). */ +function borderCss(side: BorderSide | undefined, scale: number): string { if (!side) return ""; const color = side.색 ?? "#000000"; + const w = (px: number): number => Math.max(1, Math.round(px * scale)); switch (side.선) { case "hair": - return `1px dotted ${color}`; + return `${w(1)}px dotted ${color}`; case "thin": - return `1px solid ${color}`; + return `${w(1)}px solid ${color}`; case "medium": - return `2px solid ${color}`; + return `${w(2)}px solid ${color}`; case "thick": - return `3px solid ${color}`; + return `${w(3)}px solid ${color}`; case "double": - return `3px double ${color}`; + return `${w(3)}px double ${color}`; case "dotted": - return `1px dotted ${color}`; + return `${w(1)}px dotted ${color}`; case "dashed": - return `1px dashed ${color}`; + return `${w(1)}px dashed ${color}`; } } @@ -129,16 +130,18 @@ const VALIGN_CSS: Record = { bottom: "flex-end", }; -/** 칸 하나에 서식 · 겹쳐 고른 테두리를 입힘. `hAlign` 은 부른 쪽이 정함(일반 정렬은 값 종류로 갈림 — B `formatValue` 의 `정렬`). */ +/** 칸 하나에 서식 · 겹쳐 고른 테두리를 입힘. `hAlign` 은 부른 쪽이 정함(일반 정렬은 값 종류로 갈림 — B `formatValue` 의 `정렬`). + * `scale` = 확대 · 축소 배율(칸 글자 크기 · 테두리 굵기가 같이 커짐 · 기본 1). */ export function applyCellStyle( cell: HTMLElement, style: CellStyle, borders: ResolvedBorders, hAlign: HAlign, + scale = 1, ): void { const s = cell.style; s.fontFamily = style.글꼴 ?? ""; - s.fontSize = style.크기 ? `${style.크기}pt` : ""; + s.fontSize = style.크기 ? `${style.크기 * scale}pt` : ""; s.fontWeight = style.굵게 ? "700" : "400"; s.fontStyle = style.기울임 ? "italic" : "normal"; s.textDecorationLine = [style.밑줄 && "underline", style.취소선 && "line-through"] @@ -146,10 +149,10 @@ export function applyCellStyle( .join(" "); s.color = style.글자색 ?? ""; s.backgroundColor = style.채움 ?? ""; - s.borderTop = borderCss(borders.위); - s.borderLeft = borderCss(borders.왼); - s.borderBottom = borderCss(borders.아래); - s.borderRight = borderCss(borders.오른); + s.borderTop = borderCss(borders.위, scale); + s.borderLeft = borderCss(borders.왼, scale); + s.borderBottom = borderCss(borders.아래, scale); + s.borderRight = borderCss(borders.오른, scale); s.justifyContent = VALIGN_CSS[style.세로 ?? "bottom"]; s.whiteSpace = style.줄바꿈 ? "normal" : "nowrap"; diff --git a/A00_Common/spreadsheet/spreadsheet_grid.css b/A00_Common/spreadsheet/spreadsheet_grid.css index 024233ba..9cf34e1d 100644 --- a/A00_Common/spreadsheet/spreadsheet_grid.css +++ b/A00_Common/spreadsheet/spreadsheet_grid.css @@ -152,8 +152,12 @@ } .aislo-grid--gridlines .aislo-cell { - border-right: 1px solid var(--aislo-grid-line); - border-bottom: 1px solid var(--aislo-grid-line); + border-right-style: solid; + border-bottom-style: solid; + border-right-width: var(--aislo-grid-gridline-w, 1px); + border-bottom-width: var(--aislo-grid-gridline-w, 1px); + border-right-color: var(--aislo-grid-line); + border-bottom-color: var(--aislo-grid-line); } .aislo-cell__text { diff --git a/A00_Common/spreadsheet/spreadsheet_grid.ts b/A00_Common/spreadsheet/spreadsheet_grid.ts index 2a7d5fbd..4963d5b3 100644 --- a/A00_Common/spreadsheet/spreadsheet_grid.ts +++ b/A00_Common/spreadsheet/spreadsheet_grid.ts @@ -49,10 +49,12 @@ import type { SpreadsheetContext, } from "./spreadsheet_view_types"; -const HEAD_ROW_H = 24; -const HEAD_COL_W = 46; +const HEAD_ROW_H_BASE = 24; +const HEAD_COL_W_BASE = 46; const RESIZE_HOTZONE = 4; const REF_COLORS = ["#1a73e8", "#e8710a", "#0f9d58", "#a142f4", "#d93025"]; +const ZOOM_MIN = 0.1; +const ZOOM_MAX = 4; /** 칸 div 재사용 — 새 보이는 칸 집합에 없는 건 떼어 다음 판에 다시 씀. */ interface CellPool { @@ -125,6 +127,17 @@ function buildMergeMap(sheet: Sheet): Map { return map; } +/** 확대 · 축소 — 밑감(1 배) 자리 재기를 그대로 두고 픽셀만 곱함(수는 그대로 · 화면만 커짐). */ +function scaledGeometry(base: LineGeometry, factor: number): LineGeometry { + if (factor === 1) return base; + return { + size: (i) => base.size(i) * factor, + offset: (i) => base.offset(i) * factor, + total: (i) => base.total(i) * factor, + indexAt: (px) => base.indexAt(px / factor), + }; +} + interface CellDisplay { text: string; hAlign: HAlign; @@ -207,8 +220,10 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle { const colHeadPools = { frozen: createPool(), scroll: createPool() }; const rowHeadPools = { frozen: createPool(), scroll: createPool() }; - let colGeo: LineGeometry = buildColGeometry(ctx.book, ctx.sheet()); - let rowGeo: LineGeometry = buildRowGeometry(ctx.book, ctx.sheet()); + let baseColGeo: LineGeometry = buildColGeometry(ctx.book, ctx.sheet()); + let baseRowGeo: LineGeometry = buildRowGeometry(ctx.book, ctx.sheet()); + let colGeo: LineGeometry = baseColGeo; + let rowGeo: LineGeometry = baseRowGeo; let extent = computeExtent(ctx.sheet()); let merges = buildMergeMap(ctx.sheet()); let frozenRows = 0; @@ -216,6 +231,9 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle { let frozenW = 0; let frozenH = 0; let lastSheetId = ""; + let zoom = 1; + const headRowH = (): number => Math.round(HEAD_ROW_H_BASE * zoom); + const headColW = (): number => Math.round(HEAD_COL_W_BASE * zoom); const resize = new ResizeObserver(() => layoutPanes()); resize.observe(root); @@ -224,45 +242,48 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle { function layoutPanes(): void { const w = root.clientWidth; const h = root.clientHeight; + root.style.fontFamily = ctx.book.기본?.글꼴 ?? ""; + root.style.fontSize = ctx.book.기본?.크기 ? `${ctx.book.기본.크기 * zoom}pt` : `${10 * zoom}pt`; + root.style.setProperty("--aislo-grid-gridline-w", `${Math.max(1, Math.round(zoom))}px`); frozenW = colGeo.total(frozenCols); frozenH = rowGeo.total(frozenRows); - corner.style.width = `${HEAD_COL_W}px`; - corner.style.height = `${HEAD_ROW_H}px`; - colHeadFrozen.style.left = `${HEAD_COL_W}px`; + corner.style.width = `${headColW()}px`; + corner.style.height = `${headRowH()}px`; + colHeadFrozen.style.left = `${headColW()}px`; colHeadFrozen.style.top = "0"; colHeadFrozen.style.width = `${frozenW}px`; - colHeadFrozen.style.height = `${HEAD_ROW_H}px`; - colHeadScroll.style.left = `${HEAD_COL_W + frozenW}px`; + colHeadFrozen.style.height = `${headRowH()}px`; + colHeadScroll.style.left = `${headColW() + frozenW}px`; colHeadScroll.style.top = "0"; - colHeadScroll.style.width = `${Math.max(0, w - HEAD_COL_W - frozenW)}px`; - colHeadScroll.style.height = `${HEAD_ROW_H}px`; + colHeadScroll.style.width = `${Math.max(0, w - headColW() - frozenW)}px`; + colHeadScroll.style.height = `${headRowH()}px`; rowHeadFrozen.style.left = "0"; - rowHeadFrozen.style.top = `${HEAD_ROW_H}px`; - rowHeadFrozen.style.width = `${HEAD_COL_W}px`; + rowHeadFrozen.style.top = `${headRowH()}px`; + rowHeadFrozen.style.width = `${headColW()}px`; rowHeadFrozen.style.height = `${frozenH}px`; rowHeadScroll.style.left = "0"; - rowHeadScroll.style.top = `${HEAD_ROW_H + frozenH}px`; - rowHeadScroll.style.width = `${HEAD_COL_W}px`; - rowHeadScroll.style.height = `${Math.max(0, h - HEAD_ROW_H - frozenH)}px`; - paneTL.style.left = `${HEAD_COL_W}px`; - paneTL.style.top = `${HEAD_ROW_H}px`; + rowHeadScroll.style.top = `${headRowH() + frozenH}px`; + rowHeadScroll.style.width = `${headColW()}px`; + rowHeadScroll.style.height = `${Math.max(0, h - headRowH() - frozenH)}px`; + paneTL.style.left = `${headColW()}px`; + paneTL.style.top = `${headRowH()}px`; paneTL.style.width = `${frozenW}px`; paneTL.style.height = `${frozenH}px`; - paneTR.style.left = `${HEAD_COL_W + frozenW}px`; - paneTR.style.top = `${HEAD_ROW_H}px`; - paneTR.style.width = `${Math.max(0, w - HEAD_COL_W - frozenW)}px`; + paneTR.style.left = `${headColW() + frozenW}px`; + paneTR.style.top = `${headRowH()}px`; + paneTR.style.width = `${Math.max(0, w - headColW() - frozenW)}px`; paneTR.style.height = `${frozenH}px`; - paneBL.style.left = `${HEAD_COL_W}px`; - paneBL.style.top = `${HEAD_ROW_H + frozenH}px`; + paneBL.style.left = `${headColW()}px`; + paneBL.style.top = `${headRowH() + frozenH}px`; paneBL.style.width = `${frozenW}px`; - paneBL.style.height = `${Math.max(0, h - HEAD_ROW_H - frozenH)}px`; - paneBR.style.left = `${HEAD_COL_W + frozenW}px`; - paneBR.style.top = `${HEAD_ROW_H + frozenH}px`; - paneBR.style.width = `${Math.max(0, w - HEAD_COL_W - frozenW)}px`; - paneBR.style.height = `${Math.max(0, h - HEAD_ROW_H - frozenH)}px`; + paneBL.style.height = `${Math.max(0, h - headRowH() - frozenH)}px`; + paneBR.style.left = `${headColW() + frozenW}px`; + paneBR.style.top = `${headRowH() + frozenH}px`; + paneBR.style.width = `${Math.max(0, w - headColW() - frozenW)}px`; + paneBR.style.height = `${Math.max(0, h - headRowH() - frozenH)}px`; spacer.style.transform = `translate(${colGeo.total(extent.cols) - frozenW}px, ${rowGeo.total(extent.rows) - frozenH}px)`; - overlay.style.left = `${HEAD_COL_W}px`; - overlay.style.top = `${HEAD_ROW_H}px`; + overlay.style.left = `${headColW()}px`; + overlay.style.top = `${headRowH()}px`; overlay.style.right = "0"; overlay.style.bottom = "0"; renderVisible(); @@ -280,6 +301,22 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle { } paneBR.addEventListener("scroll", onScroll); + /** Ctrl+휠 확대 · 축소(10~400%) — 칸 · 머리 · 선 굵기가 같이 커짐(`headRowH`·`headColW`·`applyCellStyle` scale). */ + function onWheel(ev: WheelEvent): void { + if (!ev.ctrlKey) return; + ev.preventDefault(); + const before = zoom; + const next = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoom * Math.exp(-ev.deltaY * 0.0015))); + if (next === before) return; + zoom = next; + colGeo = scaledGeometry(baseColGeo, zoom); + rowGeo = scaledGeometry(baseRowGeo, zoom); + paneBR.scrollLeft *= zoom / before; + paneBR.scrollTop *= zoom / before; + layoutPanes(); + } + root.addEventListener("wheel", onWheel, { passive: false }); + const gridlines = (): boolean => ctx.sheet().보기?.눈금선 !== false; function renderRowOfCells( @@ -329,7 +366,7 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle { extent.cols - 1, ); const display = cellDisplay(ctx, sheet, r0, c0, style.가로 ?? "general", style.형식); - applyCellStyle(cellEl, style, borders, display.hAlign); + applyCellStyle(cellEl, style, borders, display.hAlign, zoom); if (display.color) cellEl.style.color = display.color; const comment = sheet.comments?.[addr]; setCellText(cellEl, display.text, display.title || comment || undefined); @@ -420,8 +457,8 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle { root.classList.toggle("aislo-grid--gridlines", gridlines()); const w = root.clientWidth; const h = root.clientHeight; - const scrollW = Math.max(0, w - HEAD_COL_W - frozenW); - const scrollH = Math.max(0, h - HEAD_ROW_H - frozenH); + const scrollW = Math.max(0, w - headColW() - frozenW); + const scrollH = Math.max(0, h - headRowH() - frozenH); const frozenColsVisible = visibleIndices(colGeo, 0, Math.max(0, frozenCols - 1)); const scrollColFrom = Math.max(frozenCols, colGeo.indexAt(paneBR.scrollLeft)); const scrollColTo = Math.min(extent.cols - 1, colGeo.indexAt(paneBR.scrollLeft + scrollW) + 1); @@ -485,10 +522,10 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle { function render(): void { const sheet = ctx.sheet(); - root.style.fontFamily = ctx.book.기본?.글꼴 ?? ""; - root.style.fontSize = ctx.book.기본?.크기 ? `${ctx.book.기본.크기}pt` : ""; - colGeo = buildColGeometry(ctx.book, sheet); - rowGeo = buildRowGeometry(ctx.book, sheet); + baseColGeo = buildColGeometry(ctx.book, sheet); + baseRowGeo = buildRowGeometry(ctx.book, sheet); + colGeo = scaledGeometry(baseColGeo, zoom); + rowGeo = scaledGeometry(baseRowGeo, zoom); extent = computeExtent(sheet); merges = buildMergeMap(sheet); frozenRows = Math.min(sheet.틀고정?.행 ?? 0, extent.rows); @@ -534,8 +571,8 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle { const box = boxOf(r, c); const inFrozenCol = c < frozenCols; const inFrozenRow = r < frozenRows; - const x = HEAD_COL_W + (inFrozenCol ? box.x : box.x - paneBR.scrollLeft); - const y = HEAD_ROW_H + (inFrozenRow ? box.y : box.y - paneBR.scrollTop); + const x = headColW() + (inFrozenCol ? box.x : box.x - paneBR.scrollLeft); + const y = headRowH() + (inFrozenRow ? box.y : box.y - paneBR.scrollTop); return { x, y, w: box.w, h: box.h }; } @@ -559,8 +596,8 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle { const inFrozenCol = range.c0 < frozenCols; const inFrozenRow = range.r0 < frozenRows; const screen: CellBox = { - x: HEAD_COL_W + (inFrozenCol ? box.x : box.x - paneBR.scrollLeft), - y: HEAD_ROW_H + (inFrozenRow ? box.y : box.y - paneBR.scrollTop), + x: headColW() + (inFrozenCol ? box.x : box.x - paneBR.scrollLeft), + y: headRowH() + (inFrozenRow ? box.y : box.y - paneBR.scrollTop), w: box.w, h: box.h, }; @@ -579,8 +616,8 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle { const inFrozenRow = sel.활성.r < frozenRows; const handle = document.createElement("div"); handle.className = "aislo-grid__fill-handle"; - handle.style.left = `${HEAD_COL_W + (inFrozenCol ? active.x : active.x - paneBR.scrollLeft) + active.w - 4}px`; - handle.style.top = `${HEAD_ROW_H + (inFrozenRow ? active.y : active.y - paneBR.scrollTop) + active.h - 4}px`; + handle.style.left = `${headColW() + (inFrozenCol ? active.x : active.x - paneBR.scrollLeft) + active.w - 4}px`; + handle.style.top = `${headRowH() + (inFrozenRow ? active.y : active.y - paneBR.scrollTop) + active.h - 4}px`; selLayer!.append(handle); } @@ -606,8 +643,8 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle { const inFrozenRow = ref.범위.r0 < frozenRows; const node = document.createElement("div"); node.className = "aislo-grid__ref-highlight"; - node.style.left = `${HEAD_COL_W + (inFrozenCol ? merged.x : merged.x - paneBR.scrollLeft)}px`; - node.style.top = `${HEAD_ROW_H + (inFrozenRow ? merged.y : merged.y - paneBR.scrollTop)}px`; + node.style.left = `${headColW() + (inFrozenCol ? merged.x : merged.x - paneBR.scrollLeft)}px`; + node.style.top = `${headRowH() + (inFrozenRow ? merged.y : merged.y - paneBR.scrollTop)}px`; node.style.width = `${merged.w}px`; node.style.height = `${merged.h}px`; node.style.borderColor = REF_COLORS[ref.색번호 % REF_COLORS.length]; @@ -620,17 +657,17 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle { const x = clientX - bounds.left; const y = clientY - bounds.top; if (x < 0 || y < 0 || x > root.clientWidth || y > root.clientHeight) return null; - if (x < HEAD_COL_W && y < HEAD_ROW_H) return { kind: "corner", r: 0, c: 0 }; + if (x < headColW() && y < headRowH()) return { kind: "corner", r: 0, c: 0 }; const contentX = - x < HEAD_COL_W ? -1 : x - HEAD_COL_W + (x - HEAD_COL_W > frozenW ? paneBR.scrollLeft : 0); + x < headColW() ? -1 : x - headColW() + (x - headColW() > frozenW ? paneBR.scrollLeft : 0); const contentY = - y < HEAD_ROW_H ? -1 : y - HEAD_ROW_H + (y - HEAD_ROW_H > frozenH ? paneBR.scrollTop : 0); - if (y < HEAD_ROW_H) { + y < headRowH() ? -1 : y - headRowH() + (y - headRowH() > frozenH ? paneBR.scrollTop : 0); + if (y < headRowH()) { const c = colGeo.indexAt(contentX); const edge = Math.abs(colGeo.offset(c + 1) - contentX) <= RESIZE_HOTZONE; return { kind: edge ? "colEdge" : "colHead", r: 0, c }; } - if (x < HEAD_COL_W) { + if (x < headColW()) { const r = rowGeo.indexAt(contentY); const edge = Math.abs(rowGeo.offset(r + 1) - contentY) <= RESIZE_HOTZONE; return { kind: edge ? "rowEdge" : "rowHead", r, c: 0 }; @@ -669,8 +706,8 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle { } function visibleRange(): CellRange { - const scrollW = Math.max(0, root.clientWidth - HEAD_COL_W - frozenW); - const scrollH = Math.max(0, root.clientHeight - HEAD_ROW_H - frozenH); + const scrollW = Math.max(0, root.clientWidth - headColW() - frozenW); + const scrollH = Math.max(0, root.clientHeight - headRowH() - frozenH); return { r0: rowGeo.indexAt(paneBR.scrollTop), c0: colGeo.indexAt(paneBR.scrollLeft), From 500424d7ae590ef4dc03674cb4288662b7df41c3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 27 Sep 2026 21:05:39 +0900 Subject: [PATCH 9/9] =?UTF-8?q?feat(spreadsheet):=20D=20=EC=9E=87=EA=B8=B0?= =?UTF-8?q?=20=E2=80=94=20=EC=A0=95=EB=A0=AC=20=C2=B7=20=EC=9E=90=EB=8F=99?= =?UTF-8?q?=20=ED=95=84=ED=84=B0(sub3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - spreadsheet_extras.ts — 수식 입력줄 앞 데이터 단추(↑ ↓ 정렬… 필터) · attachFilter 부품 붙임 · Alt+↓ 머리 칸 값 목록 - spreadsheet_keys.ts — Ctrl+Shift+L 필터 · Alt+↓ 자리 - spreadsheet_selection.ts — 걸러진 행도 방향키 · Enter 가 건너뜀 - spreadsheet.ts — recalc 뒤 부품 refresh Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01L6KXAabDTenEU7hrDQmCKY --- A00_Common/spreadsheet/spreadsheet.ts | 1 + A00_Common/spreadsheet/spreadsheet_extras.ts | 49 ++++++++++++++++++- A00_Common/spreadsheet/spreadsheet_keys.ts | 9 ++++ .../spreadsheet/spreadsheet_selection.ts | 6 ++- 4 files changed, 62 insertions(+), 3 deletions(-) diff --git a/A00_Common/spreadsheet/spreadsheet.ts b/A00_Common/spreadsheet/spreadsheet.ts index 1597924a..f53be2df 100644 --- a/A00_Common/spreadsheet/spreadsheet.ts +++ b/A00_Common/spreadsheet/spreadsheet.ts @@ -244,6 +244,7 @@ export function createSpreadsheet( recalc() { ctx.engine.rebuild(ctx.book); grid.render(); + for (const p of others) p.refresh(); return ctx.engine.snapshot(); }, destroy() { diff --git a/A00_Common/spreadsheet/spreadsheet_extras.ts b/A00_Common/spreadsheet/spreadsheet_extras.ts index bcb24ea3..47bc79f1 100644 --- a/A00_Common/spreadsheet/spreadsheet_extras.ts +++ b/A00_Common/spreadsheet/spreadsheet_extras.ts @@ -2,19 +2,22 @@ * spreadsheet_extras.ts (주인 D) * 2단계 부품 잇기(sub1 파일들의 「잇는 법」) — 찾기 패널 · 메모 풍선(마우스 올림) · 메모 편집(Shift+F2) · * 서식 붓(수식 입력줄 앞 단추 · 두 번 누르면 계속 · Esc 끔) · 골라 붙여넣기(Ctrl+Shift+V → 값만 · 서식만 · 수식만) · - * 도구 모음을 누른 뒤 초점을 격자로 되돌림. + * 도구 모음을 누른 뒤 초점을 격자로 되돌림 · 정렬(↑ ↓ 정렬…) · 자동 필터(단추 · Ctrl+Shift+L · Alt+↓). * 빨간 세모는 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 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 = { @@ -22,6 +25,10 @@ const TEXT = { pasteTitle: "골라 붙여넣기", modes: { 값: "값만", 서식: "서식만", 수식: "수식만" } as Record, memo: "메모 — Ctrl+Enter 또는 밖을 누르면 적용 · Esc 취소", + sortAsc: "오름차순 정렬(활성 칸 열 기준)", + sortDesc: "내림차순 정렬(활성 칸 열 기준)", + sortDialog: "정렬 — 기준 여럿 · 머리 행", + filter: "자동 필터 켜기 · 끄기(Ctrl+Shift+L · 머리 칸에서 Alt+↓ 값 목록)", }; export interface Extras { @@ -108,6 +115,33 @@ 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); + // ── 골라 붙여넣기 ───────────────────────────────────────────────────────── let armed = false; const picker = el("div", { className: "ss-pick", attrs: { hidden: "", title: TEXT.pasteTitle } }); @@ -167,6 +201,8 @@ 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); @@ -181,6 +217,15 @@ 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; + }, escape() { painter.clear(); sticky = false; @@ -203,6 +248,6 @@ export function attachExtras( put(comments.root!, hit.r, hit.c); // showBalloon 의 cellBox 자리 → 화면 자리로 고침 }, }, - parts: [find, comments, self], + parts: [find, comments, filter, self], }; } diff --git a/A00_Common/spreadsheet/spreadsheet_keys.ts b/A00_Common/spreadsheet/spreadsheet_keys.ts index 22315c3a..a23180e1 100644 --- a/A00_Common/spreadsheet/spreadsheet_keys.ts +++ b/A00_Common/spreadsheet/spreadsheet_keys.ts @@ -41,6 +41,10 @@ export interface KeyHooks { comment?(): void; /** 고름 중 Esc(서식 붓 끄기 등) */ escape?(): void; + /** Ctrl+Shift+L 필터 켜기 · 끄기 */ + filter?(): void; + /** Alt+↓ — 필터 머리 칸이면 값 목록을 열고 true */ + dropdown?(): boolean; } export function attachKeys(ctx: SpreadsheetContext, ed: Editor, hooks: KeyHooks = {}): () => void { @@ -97,6 +101,7 @@ export function attachKeys(ctx: SpreadsheetContext, ed: Editor, hooks: KeyHooks const sheet = ctx.sheet(); const sel = ctx.selection; const arrow = ARROWS[e.key]; + if (e.altKey && e.key === "ArrowDown" && hooks.dropdown?.()) return true; if (arrow && !e.altKey) { go(move(sheet, sel, arrow[0], arrow[1], { extend: e.shiftKey, jump: ctrl })); return true; @@ -135,6 +140,10 @@ export function attachKeys(ctx: SpreadsheetContext, ed: Editor, hooks: KeyHooks if (!hooks.find) return false; hooks.find(k === "h"); return true; + case "l": + if (!e.shiftKey || !hooks.filter) return false; + hooks.filter(); + return true; case "v": if (e.shiftKey) hooks.pasteSpecial?.(); return false; // paste 사건은 그대로 — E 클립보드 · 골라 붙여넣기가 받음 diff --git a/A00_Common/spreadsheet/spreadsheet_selection.ts b/A00_Common/spreadsheet/spreadsheet_selection.ts index 64449dcf..779324dc 100644 --- a/A00_Common/spreadsheet/spreadsheet_selection.ts +++ b/A00_Common/spreadsheet/spreadsheet_selection.ts @@ -51,7 +51,11 @@ export function mergeAt(sheet: Sheet, r: number, c: number): CellRange { return merges(sheet).find((m) => inRange(m, r, c)) ?? { r0: r, c0: c, r1: r, c1: c }; } -export const rowHidden = (sheet: Sheet, r: number): boolean => !!sheet.행?.[String(r + 1)]?.숨김; +/** 숨긴 행 · 필터로 걸러진 행(방향키 · Enter 가 건너뜀 — 엑셀) */ +export function rowHidden(sheet: Sheet, r: number): boolean { + const row = sheet.행?.[String(r + 1)]; + return !!(row?.숨김 || row?.걸러짐); +} export function colHidden(sheet: Sheet, c: number): boolean { const cols = sheet.열;