Merge remote-tracking branch 'origin/dev' into sub_laptop_8
This commit is contained in:
@@ -109,3 +109,151 @@
|
||||
background: #24292f;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 2단계 잇기 — 서식 붓 · 찾기 패널 · 메모 · 골라 붙여넣기 */
|
||||
.ss-brush {
|
||||
flex: none;
|
||||
padding: 0 8px;
|
||||
border: none;
|
||||
border-right: 1px solid #d0d7de;
|
||||
background: none;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ss-brush.is-on {
|
||||
background: #dafbe1;
|
||||
box-shadow: inset 0 0 0 2px #217346;
|
||||
}
|
||||
|
||||
.ss--painting .ss-grid-host {
|
||||
cursor: copy;
|
||||
}
|
||||
|
||||
.ss-find {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 16px;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 8px;
|
||||
border: 1px solid #d0d7de;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
|
||||
}
|
||||
|
||||
.ss-find[hidden],
|
||||
.ss-find__row[hidden],
|
||||
.ss-memo[hidden],
|
||||
.ss-pick[hidden],
|
||||
.spreadsheet-comment-balloon[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ss-find__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.ss-find__input {
|
||||
width: 180px;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid #d0d7de;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.ss-find__btn {
|
||||
padding: 2px 8px;
|
||||
border: 1px solid #d0d7de;
|
||||
border-radius: 4px;
|
||||
background: #f6f8fa;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ss-find__opt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ss-find__status {
|
||||
font-size: 12px;
|
||||
color: #57606a;
|
||||
}
|
||||
|
||||
.ss-memo,
|
||||
.spreadsheet-comment-balloon {
|
||||
position: absolute;
|
||||
z-index: 15;
|
||||
min-width: 140px;
|
||||
max-width: 260px;
|
||||
padding: 4px 6px;
|
||||
border: 1px solid #bf8700;
|
||||
background: #fff8c5;
|
||||
font: inherit;
|
||||
white-space: pre-wrap;
|
||||
box-shadow: 0 2px 6px rgb(0 0 0 / 15%);
|
||||
}
|
||||
|
||||
.ss-memo {
|
||||
min-height: 60px;
|
||||
resize: both;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ss-pick {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 4px;
|
||||
border: 1px solid #d0d7de;
|
||||
background: #fff;
|
||||
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
|
||||
}
|
||||
|
||||
.ss-pick > button {
|
||||
padding: 2px 8px;
|
||||
border: 1px solid #d0d7de;
|
||||
background: #f6f8fa;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 함수 자동 완성 · 인자 도움말 */
|
||||
.ss-hint {
|
||||
position: absolute;
|
||||
z-index: 6;
|
||||
min-width: 200px;
|
||||
max-width: 360px;
|
||||
border: 1px solid #d0d7de;
|
||||
background: #fff;
|
||||
font-size: 12px;
|
||||
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
|
||||
}
|
||||
|
||||
.ss-hint[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ss-hint__item,
|
||||
.ss-hint__help {
|
||||
padding: 2px 6px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.ss-hint__item {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ss-hint__item.is-on {
|
||||
background: #ddf4ff;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* ⚠ M02 만 `import()` 로 불러옴 — 정적 import 금지(다른 페이지 번들에 안 섞이게).
|
||||
* 저장은 부른 쪽 몫(`onChange` 로 문서 · 자동저장 없음 · [저장] 때 서버가 Node 로 다시 풂).
|
||||
* 화면 차례: 도구 모음(E) · 주소 상자 + 수식 입력줄(D) · 격자(C) · 시트 탭(E).
|
||||
* 2단계 부품(찾기 · 메모 · 서식 붓 · 골라 붙여넣기)은 `spreadsheet_extras.ts` 로 이음.
|
||||
* ========================================================================== */
|
||||
|
||||
import "./spreadsheet.css";
|
||||
@@ -15,6 +16,7 @@ import { parseA1, rangeToA1, toA1 } from "./spreadsheet_address";
|
||||
import { attachClipboard } from "./spreadsheet_clipboard";
|
||||
import { applyCommand, CommandError } from "./spreadsheet_commands";
|
||||
import { createEditor } from "./spreadsheet_editor";
|
||||
import { attachExtras } from "./spreadsheet_extras";
|
||||
import { createCalcEngine } from "./spreadsheet_graph";
|
||||
import { createGrid } from "./spreadsheet_grid";
|
||||
import { createHistory } from "./spreadsheet_history";
|
||||
@@ -70,18 +72,6 @@ export interface SpreadsheetHandle {
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
/** 이어 붙이는 남의 부품 — 시험 틀이 빈 몸 부품을 가짜로 바꿔 끼우는 자리(화면 코드는 그대로 씀) */
|
||||
export const parts = {
|
||||
createCalcEngine,
|
||||
createHistory,
|
||||
applyCommand,
|
||||
createGrid,
|
||||
mountToolbar,
|
||||
mountTabs,
|
||||
attachMenu,
|
||||
attachClipboard,
|
||||
};
|
||||
|
||||
/** 움직이는 끝(닻에서 먼 쪽) — 행열 전체면 활성 칸 쪽 */
|
||||
function farCorner(sel: Selection): { r: number; c: number } {
|
||||
const g = sel.범위[0];
|
||||
@@ -117,8 +107,8 @@ export function createSpreadsheet(
|
||||
root,
|
||||
book,
|
||||
readOnly,
|
||||
engine: parts.createCalcEngine(book),
|
||||
history: parts.createHistory(),
|
||||
engine: createCalcEngine(book),
|
||||
history: createHistory(),
|
||||
selection: selectCell(firstSheet(book), 0, 0),
|
||||
sheet: () => ctx.book.시트.find((s) => s.id === ctx.selection.시트) ?? ctx.book.시트[0],
|
||||
dispatch,
|
||||
@@ -130,20 +120,22 @@ export function createSpreadsheet(
|
||||
editor: null as unknown as EditorHandle,
|
||||
};
|
||||
|
||||
const grid = parts.createGrid(ctx);
|
||||
const grid = createGrid(ctx);
|
||||
ctx.grid = grid;
|
||||
const ed = createEditor(ctx);
|
||||
ctx.editor = ed;
|
||||
const toolbar: PartHandle | null = readOnly ? null : parts.mountToolbar(ctx);
|
||||
const tabs = parts.mountTabs(ctx);
|
||||
const menu: PartHandle | null = readOnly ? null : parts.attachMenu(ctx);
|
||||
const clip = parts.attachClipboard(ctx);
|
||||
const toolbar: PartHandle | null = readOnly ? null : mountToolbar(ctx);
|
||||
const tabs = mountTabs(ctx);
|
||||
const menu: PartHandle | null = readOnly ? null : attachMenu(ctx);
|
||||
const clip = attachClipboard(ctx);
|
||||
const others = [toolbar, tabs, menu, clip].filter((p): p is PartHandle => !!p);
|
||||
|
||||
grid.root.classList.add("ss-grid-host");
|
||||
for (const node of [toolbar?.root, ed.bar, grid.root, tabs.root]) if (node) root.append(node);
|
||||
const detachKeys = attachKeys(ctx, ed);
|
||||
const detachMouse = attachMouse(ctx, ed);
|
||||
const extras = attachExtras(ctx, ed, toolbar?.root ?? null);
|
||||
others.push(...extras.parts);
|
||||
const detachKeys = attachKeys(ctx, ed, extras.keys);
|
||||
const detachMouse = attachMouse(ctx, ed, extras.mouse);
|
||||
const toast = el("div", { className: "ss-toast" });
|
||||
let toastTimer = 0;
|
||||
|
||||
@@ -160,7 +152,7 @@ export function createSpreadsheet(
|
||||
|
||||
function apply(command: Command): CommandEffect | null {
|
||||
try {
|
||||
return parts.applyCommand(ctx.book, command);
|
||||
return applyCommand(ctx.book, command);
|
||||
} catch (e) {
|
||||
if (e instanceof CommandError) return (notify(e.message), null);
|
||||
throw e;
|
||||
|
||||
@@ -18,7 +18,15 @@ import {
|
||||
rangeToA1,
|
||||
toA1,
|
||||
} from "./spreadsheet_address";
|
||||
import {
|
||||
addSheet,
|
||||
moveSheet,
|
||||
removeSheet,
|
||||
renameSheet,
|
||||
replaceSheet,
|
||||
} from "./spreadsheet_commands_sheet";
|
||||
import { shiftForCommand, shiftSpan } from "./spreadsheet_refshift";
|
||||
import { applyFilter, applySort, shiftFilter } from "./spreadsheet_sortfilter";
|
||||
import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types";
|
||||
import type {
|
||||
Cell,
|
||||
@@ -34,11 +42,11 @@ import type {
|
||||
/** 막힌 명령(병합 가름 · 마지막 시트 지우기 · 같은 시트 이름 …) — 화면이 `message` 를 알림 */
|
||||
export class CommandError extends Error {}
|
||||
|
||||
type Of<K extends Command["종류"]> = Extract<Command, { 종류: K }>;
|
||||
export type Of<K extends Command["종류"]> = Extract<Command, { 종류: K }>;
|
||||
|
||||
const clone = <T>(x: T): T => structuredClone(x);
|
||||
export const clone = <T>(x: T): T => structuredClone(x);
|
||||
|
||||
function sheetOf(book: Workbook, id: string): Sheet {
|
||||
export function sheetOf(book: Workbook, id: string): Sheet {
|
||||
const s = book.시트.find((x) => x.id === id);
|
||||
if (!s) throw new CommandError(`없는 시트: ${id}`);
|
||||
return s;
|
||||
@@ -107,7 +115,7 @@ function setSheetDefault(s: Sheet, key: "서식" | "열폭" | "행높이", value
|
||||
const addr = (시트: string, r: number, c: number): SheetCellAddress => ({ 시트, r, c });
|
||||
|
||||
/** 바뀐 시트만 통째 되돌림 짝 */
|
||||
function restoreChanged(book: Workbook, old: Sheet[]): Command[] {
|
||||
export function restoreChanged(book: Workbook, old: Sheet[]): Command[] {
|
||||
const out: Command[] = [];
|
||||
for (const o of old) {
|
||||
const now = book.시트.find((x) => x.id === o.id);
|
||||
@@ -117,20 +125,12 @@ function restoreChanged(book: Workbook, old: Sheet[]): Command[] {
|
||||
}
|
||||
|
||||
/** 모든 식 글을 명령에 맞춰 고침 — 시트 이름을 바꾸기 전에 부름 */
|
||||
function rewriteAll(book: Workbook, command: Command) {
|
||||
export function rewriteAll(book: Workbook, command: Command) {
|
||||
for (const s of book.시트)
|
||||
for (const cell of Object.values(s.칸))
|
||||
if (cell.식 !== undefined) cell.식 = shiftForCommand(cell.식, s.id, command, book);
|
||||
}
|
||||
|
||||
function checkSheetName(book: Workbook, name: string, self?: string) {
|
||||
if (!name.trim() || name.length > 31) throw new CommandError("시트 이름은 1~31 글자");
|
||||
if (/[[\]:*?/\\]/.test(name) || name.startsWith("'") || name.endsWith("'"))
|
||||
throw new CommandError("시트 이름에 못 쓰는 글자: [ ] : * ? / \\ 와 앞뒤 '");
|
||||
if (book.시트.some((s) => s.id !== self && s.이름.toLowerCase() === name.toLowerCase()))
|
||||
throw new CommandError(`같은 이름 시트가 있음: ${name}`);
|
||||
}
|
||||
|
||||
// ── 칸 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function setCells(book: Workbook, cmd: Of<"칸">): CommandEffect {
|
||||
@@ -332,6 +332,7 @@ function insertDelete(
|
||||
}
|
||||
}
|
||||
|
||||
shiftFilter(s, rows, insert, at, n);
|
||||
const key = rows ? "행" : "열";
|
||||
const frozen = s.틀고정?.[key];
|
||||
if (frozen && at < frozen)
|
||||
@@ -484,61 +485,6 @@ function sheetSize(s: Sheet, axis: "행" | "열", value: number | null): Command
|
||||
return { undo: { 종류: "시트통째", 시트: before }, cells: [], rebuild: false };
|
||||
}
|
||||
|
||||
// ── 시트 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function addSheet(book: Workbook, cmd: Of<"시트더하기">): CommandEffect {
|
||||
if (book.시트.some((s) => s.id === cmd.시트.id))
|
||||
throw new CommandError(`같은 id 시트가 있음: ${cmd.시트.id}`);
|
||||
checkSheetName(book, cmd.시트.이름);
|
||||
book.시트.splice(Math.max(0, Math.min(cmd.자리, book.시트.length)), 0, clone(cmd.시트));
|
||||
return { undo: { 종류: "시트지우기", 시트: cmd.시트.id }, cells: [], rebuild: true };
|
||||
}
|
||||
|
||||
function removeSheet(book: Workbook, cmd: Of<"시트지우기">): CommandEffect {
|
||||
const s = sheetOf(book, cmd.시트);
|
||||
if (book.시트.length === 1) throw new CommandError("마지막 시트는 못 지움");
|
||||
const at = book.시트.indexOf(s);
|
||||
const old = book.시트.map(clone);
|
||||
rewriteAll(book, cmd);
|
||||
book.시트.splice(at, 1);
|
||||
if (book.활성 === s.id) book.활성 = book.시트[Math.min(at, book.시트.length - 1)].id;
|
||||
const undo: Command[] = [
|
||||
{ 종류: "시트더하기", 시트: old[at], 자리: at },
|
||||
...restoreChanged(book, old),
|
||||
];
|
||||
return { undo: { 종류: "묶음", 명령: undo }, cells: [], rebuild: true };
|
||||
}
|
||||
|
||||
function renameSheet(book: Workbook, cmd: Of<"시트이름">): CommandEffect {
|
||||
const s = sheetOf(book, cmd.시트);
|
||||
checkSheetName(book, cmd.이름, s.id);
|
||||
const prev = s.이름;
|
||||
rewriteAll(book, cmd);
|
||||
s.이름 = cmd.이름;
|
||||
return { undo: { 종류: "시트이름", 시트: s.id, 이름: prev }, cells: [], rebuild: true };
|
||||
}
|
||||
|
||||
function moveSheet(book: Workbook, cmd: Of<"시트옮기기">): CommandEffect {
|
||||
const s = sheetOf(book, cmd.시트);
|
||||
const from = book.시트.indexOf(s);
|
||||
book.시트.splice(from, 1);
|
||||
book.시트.splice(Math.max(0, Math.min(cmd.자리, book.시트.length)), 0, s);
|
||||
return { undo: { 종류: "시트옮기기", 시트: s.id, 자리: from }, cells: [], rebuild: false };
|
||||
}
|
||||
|
||||
function replaceSheet(book: Workbook, cmd: Of<"시트통째">): CommandEffect {
|
||||
const at = book.시트.findIndex((x) => x.id === cmd.시트.id);
|
||||
if (at < 0) throw new CommandError(`없는 시트: ${cmd.시트.id}`);
|
||||
const prev = book.시트[at];
|
||||
book.시트[at] = clone(cmd.시트);
|
||||
const undo: Of<"시트통째"> = { 종류: "시트통째", 시트: prev };
|
||||
if (cmd.서식) {
|
||||
undo.서식 = book.서식;
|
||||
book.서식 = clone(cmd.서식);
|
||||
}
|
||||
return { undo, cells: [], rebuild: true };
|
||||
}
|
||||
|
||||
function batch(book: Workbook, cmd: Of<"묶음">): CommandEffect {
|
||||
const done: CommandEffect[] = [];
|
||||
try {
|
||||
@@ -644,6 +590,10 @@ export function applyCommand(book: Workbook, command: Command): CommandEffect {
|
||||
return moveSheet(book, command);
|
||||
case "시트통째":
|
||||
return replaceSheet(book, command);
|
||||
case "정렬":
|
||||
return applySort(book, command);
|
||||
case "필터":
|
||||
return applyFilter(book, command);
|
||||
case "묶음":
|
||||
return batch(book, command);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_commands_sheet.ts (주인 A)
|
||||
* 시트 명령 — 더하기 · 지우기 · 이름 · 옮기기 · 통째 바꿔 넣기. 들머리는 `applyCommand`(commands).
|
||||
* ========================================================================== */
|
||||
|
||||
import { clone, CommandError, restoreChanged, rewriteAll, sheetOf } from "./spreadsheet_commands";
|
||||
import type { Of } from "./spreadsheet_commands";
|
||||
import type { Command, CommandEffect, Workbook } from "./spreadsheet_types";
|
||||
|
||||
function checkSheetName(book: Workbook, name: string, self?: string) {
|
||||
if (!name.trim() || name.length > 31) throw new CommandError("시트 이름은 1~31 글자");
|
||||
if (/[[\]:*?/\\]/.test(name) || name.startsWith("'") || name.endsWith("'"))
|
||||
throw new CommandError("시트 이름에 못 쓰는 글자: [ ] : * ? / \\ 와 앞뒤 '");
|
||||
if (book.시트.some((s) => s.id !== self && s.이름.toLowerCase() === name.toLowerCase()))
|
||||
throw new CommandError(`같은 이름 시트가 있음: ${name}`);
|
||||
}
|
||||
|
||||
export function addSheet(book: Workbook, cmd: Of<"시트더하기">): CommandEffect {
|
||||
if (book.시트.some((s) => s.id === cmd.시트.id))
|
||||
throw new CommandError(`같은 id 시트가 있음: ${cmd.시트.id}`);
|
||||
checkSheetName(book, cmd.시트.이름);
|
||||
book.시트.splice(Math.max(0, Math.min(cmd.자리, book.시트.length)), 0, clone(cmd.시트));
|
||||
return { undo: { 종류: "시트지우기", 시트: cmd.시트.id }, cells: [], rebuild: true };
|
||||
}
|
||||
|
||||
export function removeSheet(book: Workbook, cmd: Of<"시트지우기">): CommandEffect {
|
||||
const s = sheetOf(book, cmd.시트);
|
||||
if (book.시트.length === 1) throw new CommandError("마지막 시트는 못 지움");
|
||||
const at = book.시트.indexOf(s);
|
||||
const old = book.시트.map(clone);
|
||||
rewriteAll(book, cmd);
|
||||
book.시트.splice(at, 1);
|
||||
if (book.활성 === s.id) book.활성 = book.시트[Math.min(at, book.시트.length - 1)].id;
|
||||
const undo: Command[] = [
|
||||
{ 종류: "시트더하기", 시트: old[at], 자리: at },
|
||||
...restoreChanged(book, old),
|
||||
];
|
||||
return { undo: { 종류: "묶음", 명령: undo }, cells: [], rebuild: true };
|
||||
}
|
||||
|
||||
export function renameSheet(book: Workbook, cmd: Of<"시트이름">): CommandEffect {
|
||||
const s = sheetOf(book, cmd.시트);
|
||||
checkSheetName(book, cmd.이름, s.id);
|
||||
const prev = s.이름;
|
||||
rewriteAll(book, cmd);
|
||||
s.이름 = cmd.이름;
|
||||
return { undo: { 종류: "시트이름", 시트: s.id, 이름: prev }, cells: [], rebuild: true };
|
||||
}
|
||||
|
||||
export function moveSheet(book: Workbook, cmd: Of<"시트옮기기">): CommandEffect {
|
||||
const s = sheetOf(book, cmd.시트);
|
||||
const from = book.시트.indexOf(s);
|
||||
book.시트.splice(from, 1);
|
||||
book.시트.splice(Math.max(0, Math.min(cmd.자리, book.시트.length)), 0, s);
|
||||
return { undo: { 종류: "시트옮기기", 시트: s.id, 자리: from }, cells: [], rebuild: false };
|
||||
}
|
||||
|
||||
export function replaceSheet(book: Workbook, cmd: Of<"시트통째">): CommandEffect {
|
||||
const at = book.시트.findIndex((x) => x.id === cmd.시트.id);
|
||||
if (at < 0) throw new CommandError(`없는 시트: ${cmd.시트.id}`);
|
||||
const prev = book.시트[at];
|
||||
book.시트[at] = clone(cmd.시트);
|
||||
const undo: Of<"시트통째"> = { 종류: "시트통째", 시트: prev };
|
||||
if (cmd.서식) {
|
||||
undo.서식 = book.서식;
|
||||
book.서식 = clone(cmd.서식);
|
||||
}
|
||||
return { undo, cells: [], rebuild: true };
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { moveFormula } from "./spreadsheet_refshift";
|
||||
import { colName, parseA1, parseRange, rangeToA1, toA1 } from "./spreadsheet_address";
|
||||
import { funcHelp, suggestFunctions, type FuncHelp } from "./spreadsheet_func_help";
|
||||
import { tokenize } from "./spreadsheet_parser";
|
||||
import { cellsOf, move, selectCell } from "./spreadsheet_selection";
|
||||
import type { Cell, CellAddress, CellStyle, Sheet } from "./spreadsheet_types";
|
||||
@@ -36,6 +37,10 @@ export interface Editor extends EditorHandle {
|
||||
point(sel: Selection): void;
|
||||
/** 방향키 가리키기 — 지금 가리킨 곳(없으면 편집 칸)에서 */
|
||||
pointMove(dr: number, dc: number, extend: boolean, jump: boolean): void;
|
||||
/** 함수 자동 완성 목록이 떠 있으면 고른 이름을 넣음(Tab) */
|
||||
accept(): boolean;
|
||||
/** 자동 완성 목록 안 위아래(목록이 없으면 false) */
|
||||
suggestMove(d: number): boolean;
|
||||
/** 고름 칸 전부에 같은 입력(Ctrl+Enter) — 식은 활성 칸 기준 상대 이동 */
|
||||
commitAll(): void;
|
||||
/** 초점 되찾기(스크롤 없이) */
|
||||
@@ -140,7 +145,45 @@ function styleAt(ctx: SpreadsheetContext, sheet: Sheet, r: number, c: number): C
|
||||
return ctx.book.서식[idx] ?? {};
|
||||
}
|
||||
|
||||
/** editorSlot 칸 자리 → 층 안 자리 */
|
||||
/** 식 글 caret 앞 → 함수 이름 치는 중(prefix) · 함수 인자 안(func · 몇 번째 인자 0 부터) · 아님 null */
|
||||
export function formulaHint(
|
||||
pre: string,
|
||||
): { prefix: string } | { func: string; arg: number } | null {
|
||||
if (pre[0] !== "=") return null;
|
||||
const m = /(?:^=|[(,+\-*/^&<>:;= ])([A-Za-z][A-Za-z0-9.]*)$/.exec(pre);
|
||||
if (m && !/^\$?[A-Za-z]{1,3}\$?\d+$/.test(m[1])) return { prefix: m[1] };
|
||||
let depth = 0;
|
||||
let arg = 0;
|
||||
let inStr = false;
|
||||
for (let i = pre.length - 1; i > 0; i--) {
|
||||
const ch = pre[i];
|
||||
if (ch === '"') inStr = !inStr;
|
||||
if (inStr) continue;
|
||||
if (ch === ")") depth++;
|
||||
else if (ch === "," && depth === 0) arg++;
|
||||
else if (ch === "(") {
|
||||
if (depth-- > 0) continue;
|
||||
const n = /([A-Za-z][A-Za-z0-9.]*)$/.exec(pre.slice(0, i));
|
||||
return n ? { func: n[1].toUpperCase(), arg } : null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 편집기 · 미리보기 자리 — C `editorSlot` 의 box 는 격자 뿌리 기준이라 층(layer)이 뿌리 안에서
|
||||
* 비켜 놓인 만큼 빼서 층 안 좌표로 맞춤. */
|
||||
export function slotOf(
|
||||
ctx: SpreadsheetContext,
|
||||
r: number,
|
||||
c: number,
|
||||
): { layer: HTMLElement; box: CellBox } {
|
||||
const { layer, box } = ctx.grid.editorSlot(r, c);
|
||||
const a = layer.getBoundingClientRect();
|
||||
const g = ctx.grid.root.getBoundingClientRect();
|
||||
return { layer, box: { ...box, x: box.x - (a.left - g.left), y: box.y - (a.top - g.top) } };
|
||||
}
|
||||
|
||||
/** 칸 자리 → 층 안 자리 */
|
||||
export function boxStyle(node: HTMLElement, box: CellBox): void {
|
||||
node.style.left = `${box.x}px`;
|
||||
node.style.top = `${box.y}px`;
|
||||
@@ -178,7 +221,7 @@ export function createEditor(ctx: SpreadsheetContext): Editor {
|
||||
|
||||
function place(): void {
|
||||
const at = st?.at ?? ctx.selection.활성;
|
||||
const { layer, box } = ctx.grid.editorSlot(at.r, at.c);
|
||||
const { layer, box } = slotOf(ctx, at.r, at.c);
|
||||
if (ta.parentElement !== layer) {
|
||||
const had = document.activeElement === ta;
|
||||
layer.append(ta);
|
||||
@@ -206,6 +249,56 @@ export function createEditor(ctx: SpreadsheetContext): Editor {
|
||||
function highlight(): void {
|
||||
const t = st ? text() : "";
|
||||
ctx.grid.setRefHighlights(st ? refHighlights(t, st.sheet, sheetId) : []);
|
||||
showHint();
|
||||
}
|
||||
|
||||
// ── 함수 자동 완성 · 인자 도움말 (spreadsheet_func_help · sub1) ────────────────
|
||||
const hint = el("div", { className: "ss-hint", attrs: { hidden: "" } });
|
||||
let sugg: FuncHelp[] = [];
|
||||
let pick = 0;
|
||||
let typed = "";
|
||||
|
||||
function showHint(): void {
|
||||
const node = src();
|
||||
const h = st ? formulaHint(node.value.slice(0, node.selectionEnd)) : null;
|
||||
sugg = h && "prefix" in h ? suggestFunctions(h.prefix).slice(0, 8) : [];
|
||||
typed = h && "prefix" in h ? h.prefix : "";
|
||||
const help = h && "func" in h ? funcHelp(h.func) : null;
|
||||
if (!sugg.length && !help) return void hint.setAttribute("hidden", "");
|
||||
pick = Math.min(pick, Math.max(0, sugg.length - 1));
|
||||
hint.replaceChildren(
|
||||
...(sugg.length
|
||||
? sugg.map((f, i) => {
|
||||
const row = el("div", {
|
||||
className: i === pick ? "ss-hint__item is-on" : "ss-hint__item",
|
||||
children: [el("b", { text: f.이름 }), ` ${f.뜻}`],
|
||||
});
|
||||
row.addEventListener("mousedown", (e) => {
|
||||
e.preventDefault();
|
||||
pick = i;
|
||||
accept();
|
||||
});
|
||||
return row;
|
||||
})
|
||||
: [el("div", { className: "ss-hint__help", text: `${help!.꼴} — ${help!.뜻}` })]),
|
||||
);
|
||||
if (hint.parentElement !== ta.parentElement) ta.parentElement?.append(hint);
|
||||
hint.style.left = ta.style.left;
|
||||
hint.style.top = `${parseFloat(ta.style.top || "0") + ta.offsetHeight}px`;
|
||||
hint.removeAttribute("hidden");
|
||||
}
|
||||
|
||||
function accept(): boolean {
|
||||
if (!st || !sugg.length) return false;
|
||||
const node = src();
|
||||
const pos = node.selectionEnd;
|
||||
const name = `${sugg[pick].이름}(`;
|
||||
node.setRangeText(name, pos - typed.length, pos, "end");
|
||||
(node === ta ? formula : ta).value = node.value;
|
||||
pick = 0;
|
||||
place();
|
||||
highlight();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 편집 시작 — `value` 없으면 textarea 에 이미 든 글(조합 · 바로 친 글자) 그대로 */
|
||||
@@ -231,6 +324,7 @@ export function createEditor(ctx: SpreadsheetContext): Editor {
|
||||
ta.removeAttribute("style");
|
||||
ctx.root.classList.remove("ss--editing");
|
||||
ctx.grid.setRefHighlights([]);
|
||||
hint.setAttribute("hidden", "");
|
||||
sync();
|
||||
focus();
|
||||
}
|
||||
@@ -389,11 +483,19 @@ export function createEditor(ctx: SpreadsheetContext): Editor {
|
||||
pointable,
|
||||
point,
|
||||
pointMove,
|
||||
accept,
|
||||
suggestMove(d: number) {
|
||||
if (!st || !sugg.length) return false;
|
||||
pick = (pick + d + sugg.length) % sugg.length;
|
||||
showHint();
|
||||
return true;
|
||||
},
|
||||
focus,
|
||||
place() {
|
||||
if (st) place();
|
||||
},
|
||||
destroy() {
|
||||
hint.remove();
|
||||
ta.remove();
|
||||
bar.remove();
|
||||
},
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_extras.ts (주인 D)
|
||||
* 2단계 부품 잇기(sub1 파일들의 「잇는 법」) — 찾기 패널 · 메모 풍선(마우스 올림) · 메모 편집(Shift+F2) ·
|
||||
* 서식 붓(수식 입력줄 앞 단추 · 두 번 누르면 계속 · Esc 끔) · 골라 붙여넣기(Ctrl+Shift+V → 값만 · 서식만 · 수식만) ·
|
||||
* 도구 모음을 누른 뒤 초점을 격자로 되돌림.
|
||||
* 빨간 세모는 C 격자 · 우클릭 [메모 …] 는 E 메뉴 몫(브레인 배정).
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { parseClipboard } from "./spreadsheet_clipboard";
|
||||
import { attachComments, getComment, setComment } from "./spreadsheet_comments";
|
||||
import type { Editor } from "./spreadsheet_editor";
|
||||
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 type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
|
||||
const TEXT = {
|
||||
painter: "서식 붓 — 한 번 누르면 한 번 · 두 번 누르면 Esc 까지 계속",
|
||||
pasteTitle: "골라 붙여넣기",
|
||||
modes: { 값: "값만", 서식: "서식만", 수식: "수식만" } as Record<PasteSpecialMode, string>,
|
||||
memo: "메모 — Ctrl+Enter 또는 밖을 누르면 적용 · Esc 취소",
|
||||
};
|
||||
|
||||
export interface Extras {
|
||||
keys: KeyHooks;
|
||||
mouse: MouseHooks;
|
||||
parts: PartHandle[];
|
||||
}
|
||||
|
||||
export function attachExtras(
|
||||
ctx: SpreadsheetContext,
|
||||
ed: Editor,
|
||||
toolbarRoot: HTMLElement | null,
|
||||
): Extras {
|
||||
const back = (): void => ed.focus();
|
||||
/** 칸 오른쪽 자리(부품 뿌리 기준 px) — C editorSlot box 는 격자 뿌리 기준 */
|
||||
const beside = (r: number, c: number): { x: number; y: number } => {
|
||||
const { box } = ctx.grid.editorSlot(r, c);
|
||||
const g = ctx.grid.root.getBoundingClientRect();
|
||||
const o = ctx.root.getBoundingClientRect();
|
||||
return { x: box.x + box.w + g.left - o.left + 4, y: box.y + g.top - o.top };
|
||||
};
|
||||
const put = (node: HTMLElement, r: number, c: number): void => {
|
||||
const p = beside(r, c);
|
||||
node.style.left = `${p.x}px`;
|
||||
node.style.top = `${p.y}px`;
|
||||
};
|
||||
|
||||
// ── 찾기 ───────────────────────────────────────────────────────────────
|
||||
const find = mountFindPanel(ctx, back);
|
||||
|
||||
// ── 메모 — 풍선 · 편집 ─────────────────────────────────────────────────
|
||||
const comments = attachComments(ctx);
|
||||
let hovered = "";
|
||||
const memo = el("textarea", { className: "ss-memo", attrs: { hidden: "", title: TEXT.memo } });
|
||||
ctx.root.append(memo);
|
||||
let memoAt: { r: number; c: number } | null = null;
|
||||
const closeMemo = (save: boolean): void => {
|
||||
if (!memoAt) return;
|
||||
const at = memoAt;
|
||||
memoAt = null;
|
||||
memo.setAttribute("hidden", "");
|
||||
if (save && memo.value !== (getComment(ctx.sheet(), at.r, at.c) ?? ""))
|
||||
setComment(ctx, at.r, at.c, memo.value.trim());
|
||||
back();
|
||||
};
|
||||
memo.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" || (e.key === "Enter" && e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closeMemo(e.key === "Enter");
|
||||
}
|
||||
});
|
||||
memo.addEventListener("blur", () => closeMemo(true));
|
||||
|
||||
function editMemo(): void {
|
||||
if (ctx.readOnly) return;
|
||||
const { r, c } = ctx.selection.활성;
|
||||
memoAt = { r, c };
|
||||
comments.hideBalloon();
|
||||
memo.value = getComment(ctx.sheet(), r, c) ?? "";
|
||||
memo.removeAttribute("hidden");
|
||||
put(memo, r, c);
|
||||
memo.focus();
|
||||
}
|
||||
|
||||
// ── 서식 붓 ─────────────────────────────────────────────────────────────
|
||||
const painter = createFormatPainter(ctx);
|
||||
let sticky = false;
|
||||
const brush = el("button", {
|
||||
className: "ss-brush",
|
||||
text: "붓",
|
||||
attrs: { type: "button", title: TEXT.painter },
|
||||
});
|
||||
const showBrush = (): void => {
|
||||
brush.classList.toggle("is-on", painter.active());
|
||||
ctx.root.classList.toggle("ss--painting", painter.active());
|
||||
};
|
||||
brush.addEventListener("click", (e) => {
|
||||
if (e.detail === 2) sticky = true;
|
||||
else if (painter.active()) (painter.clear(), (sticky = false));
|
||||
else (painter.pick(), (sticky = false));
|
||||
showBrush();
|
||||
back();
|
||||
});
|
||||
if (!ctx.readOnly) ed.bar.prepend(brush);
|
||||
|
||||
// ── 골라 붙여넣기 ─────────────────────────────────────────────────────────
|
||||
let armed = false;
|
||||
const picker = el("div", { className: "ss-pick", attrs: { hidden: "", title: TEXT.pasteTitle } });
|
||||
ctx.root.append(picker);
|
||||
const onPaste = (e: ClipboardEvent): void => {
|
||||
if (!armed || ed.editing()) return;
|
||||
armed = false;
|
||||
e.preventDefault();
|
||||
e.stopPropagation(); // E 클립보드의 보통 붙여넣기를 막음
|
||||
const at = { ...ctx.selection.활성 };
|
||||
const data = e.clipboardData;
|
||||
const block = parseClipboard(
|
||||
data?.getData("text/html") ?? "",
|
||||
data?.getData("text/plain") ?? "",
|
||||
at,
|
||||
);
|
||||
if (!block) return;
|
||||
// ponytail: 우리 복사면 원본을 같은 시트로 봄(ClipBlock 에 시트가 없음) — 값만 때 식 칸 계산값용
|
||||
const source =
|
||||
block.출처 === "aislo" && block.원점
|
||||
? { 시트: ctx.selection.시트, ...block.원점 }
|
||||
: undefined;
|
||||
picker.replaceChildren(
|
||||
...(Object.keys(TEXT.modes) as PasteSpecialMode[]).map((mode) => {
|
||||
const b = el("button", {
|
||||
text: TEXT.modes[mode],
|
||||
attrs: { type: "button", "data-mode": mode },
|
||||
});
|
||||
b.addEventListener("click", () => {
|
||||
picker.setAttribute("hidden", "");
|
||||
pasteSpecial(ctx, block, at, mode, source);
|
||||
back();
|
||||
});
|
||||
return b;
|
||||
}),
|
||||
);
|
||||
picker.removeAttribute("hidden");
|
||||
put(picker, at.r, at.c);
|
||||
(picker.firstElementChild as HTMLElement).focus();
|
||||
};
|
||||
picker.addEventListener("keydown", (e) => {
|
||||
if (e.key !== "Escape") return;
|
||||
e.preventDefault();
|
||||
picker.setAttribute("hidden", "");
|
||||
back();
|
||||
});
|
||||
ctx.root.addEventListener("paste", onPaste, true);
|
||||
|
||||
// ── 도구 모음 뒤 초점 ───────────────────────────────────────────────────
|
||||
const onToolbarClick = (e: Event): void => {
|
||||
if ((e.target as HTMLElement).closest("button")) back();
|
||||
};
|
||||
toolbarRoot?.addEventListener("click", onToolbarClick);
|
||||
toolbarRoot?.addEventListener("change", back);
|
||||
|
||||
const self: PartHandle = {
|
||||
root: null,
|
||||
refresh: () => showBrush(),
|
||||
destroy() {
|
||||
ctx.root.removeEventListener("paste", onPaste, true);
|
||||
toolbarRoot?.removeEventListener("click", onToolbarClick);
|
||||
toolbarRoot?.removeEventListener("change", back);
|
||||
memo.remove();
|
||||
picker.remove();
|
||||
brush.remove();
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
keys: {
|
||||
find: (replace) => find.open(replace),
|
||||
pasteSpecial: () => (armed = true),
|
||||
comment: editMemo,
|
||||
escape() {
|
||||
painter.clear();
|
||||
sticky = false;
|
||||
showBrush();
|
||||
},
|
||||
},
|
||||
mouse: {
|
||||
selected() {
|
||||
if (!painter.active()) return;
|
||||
painter.paint(ctx.selection.범위[0], sticky);
|
||||
showBrush();
|
||||
},
|
||||
hover(hit) {
|
||||
const key = hit?.kind === "cell" ? `${hit.r},${hit.c}` : "";
|
||||
if (key === hovered) return;
|
||||
hovered = key;
|
||||
if (!hit || !key || memoAt || !getComment(ctx.sheet(), hit.r, hit.c))
|
||||
return comments.hideBalloon();
|
||||
comments.showBalloon(hit.r, hit.c);
|
||||
put(comments.root!, hit.r, hit.c); // showBalloon 의 cellBox 자리 → 화면 자리로 고침
|
||||
},
|
||||
},
|
||||
parts: [find, comments, self],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_filter.ts (주인 A · 화면)
|
||||
* 자동 필터 — 머리 행 칸마다 ▼ 단추 · 값 목록 창(모두 선택 · 값마다 체크 · 이 열 정렬 · 이 열 필터 지움).
|
||||
* 걸러진 행은 엔진이 `행.걸러짐` 으로 적음(사용자 숨김과 따로) · 명령은 `필터`(spreadsheet_sortfilter.ts).
|
||||
*
|
||||
* 잇는 법(D · sub7 · C · sub6):
|
||||
* · 부품 붙이기: `const filter = attachFilter(ctx)` — 단추 층을 격자 overlay(editorSlot 층)에 얹음.
|
||||
* grid.render() 뒤마다 `filter.refresh()`(단추 자리 · 켜짐 표시) · 스크롤은 스스로 따라감.
|
||||
* · 도구 단추 「필터」 · Ctrl+Shift+L → `toggleFilter(ctx)`(켜기 = 고른 범위 · 칸 하나면 현재 영역 / 끄기).
|
||||
* · Alt+↓(활성 칸이 머리 행이면) → `filter.open(활성 열)`.
|
||||
* · 격자(C): 행 `걸러짐` 을 `숨김` 처럼 높이 0 으로 — 머리의 숨김 표시(두 줄)는 사용자 `숨김` 만.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { colName, parseRange, rangeToA1 } from "./spreadsheet_address";
|
||||
import { CommandError } from "./spreadsheet_commands";
|
||||
import { currentRegion, filterCommand, filterItems, sortCommand } from "./spreadsheet_sortfilter";
|
||||
import type { AutoFilter, Command } from "./spreadsheet_types";
|
||||
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
import "./spreadsheet_sortfilter.css";
|
||||
|
||||
const TEXT = {
|
||||
Button: ["필터", "Filter"],
|
||||
All: ["(모두 선택)", "(Select all)"],
|
||||
Blank: ["(빈 칸)", "(Blanks)"],
|
||||
SortAsc: ["오름차순 정렬", "Sort ascending"],
|
||||
SortDesc: ["내림차순 정렬", "Sort descending"],
|
||||
Clear: ["이 열 필터 지우기", "Clear filter from column"],
|
||||
Ok: ["확인", "OK"],
|
||||
Cancel: ["취소", "Cancel"],
|
||||
};
|
||||
const t = (key: keyof typeof TEXT) => TEXT[key][currentLanguageIndex] ?? TEXT[key][0];
|
||||
|
||||
function send(ctx: SpreadsheetContext, make: () => Command): void {
|
||||
try {
|
||||
ctx.dispatch(make());
|
||||
} catch (e) {
|
||||
if (!(e instanceof CommandError)) throw e;
|
||||
showToast(e.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
/** 필터 켜기 · 끄기 */
|
||||
export function toggleFilter(ctx: SpreadsheetContext): void {
|
||||
if (ctx.readOnly) return;
|
||||
const s = ctx.sheet();
|
||||
if (s.필터) return send(ctx, () => filterCommand(ctx.book, ctx.engine, s.id, null));
|
||||
const g = ctx.selection.범위[0];
|
||||
const rg = g.r0 === g.r1 && g.c0 === g.c1 ? currentRegion(ctx.book, s.id, g.r0, g.c0) : g;
|
||||
send(ctx, () => ({ 종류: "필터", 시트: s.id, 필터: { 범위: rangeToA1(rg) }, 걸러짐: [] }));
|
||||
}
|
||||
|
||||
export interface FilterHandle extends PartHandle {
|
||||
/** 열 `c` 값 목록 창 열기(필터 범위 안일 때) */
|
||||
open(c: number): void;
|
||||
}
|
||||
|
||||
export function attachFilter(ctx: SpreadsheetContext): FilterHandle {
|
||||
const layer = el("div", { className: "ss-filter__layer" });
|
||||
let panel: HTMLElement | null = null;
|
||||
|
||||
const filterOf = () => {
|
||||
const f = ctx.sheet().필터;
|
||||
const rg = f && parseRange(f.범위);
|
||||
return f && rg ? { f, rg } : null;
|
||||
};
|
||||
|
||||
/** 칸 자리 → 단추 층 안 자리(editorSlot 상자는 격자 뿌리 기준 — 층 원점과 다를 수 있어 실제 자리로 맞춤) */
|
||||
function boxIn(r: number, c: number) {
|
||||
const { layer: host, box } = ctx.grid.editorSlot(r, c);
|
||||
const a = host.getBoundingClientRect();
|
||||
const g = ctx.grid.root.getBoundingClientRect();
|
||||
return { ...box, x: box.x - (a.left - g.left), y: box.y - (a.top - g.top) };
|
||||
}
|
||||
|
||||
/** 머리 칸이 지금 보이나(틀고정 판이면 늘 보임) */
|
||||
function shown(r: number, c: number): boolean {
|
||||
const v = ctx.grid.visibleRange();
|
||||
const fz = ctx.sheet().틀고정 ?? {};
|
||||
const rowOk = r < (fz.행 ?? 0) || (r >= v.r0 && r <= v.r1);
|
||||
const colOk = c < (fz.열 ?? 0) || (c >= v.c0 && c <= v.c1);
|
||||
return rowOk && colOk;
|
||||
}
|
||||
|
||||
function place(): void {
|
||||
const { layer: host } = ctx.grid.editorSlot(0, 0);
|
||||
if (layer.parentElement !== host) host.append(layer);
|
||||
const cur = filterOf();
|
||||
const btns: HTMLElement[] = [];
|
||||
if (cur)
|
||||
for (let c = cur.rg.c0; c <= cur.rg.c1; c++) {
|
||||
const box = boxIn(cur.rg.r0, c);
|
||||
if (!box.w || !box.h || !shown(cur.rg.r0, c)) continue;
|
||||
const on = !!cur.f.조건?.[colName(c)];
|
||||
const b = el("button", {
|
||||
className: on ? "ss-filter__btn is-on" : "ss-filter__btn",
|
||||
text: "▼",
|
||||
attrs: {
|
||||
type: "button",
|
||||
"aria-label": `${t("Button")} ${colName(c)}`,
|
||||
"data-col": String(c),
|
||||
},
|
||||
});
|
||||
b.style.left = `${box.x + box.w - 17}px`;
|
||||
b.style.top = `${box.y + box.h - 17}px`;
|
||||
b.addEventListener("mousedown", (ev) => ev.stopPropagation());
|
||||
b.addEventListener("click", () => open(c));
|
||||
btns.push(b);
|
||||
}
|
||||
layer.replaceChildren(...btns, ...(panel ? [panel] : []));
|
||||
}
|
||||
|
||||
function close(): void {
|
||||
panel?.remove();
|
||||
panel = null;
|
||||
document.removeEventListener("mousedown", onOutside, true);
|
||||
}
|
||||
|
||||
const onOutside = (ev: MouseEvent) => {
|
||||
if (panel && !panel.contains(ev.target as Node)) close();
|
||||
};
|
||||
|
||||
function apply(next: AutoFilter): void {
|
||||
close();
|
||||
send(ctx, () => filterCommand(ctx.book, ctx.engine, ctx.sheet().id, next));
|
||||
}
|
||||
|
||||
function open(c: number): void {
|
||||
const cur = filterOf();
|
||||
if (!cur || c < cur.rg.c0 || c > cur.rg.c1 || ctx.readOnly) return;
|
||||
close();
|
||||
const sheetId = ctx.sheet().id;
|
||||
const items = filterItems(ctx.book, ctx.engine, sheetId, c);
|
||||
const boxes = items.map((it) => {
|
||||
const cb = el("input", { attrs: { type: "checkbox" } });
|
||||
cb.checked = it.켬;
|
||||
return cb;
|
||||
});
|
||||
const all = el("input", { attrs: { type: "checkbox" } });
|
||||
const ok = createButton({
|
||||
label: t("Ok"),
|
||||
onClick: () => {
|
||||
const conds = { ...cur.f.조건 };
|
||||
if (boxes.every((b) => b.checked)) delete conds[colName(c)];
|
||||
else conds[colName(c)] = items.filter((_, i) => boxes[i].checked).map((it) => it.글);
|
||||
apply({ ...cur.f, 조건: conds });
|
||||
},
|
||||
});
|
||||
const sync = () => {
|
||||
all.checked = boxes.every((b) => b.checked);
|
||||
all.indeterminate = !all.checked && boxes.some((b) => b.checked);
|
||||
ok.disabled = !boxes.some((b) => b.checked);
|
||||
};
|
||||
all.addEventListener("change", () => {
|
||||
for (const b of boxes) b.checked = all.checked;
|
||||
sync();
|
||||
});
|
||||
for (const b of boxes) b.addEventListener("change", sync);
|
||||
const link = (label: string, run: () => void) => {
|
||||
const a = el("button", {
|
||||
className: "ss-filter__link",
|
||||
text: label,
|
||||
attrs: { type: "button" },
|
||||
});
|
||||
a.addEventListener("click", run);
|
||||
return a;
|
||||
};
|
||||
const sortBy = (desc: boolean) => () => {
|
||||
close();
|
||||
send(ctx, () =>
|
||||
sortCommand(ctx.book, ctx.engine, sheetId, cur.rg, {
|
||||
머리: true,
|
||||
기준: [{ 열: c, 내림: desc }],
|
||||
}),
|
||||
);
|
||||
};
|
||||
const rest = { ...cur.f.조건 };
|
||||
delete rest[colName(c)];
|
||||
|
||||
panel = el("div", {
|
||||
className: "ss-filter__panel",
|
||||
attrs: { role: "dialog", "aria-label": `${t("Button")} ${colName(c)}` },
|
||||
children: [
|
||||
link(t("SortAsc"), sortBy(false)),
|
||||
link(t("SortDesc"), sortBy(true)),
|
||||
link(t("Clear"), () => apply({ ...cur.f, 조건: rest })),
|
||||
el("div", {
|
||||
className: "ss-filter__list",
|
||||
children: [
|
||||
el("label", { children: [all, t("All")] }),
|
||||
...items.map((it, i) =>
|
||||
el("label", { children: [boxes[i], it.글 === "" ? t("Blank") : it.글] }),
|
||||
),
|
||||
],
|
||||
}),
|
||||
el("div", {
|
||||
className: "ss-filter__foot",
|
||||
children: [createButton({ label: t("Cancel"), variant: "ghost", onClick: close }), ok],
|
||||
}),
|
||||
],
|
||||
});
|
||||
panel.addEventListener("keydown", (ev) => {
|
||||
if (ev.key === "Escape") close();
|
||||
ev.stopPropagation();
|
||||
});
|
||||
const box = boxIn(cur.rg.r0, c);
|
||||
panel.style.left = `${box.x}px`;
|
||||
panel.style.top = `${box.y + box.h}px`;
|
||||
sync();
|
||||
layer.append(panel);
|
||||
document.addEventListener("mousedown", onOutside, true);
|
||||
}
|
||||
|
||||
const onScroll = () => place();
|
||||
ctx.grid.root.addEventListener("scroll", onScroll, true);
|
||||
place();
|
||||
|
||||
return {
|
||||
root: layer,
|
||||
refresh() {
|
||||
close();
|
||||
place();
|
||||
},
|
||||
open,
|
||||
destroy() {
|
||||
close();
|
||||
ctx.grid.root.removeEventListener("scroll", onScroll, true);
|
||||
layer.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_find_panel.ts (주인 D)
|
||||
* 찾기(Ctrl+F) · 바꾸기(Ctrl+H) 작은 패널 — 부품 오른위에 뜸. 찾기 규칙은 `spreadsheet_find.ts`(sub1).
|
||||
* Enter = 다음 · Esc = 닫고 격자로. 바꾸기 = 활성 칸이 맞으면 바꾸고 다음으로(엑셀).
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { findAll, findNext, replaceAll, replaceOne, type FindOptions } from "./spreadsheet_find";
|
||||
import { selectCell } from "./spreadsheet_selection";
|
||||
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
|
||||
const TEXT = {
|
||||
find: "찾을 내용",
|
||||
replace: "바꿀 내용",
|
||||
next: "다음",
|
||||
one: "바꾸기",
|
||||
all: "모두 바꾸기",
|
||||
close: "닫기",
|
||||
case: "대소문자",
|
||||
whole: "전체 칸",
|
||||
formula: "수식 안",
|
||||
none: "찾을 수 없음",
|
||||
replaced: "{n} 개 바꿈",
|
||||
};
|
||||
|
||||
export interface FindPanelHandle extends PartHandle {
|
||||
open(replace: boolean): void;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export function mountFindPanel(ctx: SpreadsheetContext, back: () => void): FindPanelHandle {
|
||||
const query = el("input", { className: "ss-find__input", attrs: { placeholder: TEXT.find } });
|
||||
const repl = el("input", { className: "ss-find__input", attrs: { placeholder: TEXT.replace } });
|
||||
const status = el("span", { className: "ss-find__status" });
|
||||
const button = (text: string, onClick: () => void): HTMLButtonElement => {
|
||||
const b = el("button", { className: "ss-find__btn", text, attrs: { type: "button" } });
|
||||
b.addEventListener("click", onClick);
|
||||
return b;
|
||||
};
|
||||
const check = (text: string): HTMLInputElement => {
|
||||
const box = el("input", { attrs: { type: "checkbox" } });
|
||||
options.append(el("label", { className: "ss-find__opt", children: [box, text] }));
|
||||
return box;
|
||||
};
|
||||
const closeBtn = button("×", close);
|
||||
closeBtn.title = TEXT.close;
|
||||
const options = el("div", { className: "ss-find__row" });
|
||||
const caseBox = check(TEXT.case);
|
||||
const wholeBox = check(TEXT.whole);
|
||||
const formulaBox = check(TEXT.formula);
|
||||
const replaceRow = el("div", {
|
||||
className: "ss-find__row",
|
||||
children: [repl, button(TEXT.one, one), button(TEXT.all, all)],
|
||||
});
|
||||
const root = el("div", {
|
||||
className: "ss-find",
|
||||
attrs: { hidden: "" },
|
||||
children: [
|
||||
el("div", {
|
||||
className: "ss-find__row",
|
||||
children: [query, button(TEXT.next, next), closeBtn],
|
||||
}),
|
||||
replaceRow,
|
||||
options,
|
||||
status,
|
||||
],
|
||||
});
|
||||
ctx.root.append(root);
|
||||
|
||||
const opts = (): FindOptions => ({
|
||||
대소문자: caseBox.checked,
|
||||
전체칸: wholeBox.checked,
|
||||
수식안: formulaBox.checked,
|
||||
});
|
||||
const here = () => ({ 시트: ctx.selection.시트, ...ctx.selection.활성 });
|
||||
|
||||
function next(): void {
|
||||
status.textContent = "";
|
||||
if (!query.value) return;
|
||||
const hit = findNext(ctx.book, here(), query.value, opts());
|
||||
if (!hit) return void (status.textContent = TEXT.none);
|
||||
if (hit.시트 !== ctx.selection.시트) ctx.showSheet(hit.시트);
|
||||
ctx.select(selectCell(ctx.sheet(), hit.r, hit.c), true);
|
||||
}
|
||||
|
||||
function one(): void {
|
||||
if (ctx.readOnly || !query.value) return;
|
||||
const at = here();
|
||||
const onIt = findAll(ctx.book, query.value, opts(), [at.시트]).some(
|
||||
(h) => h.r === at.r && h.c === at.c,
|
||||
);
|
||||
if (onIt) replaceOne(ctx, at, query.value, repl.value, opts());
|
||||
next();
|
||||
}
|
||||
|
||||
function all(): void {
|
||||
if (ctx.readOnly || !query.value) return;
|
||||
const n = replaceAll(ctx, query.value, repl.value, opts());
|
||||
status.textContent = n ? TEXT.replaced.replace("{n}", String(n)) : TEXT.none;
|
||||
}
|
||||
|
||||
function open(replace: boolean): void {
|
||||
replaceRow.hidden = !replace || ctx.readOnly;
|
||||
root.removeAttribute("hidden");
|
||||
status.textContent = "";
|
||||
query.focus();
|
||||
query.select();
|
||||
}
|
||||
|
||||
function close(): void {
|
||||
root.setAttribute("hidden", "");
|
||||
back();
|
||||
}
|
||||
|
||||
const onKey = (e: KeyboardEvent): void => {
|
||||
if (e.key === "Escape") close();
|
||||
else if (e.key === "Enter") e.target === repl ? one() : next();
|
||||
else return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
root.addEventListener("keydown", onKey);
|
||||
|
||||
return {
|
||||
root,
|
||||
open,
|
||||
close,
|
||||
refresh() {},
|
||||
destroy: () => root.remove(),
|
||||
};
|
||||
}
|
||||
@@ -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<string, SheetFunction> = { ...MATH_FUNCTIONS, ...MISC_FUNCTIONS };
|
||||
const TABLE: Record<string, SheetFunction> = {
|
||||
...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(일련번호)", 뜻: "일" },
|
||||
];
|
||||
|
||||
@@ -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<boolean>(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<string, SheetFunction> = {
|
||||
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);
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -415,4 +415,76 @@ export const MATH_FUNCTIONS: Record<string, SheetFunction> = {
|
||||
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),
|
||||
};
|
||||
|
||||
@@ -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<string, SheetFunction> = {
|
||||
IF: {
|
||||
min: 2,
|
||||
@@ -311,217 +263,207 @@ export const MISC_FUNCTIONS: Record<string, SheetFunction> = {
|
||||
}
|
||||
},
|
||||
},
|
||||
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()));
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -31,7 +31,19 @@ const ARROWS: Record<string, [number, number]> = {
|
||||
ArrowRight: [0, 1],
|
||||
};
|
||||
|
||||
export function attachKeys(ctx: SpreadsheetContext, ed: Editor): () => void {
|
||||
/** 2단계 잇기 자리 — 부른 쪽(spreadsheet.ts)이 채움 */
|
||||
export interface KeyHooks {
|
||||
/** Ctrl+F · Shift+F5 찾기 · Ctrl+H 바꾸기 */
|
||||
find?(replace: boolean): void;
|
||||
/** Ctrl+Shift+V — 곧 올 paste 사건을 골라 붙여넣기로 */
|
||||
pasteSpecial?(): void;
|
||||
/** Shift+F2 메모 편집 */
|
||||
comment?(): void;
|
||||
/** 고름 중 Esc(서식 붓 끄기 등) */
|
||||
escape?(): void;
|
||||
}
|
||||
|
||||
export function attachKeys(ctx: SpreadsheetContext, ed: Editor, hooks: KeyHooks = {}): () => void {
|
||||
const go = (sel: Selection): void => ctx.select(sel, true);
|
||||
|
||||
/** 확정 뒤 한 칸 — 고름이 여러 칸이면 그 안에서 돎 */
|
||||
@@ -56,6 +68,7 @@ export function attachKeys(ctx: SpreadsheetContext, ed: Editor): () => void {
|
||||
return true;
|
||||
}
|
||||
case "Tab":
|
||||
if (!e.shiftKey && ed.accept()) return true; // 함수 자동 완성
|
||||
ed.commit();
|
||||
next(0, e.shiftKey ? -1 : 1);
|
||||
return true;
|
||||
@@ -67,6 +80,7 @@ export function attachKeys(ctx: SpreadsheetContext, ed: Editor): () => void {
|
||||
return true;
|
||||
}
|
||||
const arrow = ARROWS[e.key];
|
||||
if (arrow && !arrow[1] && !e.shiftKey && ed.suggestMove(arrow[0])) return true;
|
||||
if (!arrow || inBar || ed.mode() === "edit") return false;
|
||||
if (ed.pointable()) {
|
||||
ed.pointMove(arrow[0], arrow[1], e.shiftKey, ctrl);
|
||||
@@ -116,6 +130,14 @@ export function attachKeys(ctx: SpreadsheetContext, ed: Editor): () => void {
|
||||
case "r":
|
||||
fill(k === "d");
|
||||
return true;
|
||||
case "f":
|
||||
case "h":
|
||||
if (!hooks.find) return false;
|
||||
hooks.find(k === "h");
|
||||
return true;
|
||||
case "v":
|
||||
if (e.shiftKey) hooks.pasteSpecial?.();
|
||||
return false; // paste 사건은 그대로 — E 클립보드 · 골라 붙여넣기가 받음
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -126,9 +148,17 @@ export function attachKeys(ctx: SpreadsheetContext, ed: Editor): () => void {
|
||||
case "Tab":
|
||||
next(0, e.shiftKey ? -1 : 1);
|
||||
return true;
|
||||
case "F2":
|
||||
ed.begin();
|
||||
case "F5": // Shift+F5 = 찾기(엑셀 · Ctrl+F 를 브라우저가 가로챌 때)
|
||||
if (!e.shiftKey || !hooks.find) return false;
|
||||
hooks.find(false);
|
||||
return true;
|
||||
case "F2":
|
||||
if (e.shiftKey) hooks.comment?.();
|
||||
else ed.begin();
|
||||
return true;
|
||||
case "Escape":
|
||||
hooks.escape?.();
|
||||
return !!hooks.escape;
|
||||
case "Delete":
|
||||
if (!ctx.readOnly) ctx.dispatch({ 종류: "내용지움", 시트: sheet.id, 범위: sel.범위 });
|
||||
return true;
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* 머리 끝(폭 · 높이 끌기)은 C 몫 — 여기서 건드리지 않음.
|
||||
* ========================================================================== */
|
||||
|
||||
import { autofitColumn } from "./spreadsheet_autofit";
|
||||
import { fillCells } from "./spreadsheet_fill";
|
||||
import {
|
||||
hasValue,
|
||||
@@ -18,7 +19,7 @@ import {
|
||||
selectRows,
|
||||
selectSpan,
|
||||
} from "./spreadsheet_selection";
|
||||
import { boxStyle, type Editor } from "./spreadsheet_editor";
|
||||
import { boxStyle, slotOf, type Editor } from "./spreadsheet_editor";
|
||||
import type { CellAddress, CellRange } from "./spreadsheet_types";
|
||||
import type { HitResult, Selection, SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
|
||||
@@ -45,7 +46,19 @@ export function fillTarget(
|
||||
return { target: { ...src, c0: p.c }, clear: null };
|
||||
}
|
||||
|
||||
export function attachMouse(ctx: SpreadsheetContext, ed: Editor): () => void {
|
||||
/** 2단계 잇기 자리 — 부른 쪽(spreadsheet.ts)이 채움 */
|
||||
export interface MouseHooks {
|
||||
/** 칸 끌어 고르기가 끝남(서식 붓 바르기) */
|
||||
selected?(): void;
|
||||
/** 누르지 않고 움직일 때 가리킨 것(메모 풍선) — 격자 밖이면 null */
|
||||
hover?(hit: HitResult | null): void;
|
||||
}
|
||||
|
||||
export function attachMouse(
|
||||
ctx: SpreadsheetContext,
|
||||
ed: Editor,
|
||||
hooks: MouseHooks = {},
|
||||
): () => void {
|
||||
const root = ctx.grid.root;
|
||||
const preview = document.createElement("div");
|
||||
preview.className = "ss-fill-preview";
|
||||
@@ -80,8 +93,8 @@ export function attachMouse(ctx: SpreadsheetContext, ed: Editor): () => void {
|
||||
|
||||
function showPreview(g: CellRange | null): void {
|
||||
if (!g) return preview.remove();
|
||||
const a = ctx.grid.editorSlot(g.r0, g.c0);
|
||||
const b = ctx.grid.editorSlot(Math.min(g.r1, LAST_R), Math.min(g.c1, LAST_C));
|
||||
const a = slotOf(ctx, g.r0, g.c0);
|
||||
const b = slotOf(ctx, Math.min(g.r1, LAST_R), Math.min(g.c1, LAST_C));
|
||||
if (preview.parentElement !== a.layer) a.layer.append(preview);
|
||||
boxStyle(preview, {
|
||||
x: a.box.x,
|
||||
@@ -198,11 +211,14 @@ export function attachMouse(ctx: SpreadsheetContext, ed: Editor): () => void {
|
||||
ctx.select(first);
|
||||
const anchor = first.기준;
|
||||
let last = `${hit.r},${hit.c}`;
|
||||
return drag((p) => {
|
||||
if (`${p.r},${p.c}` === last) return;
|
||||
last = `${p.r},${p.c}`;
|
||||
ctx.select(selectSpan(sheet, anchor, p));
|
||||
});
|
||||
return drag(
|
||||
(p) => {
|
||||
if (`${p.r},${p.c}` === last) return;
|
||||
last = `${p.r},${p.c}`;
|
||||
ctx.select(selectSpan(sheet, anchor, p));
|
||||
},
|
||||
() => hooks.selected?.(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -212,12 +228,28 @@ export function attachMouse(ctx: SpreadsheetContext, ed: Editor): () => void {
|
||||
const hit = ctx.grid.hitTest(e.clientX, e.clientY);
|
||||
if (!hit) return;
|
||||
if (hit.kind === "fillHandle") return fillDown();
|
||||
if (hit.kind === "colEdge") {
|
||||
// 고른 열 전체의 경계면 고른 열 모두(엑셀)
|
||||
const g = ctx.selection.범위[0];
|
||||
const whole = g.r0 === 0 && g.r1 === LAST_R && inRange(g, 0, hit.c);
|
||||
for (let c = whole ? g.c0 : hit.c; c <= (whole ? g.c1 : hit.c); c++) autofitColumn(ctx, c);
|
||||
return;
|
||||
}
|
||||
if (hit.kind === "cell" && !ed.editing()) ed.begin();
|
||||
}
|
||||
|
||||
const onHover = (e: MouseEvent): void => {
|
||||
if (!stopDrag && !e.buttons) hooks.hover?.(ctx.grid.hitTest(e.clientX, e.clientY));
|
||||
};
|
||||
const onLeave = (): void => hooks.hover?.(null);
|
||||
|
||||
root.addEventListener("mousedown", onDown);
|
||||
root.addEventListener("dblclick", onDbl);
|
||||
root.addEventListener("mousemove", onHover);
|
||||
root.addEventListener("mouseleave", onLeave);
|
||||
return () => {
|
||||
root.removeEventListener("mousemove", onHover);
|
||||
root.removeEventListener("mouseleave", onLeave);
|
||||
stopDrag?.();
|
||||
preview.remove();
|
||||
root.removeEventListener("mousedown", onDown);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_sort_dialog.ts (주인 A · 화면)
|
||||
* 정렬 대화 상자 · 빠른 정렬(오름 · 내림). 엔진은 `spreadsheet_sortfilter.ts`(명령 `정렬`).
|
||||
* 범위 = 고른 범위 · 칸 하나만 골랐으면 이어진 값 덩어리(엑셀 「현재 영역」) · 머리 행은 짐작해 켬.
|
||||
*
|
||||
* 잇는 법(D · sub7):
|
||||
* · 도구 모음 · 메뉴 「정렬…」 → `openSortDialog(ctx)` — 창을 `ctx.root` 에 띄우고 닫는 함수를 돌려줌.
|
||||
* · 「오름차순」 「내림차순」 단추 → `sortSelection(ctx, 내림)` — 활성 칸 열을 기준으로 바로 정렬.
|
||||
* · 막히면(병합 걸침 등) 토스트로 까닭을 보임 · 되돌리기는 `ctx.dispatch` 가 쌓음.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { colName, rangeToA1 } from "./spreadsheet_address";
|
||||
import { CommandError } from "./spreadsheet_commands";
|
||||
import { currentRegion, displayText, sortCommand } from "./spreadsheet_sortfilter";
|
||||
import type { SortKey, SortOptions } from "./spreadsheet_sortfilter";
|
||||
import type { CellRange } from "./spreadsheet_types";
|
||||
import type { SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
import "./spreadsheet_sortfilter.css";
|
||||
|
||||
const TEXT = {
|
||||
Title: ["정렬", "Sort"],
|
||||
Range: ["범위", "Range"],
|
||||
Header: ["머리 행 있음", "My data has headers"],
|
||||
Case: ["대소문자 구분", "Case sensitive"],
|
||||
Column: ["열", "Column"],
|
||||
Asc: ["오름차순", "Ascending"],
|
||||
Desc: ["내림차순", "Descending"],
|
||||
AddKey: ["기준 더하기", "Add level"],
|
||||
RemoveKey: ["빼기", "Remove"],
|
||||
Cancel: ["취소", "Cancel"],
|
||||
Ok: ["정렬", "Sort"],
|
||||
};
|
||||
const t = (key: keyof typeof TEXT) => TEXT[key][currentLanguageIndex] ?? TEXT[key][0];
|
||||
|
||||
/** 정렬 범위 — 칸 하나면 현재 영역 */
|
||||
function targetRange(ctx: SpreadsheetContext): CellRange {
|
||||
const g = ctx.selection.범위[0];
|
||||
if (g.r0 === g.r1 && g.c0 === g.c1)
|
||||
return currentRegion(ctx.book, ctx.selection.시트, g.r0, g.c0);
|
||||
return g;
|
||||
}
|
||||
|
||||
/** 머리 행 짐작 — 첫 행이 모두 글이고 둘째 행에 글 아닌 값이 있으면 머리 */
|
||||
function guessHeader(ctx: SpreadsheetContext, rg: CellRange): boolean {
|
||||
if (rg.r0 === rg.r1) return false;
|
||||
const v = (r: number, c: number) => ctx.engine.value(ctx.selection.시트, r, c);
|
||||
let allText = true;
|
||||
let secondOther = false;
|
||||
for (let c = rg.c0; c <= rg.c1; c++) {
|
||||
const a = v(rg.r0, c);
|
||||
const b = v(rg.r0 + 1, c);
|
||||
if (a !== null && typeof a !== "string") allText = false;
|
||||
if (b !== null && typeof b !== "string") secondOther = true;
|
||||
}
|
||||
return allText && secondOther;
|
||||
}
|
||||
|
||||
function run(ctx: SpreadsheetContext, rg: CellRange, opts: SortOptions): boolean {
|
||||
try {
|
||||
ctx.dispatch(sortCommand(ctx.book, ctx.engine, ctx.selection.시트, rg, opts));
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (!(e instanceof CommandError)) throw e;
|
||||
showToast(e.message, "error");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 활성 칸 열 기준 바로 정렬 */
|
||||
export function sortSelection(ctx: SpreadsheetContext, desc: boolean): void {
|
||||
if (ctx.readOnly) return;
|
||||
const rg = targetRange(ctx);
|
||||
const c = Math.min(Math.max(ctx.selection.활성.c, rg.c0), rg.c1);
|
||||
run(ctx, rg, { 머리: guessHeader(ctx, rg), 기준: [{ 열: c, 내림: desc }] });
|
||||
}
|
||||
|
||||
/** 정렬 대화 상자 — 닫는 함수를 돌려줌 */
|
||||
export function openSortDialog(ctx: SpreadsheetContext): () => void {
|
||||
const rg = targetRange(ctx);
|
||||
const sheet = ctx.selection.시트;
|
||||
const keys: SortKey[] = [{ 열: Math.min(Math.max(ctx.selection.활성.c, rg.c0), rg.c1) }];
|
||||
|
||||
const header = el("input", { attrs: { type: "checkbox" } });
|
||||
header.checked = guessHeader(ctx, rg);
|
||||
const caseBox = el("input", { attrs: { type: "checkbox" } });
|
||||
const list = el("div", { className: "ss-sort__keys" });
|
||||
const box = el("div", {
|
||||
className: "ss-sort__box",
|
||||
attrs: { role: "dialog", "aria-label": t("Title") },
|
||||
});
|
||||
const shade = el("div", { className: "ss-sort", children: [box] });
|
||||
|
||||
const columnLabel = (c: number) => {
|
||||
const head = header.checked ? displayText(ctx.book, ctx.engine, sheet, rg.r0, c) : "";
|
||||
return head ? `${colName(c)}: ${head}` : `${t("Column")} ${colName(c)}`;
|
||||
};
|
||||
|
||||
function drawKeys() {
|
||||
list.replaceChildren(
|
||||
...keys.map((k, i) => {
|
||||
const col = el("select", { attrs: { "aria-label": t("Column") } });
|
||||
for (let c = rg.c0; c <= rg.c1; c++)
|
||||
col.append(el("option", { text: columnLabel(c), attrs: { value: String(c) } }));
|
||||
col.value = String(k.열);
|
||||
col.addEventListener("change", () => (k.열 = Number(col.value)));
|
||||
const dir = el("select", {
|
||||
children: [
|
||||
el("option", { text: t("Asc"), attrs: { value: "asc" } }),
|
||||
el("option", { text: t("Desc"), attrs: { value: "desc" } }),
|
||||
],
|
||||
});
|
||||
dir.value = k.내림 ? "desc" : "asc";
|
||||
dir.addEventListener("change", () => (k.내림 = dir.value === "desc"));
|
||||
const remove = createButton({
|
||||
label: t("RemoveKey"),
|
||||
variant: "ghost",
|
||||
disabled: keys.length === 1,
|
||||
onClick: () => {
|
||||
keys.splice(i, 1);
|
||||
drawKeys();
|
||||
},
|
||||
});
|
||||
return el("div", { className: "ss-sort__row", children: [col, dir, remove] });
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
shade.remove();
|
||||
document.removeEventListener("keydown", onKey, true);
|
||||
};
|
||||
const ok = () => {
|
||||
if (run(ctx, rg, { 머리: header.checked, 기준: keys, 대소문자: caseBox.checked })) close();
|
||||
};
|
||||
const onKey = (ev: KeyboardEvent) => {
|
||||
if (ev.key === "Escape") close();
|
||||
else if (ev.key === "Enter" && !(ev.target instanceof HTMLSelectElement)) ok();
|
||||
else return;
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
};
|
||||
|
||||
header.addEventListener("change", drawKeys);
|
||||
box.append(
|
||||
el("div", { className: "ss-sort__title", text: t("Title") }),
|
||||
el("div", { className: "ss-sort__meta", text: `${t("Range")} ${rangeToA1(rg)}` }),
|
||||
el("label", { className: "ss-sort__meta", children: [header, t("Header")] }),
|
||||
el("label", { className: "ss-sort__meta", children: [caseBox, t("Case")] }),
|
||||
list,
|
||||
el("div", {
|
||||
className: "ss-sort__row",
|
||||
children: [
|
||||
createButton({
|
||||
label: t("AddKey"),
|
||||
variant: "ghost",
|
||||
onClick: () => {
|
||||
keys.push({ 열: rg.c0 });
|
||||
drawKeys();
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
el("div", {
|
||||
className: "ss-sort__foot",
|
||||
children: [
|
||||
createButton({ label: t("Cancel"), variant: "ghost", onClick: close }),
|
||||
createButton({ label: t("Ok"), onClick: ok }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
shade.addEventListener("mousedown", (ev) => {
|
||||
if (ev.target === shade) close();
|
||||
});
|
||||
document.addEventListener("keydown", onKey, true);
|
||||
drawKeys();
|
||||
ctx.root.append(shade);
|
||||
return close;
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/* spreadsheet_sortfilter.css (주인 A) — 정렬 대화 상자 · 자동 필터 단추 · 값 목록 창 */
|
||||
|
||||
.ss-sort {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: var(--z-dropdown);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgb(0 0 0 / 18%);
|
||||
}
|
||||
|
||||
.ss-sort__box,
|
||||
.ss-filter__panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-12);
|
||||
background: var(--color-surface-raised);
|
||||
color: var(--color-text-body);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
box-shadow: var(--shadow-lg);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ss-sort__box {
|
||||
min-width: 360px;
|
||||
max-width: 90%;
|
||||
}
|
||||
|
||||
.ss-sort__title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ss-sort__meta,
|
||||
.ss-sort__row,
|
||||
.ss-sort__foot,
|
||||
.ss-filter__foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.ss-sort__meta {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.ss-sort__row select {
|
||||
flex: 1;
|
||||
padding: var(--spacing-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-canvas);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.ss-sort__foot,
|
||||
.ss-filter__foot {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* 필터 단추 층 — 크기 0(격자 조작을 막지 않음) · 단추만 누름 */
|
||||
.ss-filter__layer {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.ss-filter__btn {
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
font-size: 9px;
|
||||
line-height: 14px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.ss-filter__btn.is-on {
|
||||
color: var(--color-royal-amethyst);
|
||||
border-color: var(--color-royal-amethyst);
|
||||
}
|
||||
|
||||
.ss-filter__panel {
|
||||
position: absolute;
|
||||
z-index: var(--z-dropdown);
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.ss-filter__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
padding: var(--spacing-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-canvas);
|
||||
}
|
||||
|
||||
.ss-filter__list label {
|
||||
display: flex;
|
||||
gap: var(--spacing-4);
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ss-filter__link {
|
||||
padding: 2px var(--spacing-4);
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ss-filter__link:hover {
|
||||
background: var(--color-surface-sunken);
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_sortfilter.ts (주인 A)
|
||||
* 정렬 · 자동 필터 엔진 — DOM 모름. 값 비교 · 보이는 글은 엔진(`CalcEngine`) · 숫자 형식(B)으로 셈.
|
||||
* 명령은 결과(정렬 차례 · 걸러진 행)를 박아 둠 → 다시 하기가 같은 결과 · 적용은 `applyCommand` 가 여기로 넘김.
|
||||
*
|
||||
* 엑셀 규칙:
|
||||
* 정렬 차례 — 오름: 수 < 글 < 참거짓(FALSE<TRUE) < 오류 · 내림은 거꾸로 · 빈 칸은 늘 맨 뒤 · 같은 값은 원래 차례.
|
||||
* 글은 대소문자 가리지 않음(옵션으로 가림 = 소문자 먼저). 값은 계산값(식 칸은 푼 값).
|
||||
* 정렬 옮김 — 범위 열만 행째 옮김 · 식은 복사처럼 상대 참조만 옮김(범위 밖에서 범위를 가리키던 식은 그대로) ·
|
||||
* 서식 · 메모도 같이 · 행 높이는 제자리 · 걸러진 행은 제자리(보이는 행끼리만 정렬) · 병합이 걸치면 막음.
|
||||
* 필터 — 첫 행 = 머리 · 값 목록 = 보이는 글(숫자 형식 적용) · 빈 칸 = "" · 조건 안 맞는 행은 `걸러짐`
|
||||
* (사용자 숨김과 따로) · 행열을 넣고 지우면 필터 범위 · 조건 열도 따라감 · 끄면 걸러짐 모두 풂.
|
||||
*
|
||||
* 잇는 법(D · sub7 · C · sub6):
|
||||
* · 정렬: 대화 상자 `openSortDialog(ctx)`(spreadsheet_sort_dialog.ts) · 도구 단추 `sortSelection(ctx, 내림)`.
|
||||
* · 필터: `attachFilter(ctx)`(spreadsheet_filter.ts) 를 부품 목록에 붙이고 grid.render() 뒤 `refresh()` ·
|
||||
* 도구 단추 · Ctrl+Shift+L = `toggleFilter(ctx)`.
|
||||
* · 격자(C): 행 `걸러짐` 도 `숨김` 처럼 높이 0 — 머리의 숨김 표시는 사용자 `숨김` 만.
|
||||
* ========================================================================== */
|
||||
|
||||
import { formatValue } from "./spreadsheet_numfmt";
|
||||
import {
|
||||
colIndex,
|
||||
colName,
|
||||
parseRange,
|
||||
rangesOverlap,
|
||||
rangeToA1,
|
||||
toA1,
|
||||
} from "./spreadsheet_address";
|
||||
import { clone, CommandError, sheetOf } from "./spreadsheet_commands";
|
||||
import type { Of } from "./spreadsheet_commands";
|
||||
import { compareValues, isError, isFrac } from "./spreadsheet_eval";
|
||||
import { moveFormula, shiftSpan } from "./spreadsheet_refshift";
|
||||
import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types";
|
||||
import type {
|
||||
AutoFilter,
|
||||
CalcEngine,
|
||||
Cell,
|
||||
CellRange,
|
||||
Command,
|
||||
CommandEffect,
|
||||
Scalar,
|
||||
Sheet,
|
||||
Workbook,
|
||||
} from "./spreadsheet_types";
|
||||
|
||||
export interface SortKey {
|
||||
/** 기준 열(0 부터 · 범위 안) */
|
||||
열: number;
|
||||
내림?: boolean;
|
||||
}
|
||||
|
||||
export interface SortOptions {
|
||||
/** 첫 행 = 머리(정렬 안 함) */
|
||||
머리: boolean;
|
||||
기준: SortKey[];
|
||||
/** 글 대소문자 가림 */
|
||||
대소문자?: boolean;
|
||||
}
|
||||
|
||||
const filtered = (s: Sheet, r: number) => !!s.행?.[r + 1]?.걸러짐;
|
||||
|
||||
const rank = (v: Scalar) =>
|
||||
isFrac(v) ? 0 : typeof v === "string" ? 1 : typeof v === "boolean" ? 2 : 3;
|
||||
|
||||
const blank = (v: Scalar) => v === null || v === "";
|
||||
|
||||
/** 엑셀 정렬 비교 — 빈 칸은 오름 · 내림 모두 맨 뒤 */
|
||||
export function sortCompare(a: Scalar, b: Scalar, desc = false, caseSensitive = false): number {
|
||||
if (blank(a) || blank(b)) return blank(a) === blank(b) ? 0 : blank(a) ? 1 : -1;
|
||||
let d = rank(a) - rank(b);
|
||||
if (d === 0 && !isError(a)) {
|
||||
if (typeof a === "string" && caseSensitive) d = COLLATE_CASE.compare(a, b as string);
|
||||
else if (typeof a === "string") d = COLLATE.compare(a, b as string);
|
||||
else d = compareValues(a, b);
|
||||
}
|
||||
return desc ? -d : d;
|
||||
}
|
||||
|
||||
const COLLATE = new Intl.Collator("ko", { sensitivity: "accent" });
|
||||
const COLLATE_CASE = new Intl.Collator("ko", { sensitivity: "variant", caseFirst: "lower" });
|
||||
|
||||
/** 칸 하나에서 이어진 값 덩어리(엑셀 「현재 영역」) — 빈 칸만 둘러싼 테두리까지 늘림 */
|
||||
export function currentRegion(book: Workbook, sheetId: string, r: number, c: number): CellRange {
|
||||
const s = sheetOf(book, sheetId);
|
||||
const filled = new Set<string>();
|
||||
for (const [a1, cell] of Object.entries(s.칸))
|
||||
if (cell.값 !== undefined || cell.식 !== undefined) filled.add(a1);
|
||||
const has = (rr: number, cc: number) =>
|
||||
rr >= 0 && cc >= 0 && rr < MAX_ROWS && cc < MAX_COLS && filled.has(toA1(rr, cc));
|
||||
const g = { r0: r, c0: c, r1: r, c1: c };
|
||||
for (let grew = true; grew;) {
|
||||
grew = false;
|
||||
const rowHas = (rr: number) => {
|
||||
for (let cc = g.c0 - 1; cc <= g.c1 + 1; cc++) if (has(rr, cc)) return true;
|
||||
return false;
|
||||
};
|
||||
const colHas = (cc: number) => {
|
||||
for (let rr = g.r0 - 1; rr <= g.r1 + 1; rr++) if (has(rr, cc)) return true;
|
||||
return false;
|
||||
};
|
||||
if (g.r0 > 0 && rowHas(g.r0 - 1)) (g.r0--, (grew = true));
|
||||
if (g.r1 < MAX_ROWS - 1 && rowHas(g.r1 + 1)) (g.r1++, (grew = true));
|
||||
if (g.c0 > 0 && colHas(g.c0 - 1)) (g.c0--, (grew = true));
|
||||
if (g.c1 < MAX_COLS - 1 && colHas(g.c1 + 1)) (g.c1++, (grew = true));
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
function checkMerges(s: Sheet, range: CellRange) {
|
||||
const hit = (s.병합 ?? []).some((m) => {
|
||||
const rg = parseRange(m);
|
||||
return rg && rangesOverlap(rg, range);
|
||||
});
|
||||
if (hit) throw new CommandError("병합한 칸이 든 범위는 정렬하지 못함 — 병합을 먼저 풂");
|
||||
}
|
||||
|
||||
// ── 정렬 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** 정렬 명령 만들기 — 값은 엔진 계산값으로 견줌 · 걸러진 행은 제자리 */
|
||||
export function sortCommand(
|
||||
book: Workbook,
|
||||
engine: CalcEngine,
|
||||
sheetId: string,
|
||||
range: CellRange,
|
||||
opts: SortOptions,
|
||||
): Command {
|
||||
const s = sheetOf(book, sheetId);
|
||||
const data: CellRange = { ...range, r0: range.r0 + (opts.머리 ? 1 : 0) };
|
||||
if (data.r0 > data.r1) throw new CommandError("정렬할 행이 없음");
|
||||
if (!opts.기준.length) throw new CommandError("정렬 기준이 없음");
|
||||
for (const k of opts.기준)
|
||||
if (k.열 < range.c0 || k.열 > range.c1) throw new CommandError("정렬 기준 열이 범위 밖");
|
||||
checkMerges(s, data);
|
||||
const rows: number[] = [];
|
||||
for (let r = data.r0; r <= data.r1; r++) if (!filtered(s, r)) rows.push(r);
|
||||
const keys = new Map(rows.map((r) => [r, opts.기준.map((k) => engine.value(sheetId, r, k.열))]));
|
||||
const sorted = [...rows].sort((a, b) => {
|
||||
const va = keys.get(a)!;
|
||||
const vb = keys.get(b)!;
|
||||
for (let i = 0; i < opts.기준.length; i++) {
|
||||
const d = sortCompare(va[i], vb[i], opts.기준[i].내림, opts.대소문자);
|
||||
if (d) return d;
|
||||
}
|
||||
return a - b;
|
||||
});
|
||||
const order: number[] = [];
|
||||
let next = 0;
|
||||
for (let r = data.r0; r <= data.r1; r++) order.push(filtered(s, r) ? r : sorted[next++]);
|
||||
return { 종류: "정렬", 시트: sheetId, 범위: data, 차례: order };
|
||||
}
|
||||
|
||||
export function applySort(book: Workbook, cmd: Of<"정렬">): CommandEffect {
|
||||
const s = sheetOf(book, cmd.시트);
|
||||
const { r0, r1, c0, c1 } = cmd.범위;
|
||||
const n = r1 - r0 + 1;
|
||||
if (
|
||||
cmd.차례.length !== n ||
|
||||
new Set(cmd.차례).size !== n ||
|
||||
cmd.차례.some((r) => r < r0 || r > r1)
|
||||
)
|
||||
throw new CommandError("정렬 차례가 범위와 안 맞음");
|
||||
checkMerges(s, cmd.범위);
|
||||
const before = clone(s);
|
||||
const take = <T>(table: Record<string, T> | undefined) => {
|
||||
const out = new Map<string, T>();
|
||||
for (let r = r0; r <= r1; r++)
|
||||
for (let c = c0; c <= c1; c++) {
|
||||
const a1 = toA1(r, c);
|
||||
if (table && a1 in table) {
|
||||
out.set(a1, table[a1]);
|
||||
delete table[a1];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const cells = take<Cell>(s.칸);
|
||||
const notes = take<string>(s.comments);
|
||||
cmd.차례.forEach((src, i) => {
|
||||
const to = r0 + i;
|
||||
for (let c = c0; c <= c1; c++) {
|
||||
const cell = cells.get(toA1(src, c));
|
||||
if (cell) {
|
||||
const moved = { ...cell };
|
||||
if (moved.식 !== undefined) moved.식 = moveFormula(moved.식, to - src, 0);
|
||||
s.칸[toA1(to, c)] = moved;
|
||||
}
|
||||
const note = notes.get(toA1(src, c));
|
||||
if (note !== undefined) (s.comments ??= {})[toA1(to, c)] = note;
|
||||
}
|
||||
});
|
||||
if (s.comments && !Object.keys(s.comments).length) delete s.comments;
|
||||
return { undo: { 종류: "시트통째", 시트: before }, cells: [], rebuild: true };
|
||||
}
|
||||
|
||||
// ── 자동 필터 ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** 칸의 보이는 글(숫자 형식 적용) — 빈 칸 "" */
|
||||
export function displayText(
|
||||
book: Workbook,
|
||||
engine: CalcEngine,
|
||||
sheetId: string,
|
||||
r: number,
|
||||
c: number,
|
||||
) {
|
||||
const s = sheetOf(book, sheetId);
|
||||
const cell = s.칸[toA1(r, c)];
|
||||
if (!cell || (cell.값 === undefined && cell.식 === undefined)) return "";
|
||||
const idx = cell.서식 ?? s.행?.[r + 1]?.서식 ?? s.열?.[colName(c)]?.서식 ?? s.기본?.서식 ?? 0;
|
||||
return formatValue(engine.value(sheetId, r, c), book.서식[idx]?.형식).글;
|
||||
}
|
||||
|
||||
/** 필터 단추 목록 — 열의 서로 다른 보이는 글(정렬 차례 · 빈 칸 맨 뒤) · 켬 = 지금 보이는 값 */
|
||||
export function filterItems(
|
||||
book: Workbook,
|
||||
engine: CalcEngine,
|
||||
sheetId: string,
|
||||
c: number,
|
||||
): { 글: string; 켬: boolean }[] {
|
||||
const s = sheetOf(book, sheetId);
|
||||
const f = s.필터 && parseRange(s.필터.범위);
|
||||
if (!f) return [];
|
||||
const allowed = s.필터!.조건?.[colName(c)];
|
||||
const seen = new Map<string, Scalar>();
|
||||
for (let r = f.r0 + 1; r <= f.r1; r++) {
|
||||
const text = displayText(book, engine, sheetId, r, c);
|
||||
if (!seen.has(text)) seen.set(text, text === "" ? null : engine.value(sheetId, r, c));
|
||||
}
|
||||
return [...seen]
|
||||
.sort((a, b) => sortCompare(a[1], b[1]) || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
|
||||
.map(([글]) => ({ 글, 켬: !allowed || allowed.includes(글) }));
|
||||
}
|
||||
|
||||
/** 필터 명령 — 범위 · 조건으로 걸러질 행을 셈(null = 필터 끔) */
|
||||
export function filterCommand(
|
||||
book: Workbook,
|
||||
engine: CalcEngine,
|
||||
sheetId: string,
|
||||
filter: AutoFilter | null,
|
||||
): Command {
|
||||
if (!filter) return { 종류: "필터", 시트: sheetId, 필터: null, 걸러짐: [] };
|
||||
const f = parseRange(filter.범위);
|
||||
if (!f) throw new CommandError(`필터 범위 틀림: ${filter.범위}`);
|
||||
const hidden: number[] = [];
|
||||
const conds = Object.entries(filter.조건 ?? {}).map(
|
||||
([col, list]) => [colIndex(col), new Set(list)] as const,
|
||||
);
|
||||
for (let r = f.r0 + 1; r <= f.r1; r++)
|
||||
if (conds.some(([c, ok]) => !ok.has(displayText(book, engine, sheetId, r, c)))) hidden.push(r);
|
||||
return { 종류: "필터", 시트: sheetId, 필터: filter, 걸러짐: hidden };
|
||||
}
|
||||
|
||||
export function applyFilter(book: Workbook, cmd: Of<"필터">): CommandEffect {
|
||||
const s = sheetOf(book, cmd.시트);
|
||||
const before = clone(s);
|
||||
if (cmd.필터) s.필터 = clone(cmd.필터);
|
||||
else delete s.필터;
|
||||
const table = (s.행 ??= {});
|
||||
for (const [k, info] of Object.entries(table)) {
|
||||
delete info.걸러짐;
|
||||
if (!Object.keys(info).length) delete table[k];
|
||||
}
|
||||
for (const r of cmd.걸러짐) (table[String(r + 1)] ??= {}).걸러짐 = true;
|
||||
if (!Object.keys(table).length) delete s.행;
|
||||
return { undo: { 종류: "시트통째", 시트: before }, cells: [], rebuild: false };
|
||||
}
|
||||
|
||||
/** 행열 넣기 · 지우기 때 필터 범위 · 조건 열 따라감(머리 행 · 끝 열까지 지우면 필터 없어짐) */
|
||||
export function shiftFilter(s: Sheet, rows: boolean, insert: boolean, at: number, n: number) {
|
||||
const f = s.필터 && parseRange(s.필터.범위);
|
||||
if (!s.필터 || !f) return;
|
||||
const sp = rows
|
||||
? shiftSpan(f.r0, f.r1, insert, at, n, MAX_ROWS)
|
||||
: shiftSpan(f.c0, f.c1, insert, at, n, MAX_COLS);
|
||||
const headGone = rows && !insert && f.r0 >= at && f.r0 < at + n;
|
||||
if (!sp || headGone) {
|
||||
delete s.필터;
|
||||
return;
|
||||
}
|
||||
const next = rows ? { ...f, r0: sp[0], r1: sp[1] } : { ...f, c0: sp[0], c1: sp[1] };
|
||||
s.필터.범위 = rangeToA1(next);
|
||||
if (rows || !s.필터.조건) return;
|
||||
const conds: Record<string, string[]> = {};
|
||||
for (const [col, list] of Object.entries(s.필터.조건)) {
|
||||
const c = shiftSpan(colIndex(col), colIndex(col), insert, at, n, MAX_COLS);
|
||||
if (c) conds[colName(c[0])] = list;
|
||||
}
|
||||
s.필터.조건 = conds;
|
||||
}
|
||||
@@ -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
|
||||
@@ -27,6 +27,11 @@
|
||||
* A spreadsheet_refshift.ts 넣기 · 지우기 · 옮기기 · 복사 때 참조 옮김 ~300
|
||||
* A spreadsheet_commands.ts 명령 적용 → 되돌림 짝 · 빈 통합문서 ~550
|
||||
* A spreadsheet_history.ts 되돌리기 · 다시 ~100
|
||||
* A spreadsheet_commands_sheet.ts 시트 명령(더하기 · 지우기 · 이름 · 옮기기 · 통째) ~100
|
||||
* A spreadsheet_sortfilter.ts 정렬 · 자동 필터 엔진(차례 · 걸러짐 셈 · 적용) ~300
|
||||
* A spreadsheet_sort_dialog.ts 정렬 대화 상자 · 빠른 정렬(화면) ~250
|
||||
* A spreadsheet_filter.ts 자동 필터 단추 · 값 목록 창(화면) ~300
|
||||
* A spreadsheet_sortfilter.css ~150
|
||||
* A spreadsheet_recalc.ts 통합문서 한 벌 풀이(서버 Node · 시험 진입) ~80
|
||||
* B spreadsheet_functions.ts 함수 표(이름 → 뜻) · 인자 수 검사 ~120
|
||||
* B spreadsheet_functions_math.ts 수학 · 삼각 · 끝수 ~400
|
||||
@@ -125,6 +130,14 @@ export interface RowInfo {
|
||||
높이?: number;
|
||||
숨김?: boolean;
|
||||
서식?: number;
|
||||
/** 자동 필터가 숨긴 행 — 사용자 `숨김` 과 따로(필터 풀면 보임) · 격자는 숨김처럼 그림. */
|
||||
걸러짐?: boolean;
|
||||
}
|
||||
|
||||
/** 자동 필터 — `범위` 첫 행 = 머리(단추) · `조건` = 열 글자 → 보일 값(보이는 글 · 빈 칸은 ""). 조건 없는 열은 다 보임. */
|
||||
export interface AutoFilter {
|
||||
범위: string;
|
||||
조건?: Record<string, string[]>;
|
||||
}
|
||||
|
||||
export interface Sheet {
|
||||
@@ -147,6 +160,8 @@ export interface Sheet {
|
||||
기본?: { 서식?: number; 열폭?: number; 행높이?: number };
|
||||
/** 칸 메모 — A1 열쇠 → 메모 글. 행열 넣기 · 지우기 · 잘라 붙이기 때 칸과 같이 옮김(지운 칸 메모는 지움). */
|
||||
comments?: Record<string, string>;
|
||||
/** 자동 필터(시트에 하나) */
|
||||
필터?: AutoFilter;
|
||||
}
|
||||
|
||||
/** 새 시트 기본값 — 값은 사용자 협의로 확정(9_엑셀검토 8장 · 임의 확정 금지). 없으면 A 의 임시값. */
|
||||
@@ -353,6 +368,11 @@ export type Command =
|
||||
| { 종류: "시트옮기기"; 시트: string; 자리: number }
|
||||
/** 되돌림 짝 전용 — 시트 통째 바꿔 넣기(행열 지우기 되돌림 등) */
|
||||
| { 종류: "시트통째"; 시트: Sheet; 서식?: CellStyle[] }
|
||||
/** 정렬 — `차례[i]` = 범위 r0+i 자리에 올 원래 행(범위 열만 옮김 · 식은 복사처럼 상대 참조만 옮김).
|
||||
* 차례는 `sortCommand`(값 비교는 엔진)가 만듦 — 다시 하기가 같은 결과가 되게 명령에 박음. */
|
||||
| { 종류: "정렬"; 시트: string; 범위: CellRange; 차례: number[] }
|
||||
/** 자동 필터 켜기 · 조건 바꾸기 · 끄기(null) — `걸러짐` = 숨길 행(`filterCommand` 가 값으로 셈) */
|
||||
| { 종류: "필터"; 시트: string; 필터: AutoFilter | null; 걸러짐: number[] }
|
||||
/** 여러 명령 = 되돌리기 한 번 */
|
||||
| { 종류: "묶음"; 명령: Command[] };
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>정렬 · 필터 시험 틀</title>
|
||||
<link rel="stylesheet" href="../../../ui_template/ui_template_theme.css" />
|
||||
<style>
|
||||
body { margin: 0; font-family: sans-serif; }
|
||||
#bar { margin: 8px; display: flex; gap: 8px; }
|
||||
#host { height: 520px; margin: 8px; border: 1px solid #999; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="bar">
|
||||
<button id="btn-sort">정렬…</button>
|
||||
<button id="btn-asc">오름차순</button>
|
||||
<button id="btn-desc">내림차순</button>
|
||||
<button id="btn-filter">필터</button>
|
||||
<button id="btn-undo">되돌리기</button>
|
||||
</div>
|
||||
<div id="host"></div>
|
||||
<script>
|
||||
window.ssErrors = [];
|
||||
window.addEventListener("error", (e) => window.ssErrors.push(String(e.error?.stack || e.message)));
|
||||
</script>
|
||||
<script src="../../../tmp/spreadsheet_sortfilter/harness.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,85 @@
|
||||
/* 정렬 · 자동 필터 화면 시험 틀 입구 — 진짜 부품(createSpreadsheet)에 정렬 대화 상자 · 필터 단추를 얹음.
|
||||
* sub7 잇기 전이라 `parts.mountTabs` 를 감싸 문맥(ctx)을 꺼내고, grid.render 뒤 filter.refresh 를 부름.
|
||||
* 번들: node harness_sortfilter_bundle.cjs → tmp/spreadsheet_sortfilter/harness.js (git 밖) */
|
||||
import { createSpreadsheet, parts } from "../../../A00_Common/spreadsheet/spreadsheet";
|
||||
import {
|
||||
openSortDialog,
|
||||
sortSelection,
|
||||
} from "../../../A00_Common/spreadsheet/spreadsheet_sort_dialog";
|
||||
import { attachFilter, toggleFilter } from "../../../A00_Common/spreadsheet/spreadsheet_filter";
|
||||
import type { SpreadsheetContext } from "../../../A00_Common/spreadsheet/spreadsheet_view_types";
|
||||
import type { Workbook } from "../../../A00_Common/spreadsheet/spreadsheet_types";
|
||||
|
||||
const w = window as unknown as Record<string, unknown>;
|
||||
let ctx: SpreadsheetContext | null = null;
|
||||
const origTabs = parts.mountTabs;
|
||||
parts.mountTabs = (c: SpreadsheetContext) => {
|
||||
ctx = c;
|
||||
return origTabs(c);
|
||||
};
|
||||
|
||||
const rows: [string, string, number][] = [
|
||||
["터파기", "토사", 12.5],
|
||||
["되메우기", "토사", 3],
|
||||
["잡석", "", 7.25],
|
||||
["터파기", "풍화암", 4],
|
||||
["콘크리트", "18-21-8", 9.9],
|
||||
["되메우기", "토사", 0],
|
||||
["거푸집", "합판 3회", 21],
|
||||
["터파기", "토사", 1.5],
|
||||
];
|
||||
const 칸: Workbook["시트"][number]["칸"] = {
|
||||
A1: { 값: "공종" },
|
||||
B1: { 값: "규격" },
|
||||
C1: { 값: "수량" },
|
||||
D1: { 값: "두 배" },
|
||||
C12: { 식: "SUM(C2:C9)" },
|
||||
B12: { 값: "합계" },
|
||||
};
|
||||
rows.forEach(([a, b, c], i) => {
|
||||
const r = i + 2;
|
||||
칸[`A${r}`] = { 값: a };
|
||||
if (b) 칸[`B${r}`] = { 값: b };
|
||||
칸[`C${r}`] = { 값: c, 서식: 1 };
|
||||
칸[`D${r}`] = { 식: `C${r}*2`, 서식: 1 };
|
||||
});
|
||||
const book: Workbook = {
|
||||
종류: "통합문서",
|
||||
판: 1,
|
||||
열: "harness",
|
||||
서식: [{}, { 형식: "0.00" }],
|
||||
시트: [{ id: "s1", 이름: "정렬필터", 칸, 행: { 11: { 숨김: true } } }],
|
||||
활성: "s1",
|
||||
};
|
||||
|
||||
const host = document.getElementById("host")!;
|
||||
w.ss = createSpreadsheet(host, book, {
|
||||
onChange: () => (w.ssChanges = Number(w.ssChanges ?? 0) + 1),
|
||||
});
|
||||
const c = ctx as unknown as SpreadsheetContext;
|
||||
const filter = attachFilter(c);
|
||||
const render = c.grid.render.bind(c.grid);
|
||||
c.grid.render = () => {
|
||||
render();
|
||||
filter.refresh();
|
||||
};
|
||||
w.ssCtx = c;
|
||||
w.ssFilter = filter;
|
||||
w.ssSortDialog = () => openSortDialog(c);
|
||||
w.ssSortQuick = (desc: boolean) => sortSelection(c, desc);
|
||||
w.ssToggleFilter = () => toggleFilter(c);
|
||||
/** 열 값(보이는 칸 글) — 판정용 */
|
||||
w.ssCol = (letter: string, r0: number, r1: number) => {
|
||||
const out: string[] = [];
|
||||
for (let r = r0; r <= r1; r++)
|
||||
out.push(document.querySelector(`[data-a1="${letter}${r}"]`)?.textContent ?? "");
|
||||
return out;
|
||||
};
|
||||
for (const [id, run] of [
|
||||
["btn-sort", () => openSortDialog(c)],
|
||||
["btn-asc", () => sortSelection(c, false)],
|
||||
["btn-desc", () => sortSelection(c, true)],
|
||||
["btn-filter", () => toggleFilter(c)],
|
||||
["btn-undo", () => c.undo()],
|
||||
] as const)
|
||||
document.getElementById(id)!.addEventListener("click", run);
|
||||
@@ -0,0 +1,29 @@
|
||||
/* 정렬 · 필터 시험 틀 번들 — rolldown(이미 설치) 한 파일 · css 는 <style> 로 넣음 · @ui 별칭.
|
||||
* node harness_sortfilter_bundle.cjs → tmp/spreadsheet_sortfilter/harness.js (git 밖) */
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ROOT = path.join(__dirname, "..", "..", "..");
|
||||
const { build } = require(path.join(ROOT, "config", "node_modules", "rolldown"));
|
||||
|
||||
build({
|
||||
input: path.join(__dirname, "harness_sortfilter.ts"),
|
||||
resolve: { alias: { "@ui": path.join(ROOT, "ui_template") } },
|
||||
plugins: [
|
||||
{
|
||||
name: "css-inline",
|
||||
load(id) {
|
||||
if (!id.endsWith(".css")) return null;
|
||||
const css = JSON.stringify(fs.readFileSync(id, "utf8"));
|
||||
const code = `const s = document.createElement("style"); s.textContent = ${css}; document.head.append(s);`;
|
||||
return { code, moduleType: "js" };
|
||||
},
|
||||
},
|
||||
],
|
||||
output: { file: path.join(ROOT, "tmp", "spreadsheet_sortfilter", "harness.js"), format: "iife" },
|
||||
}).then(
|
||||
() => console.log("ok"),
|
||||
(e) => {
|
||||
console.error(e.message);
|
||||
process.exit(1);
|
||||
},
|
||||
);
|
||||
@@ -93,6 +93,10 @@ const out = {
|
||||
highlights: ed
|
||||
.refHighlights("=A1+B2:C3*A1+치수!B1", "s1", (n) => (n === "치수" ? "s2" : null))
|
||||
.map((h) => `${h.시트}:${addr.rangeToA1(h.범위)}:${h.색번호}`),
|
||||
// 함수 자동 완성 · 인자 도움말 자리
|
||||
hint: ["=su", "=A1+ro", "=A1", "=ROUND(B5,", "=SUM(A1,MAX(1,2),", '=IF(A1=",",', "abc"].map((t) =>
|
||||
ed.formulaHint(t),
|
||||
),
|
||||
// 채우기 끌기 목표
|
||||
fill: [[{ r: 9, c: 1 }], [{ r: 2, c: 6 }], [{ r: 3, c: 1 }], [{ r: 2, c: 1 }]].map(([p]) => {
|
||||
const t = mouse.fillTarget({ r0: 1, c0: 1, r1: 3, c1: 2 }, p);
|
||||
|
||||
@@ -99,7 +99,7 @@ const H = load("spreadsheet_history");
|
||||
const RC = load("spreadsheet_recalc");
|
||||
|
||||
// 무게 시험(`bench_spreadsheet_engine.cjs`)이 같은 적재 · 가짜 함수를 씀 — 불려 올 때는 시험을 안 돌림
|
||||
module.exports = { A, C, E, F, G };
|
||||
module.exports = { A, C, E, F, G, load };
|
||||
if (require.main !== module) return;
|
||||
|
||||
const results = {};
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
/* 정렬 · 자동 필터 엔진 시험 헬퍼 — 엑셀 규칙표를 묶음마다 돌려 틀린 것 목록을 JSON 으로 냄.
|
||||
* 적재 · 가짜 함수는 `helper_spreadsheet_engine.cjs` 것을 씀.
|
||||
*/
|
||||
const { A, C, E, F, G, load } = require("./helper_spreadsheet_engine.cjs");
|
||||
const SF = load("spreadsheet_sortfilter");
|
||||
|
||||
const results = {};
|
||||
let group = "";
|
||||
const eq = (label, got, want) => {
|
||||
const g = JSON.stringify(got);
|
||||
const w = JSON.stringify(want);
|
||||
if (g !== w) results[group].push(`${label}: got ${g} want ${w}`);
|
||||
};
|
||||
const section = (name, fn) => {
|
||||
group = name;
|
||||
results[name] = [];
|
||||
try {
|
||||
fn();
|
||||
} catch (e) {
|
||||
results[name].push(`예외: ${e && e.stack ? e.stack : e}`);
|
||||
}
|
||||
};
|
||||
const canon = (v) =>
|
||||
Array.isArray(v)
|
||||
? v.map(canon)
|
||||
: v && typeof v === "object"
|
||||
? Object.fromEntries(Object.keys(v).sort().map((k) => [k, canon(v[k])]))
|
||||
: v;
|
||||
|
||||
function book(cells, extra = {}) {
|
||||
const 칸 = {};
|
||||
for (const [k, v] of Object.entries(cells))
|
||||
칸[k] = typeof v === "string" && v.startsWith("=") ? { 식: v.slice(1) } : { 값: v };
|
||||
return { 종류: "통합문서", 판: 1, 열: "t", 서식: [{}], 시트: [{ id: "s1", 이름: "본", 칸, ...extra }] };
|
||||
}
|
||||
const show = (v) => (v === null ? null : E.isError(v) ? v.error : E.isFrac(v) ? F.fracToString(v) : v);
|
||||
const col = (b, letter, r0, r1) => {
|
||||
const e = G.createCalcEngine(b);
|
||||
const out = [];
|
||||
for (let r = r0; r <= r1; r++) out.push(show(e.value("s1", r - 1, A.colIndex(letter))));
|
||||
return out;
|
||||
};
|
||||
const R = A.parseRange;
|
||||
|
||||
/** 정렬 → 명령 적용 → 되돌림이 처음과 같은지 · 다시 하면 같은 결과인지 */
|
||||
function sortAndCheck(label, b, range, opts) {
|
||||
const e = G.createCalcEngine(b);
|
||||
const cmd = SF.sortCommand(b, e, "s1", R(range), opts);
|
||||
const before = JSON.stringify(canon(b.시트));
|
||||
const fx = C.applyCommand(b, cmd);
|
||||
const after = JSON.stringify(canon(b.시트));
|
||||
C.applyCommand(b, fx.undo);
|
||||
eq(`${label} 되돌림`, JSON.stringify(canon(b.시트)) === before, true);
|
||||
C.applyCommand(b, cmd);
|
||||
eq(`${label} 다시`, JSON.stringify(canon(b.시트)) === after, true);
|
||||
return cmd;
|
||||
}
|
||||
|
||||
// ── 1. 정렬 차례 ────────────────────────────────────────────────────────────
|
||||
section("sort_order", () => {
|
||||
const mixed = () =>
|
||||
book({
|
||||
A1: "값",
|
||||
B1: "번호",
|
||||
A2: 3, B2: 1,
|
||||
A3: "b", B3: 2,
|
||||
A4: true, B4: 3,
|
||||
A5: "A", B5: 4,
|
||||
A6: 1, B6: 5,
|
||||
B7: 6, // 빈 칸
|
||||
A8: "=1/0", B8: 7,
|
||||
A9: false, B9: 8,
|
||||
A10: "a", B10: 9,
|
||||
A11: 2, B11: 10,
|
||||
});
|
||||
let b = mixed();
|
||||
sortAndCheck("오름", b, "A1:B11", { 머리: true, 기준: [{ 열: 0 }] });
|
||||
eq("오름 = 수 < 글 < 참거짓 < 오류 < 빈 칸 · 같은 값은 원래 차례", col(b, "B", 2, 11), ["5", "10", "1", "4", "9", "2", "8", "3", "7", "6"]);
|
||||
eq("머리 행 제자리", col(b, "A", 1, 1), ["값"]);
|
||||
b = mixed();
|
||||
sortAndCheck("내림", b, "A1:B11", { 머리: true, 기준: [{ 열: 0, 내림: true }] });
|
||||
eq("내림 = 거꾸로 · 빈 칸은 그래도 맨 뒤", col(b, "B", 2, 11), ["7", "3", "8", "2", "4", "9", "1", "10", "5", "6"]);
|
||||
b = mixed();
|
||||
sortAndCheck("대소문자", b, "A1:B11", { 머리: true, 기준: [{ 열: 0 }], 대소문자: true });
|
||||
eq("대소문자 가리면 소문자 먼저", col(b, "B", 5, 6), ["9", "4"]);
|
||||
// 여러 기준 · 머리 없음
|
||||
b = book({ A1: "나", B1: 1, A2: "가", B2: 2, A3: "나", B3: 3, A4: "가", B4: 1, A5: 10, B5: 9, A6: 9, B6: 9 });
|
||||
sortAndCheck("여러 기준", b, "A1:B6", { 머리: false, 기준: [{ 열: 0 }, { 열: 1, 내림: true }] });
|
||||
eq("첫 기준 오름 · 둘째 내림", [col(b, "A", 1, 6), col(b, "B", 1, 6)], [
|
||||
["9", "10", "가", "가", "나", "나"],
|
||||
["9", "9", "2", "1", "3", "1"],
|
||||
]);
|
||||
eq("현재 영역", SF.currentRegion(mixed(), "s1", 4, 0), R("A1:B11"));
|
||||
eq("현재 영역 빈 칸 너머는 안 감", SF.currentRegion(book({ A1: 1, A2: 2, C1: 3 }), "s1", 0, 0), R("A1:A2"));
|
||||
});
|
||||
|
||||
// ── 2. 정렬 뒤 수식 · 서식 · 메모 (엑셀 규칙) ────────────────────────────────
|
||||
section("sort_formulas", () => {
|
||||
const b = book(
|
||||
{
|
||||
A1: 3, B1: "=A1*10", C1: "=$A$1+A1",
|
||||
A2: 1, B2: "=A2*10", C2: "=$A$1+A2",
|
||||
A3: 2, B3: "=A3*10", C3: "=$A$1+A3",
|
||||
E1: "=SUM(A1:A3)", E2: "=A1",
|
||||
},
|
||||
{ comments: { A1: "셋", A3: "둘" } },
|
||||
);
|
||||
b.시트[0].칸.A2.서식 = 0;
|
||||
b.서식.push({ 굵게: true });
|
||||
b.시트[0].칸.A1.서식 = 1;
|
||||
sortAndCheck("식 옮김", b, "A1:C3", { 머리: false, 기준: [{ 열: 0 }] });
|
||||
const s = b.시트[0].칸;
|
||||
eq("상대 참조는 복사처럼 · 절대는 그대로", [s.B1.식, s.B3.식, s.C1.식, s.C3.식], ["A1*10", "A3*10", "$A$1+A1", "$A$1+A3"]);
|
||||
eq("범위 밖 식은 그대로", [s.E1.식, s.E2.식], ["SUM(A1:A3)", "A1"]);
|
||||
eq("값", col(b, "B", 1, 3), ["10", "20", "30"]);
|
||||
eq("E2 는 새 A1 을 봄", col(b, "E", 2, 2), ["1"]);
|
||||
eq("서식 · 메모 따라감", [s.A3.서식, b.시트[0].comments], [1, { A2: "둘", A3: "셋" }]);
|
||||
});
|
||||
|
||||
// ── 3. 정렬 막힘 · 걸러진 행 제자리 ─────────────────────────────────────────
|
||||
section("sort_rules", () => {
|
||||
const b = book({ A1: 3, A2: 1, A3: 2 }, { 병합: ["B2:C2"] });
|
||||
const e = G.createCalcEngine(b);
|
||||
let msg = null;
|
||||
try {
|
||||
SF.sortCommand(b, e, "s1", R("A1:C3"), { 머리: false, 기준: [{ 열: 0 }] });
|
||||
} catch (err) {
|
||||
msg = err instanceof C.CommandError;
|
||||
}
|
||||
eq("병합 걸치면 막음", msg, true);
|
||||
msg = null;
|
||||
try {
|
||||
SF.sortCommand(b, e, "s1", R("A1:A3"), { 머리: false, 기준: [{ 열: 3 }] });
|
||||
} catch (err) {
|
||||
msg = err instanceof C.CommandError;
|
||||
}
|
||||
eq("기준 열이 범위 밖", msg, true);
|
||||
msg = null;
|
||||
try {
|
||||
C.applyCommand(b, { 종류: "정렬", 시트: "s1", 범위: R("A1:A3"), 차례: [0, 0, 1] });
|
||||
} catch (err) {
|
||||
msg = err instanceof C.CommandError;
|
||||
}
|
||||
eq("차례가 틀리면 막음", msg, true);
|
||||
const f = book({ A1: 5, A2: 4, A3: 3, A4: 2, A5: 1 }, { 행: { 2: { 걸러짐: true }, 4: { 걸러짐: true, 높이: 30 } } });
|
||||
sortAndCheck("걸러진 행", f, "A1:A5", { 머리: false, 기준: [{ 열: 0 }] });
|
||||
eq("보이는 행끼리만 · 걸러진 행 · 높이 제자리", [col(f, "A", 1, 5), f.시트[0].행], [["1", "4", "3", "2", "5"], { 2: { 걸러짐: true }, 4: { 걸러짐: true, 높이: 30 } }]);
|
||||
});
|
||||
|
||||
// ── 4. 자동 필터 ────────────────────────────────────────────────────────────
|
||||
section("filter", () => {
|
||||
const make = () => {
|
||||
const b = book({
|
||||
A1: "공종", B1: "수량",
|
||||
A2: "터파기", B2: 1.5,
|
||||
A3: "되메우기", B3: 0,
|
||||
A4: "터파기", B4: 2,
|
||||
B5: 3,
|
||||
A6: "잡석", B6: "=B2",
|
||||
D1: "=SUM(B2:B6)",
|
||||
}, { 행: { 3: { 숨김: true } } });
|
||||
b.서식.push({ 형식: "0.00" });
|
||||
for (let r = 2; r <= 6; r++) b.시트[0].칸[`B${r}`].서식 = 1;
|
||||
return b;
|
||||
};
|
||||
const b = make();
|
||||
let e = G.createCalcEngine(b);
|
||||
C.applyCommand(b, SF.filterCommand(b, e, "s1", { 범위: "A1:B6" }));
|
||||
eq("켬 = 걸러짐 없음", [b.시트[0].필터, b.시트[0].행], [{ 범위: "A1:B6" }, { 3: { 숨김: true } }]);
|
||||
eq("값 목록 · 가나다 · 빈 칸 맨 뒤", SF.filterItems(b, e, "s1", 0), [
|
||||
{ 글: "되메우기", 켬: true },
|
||||
{ 글: "잡석", 켬: true },
|
||||
{ 글: "터파기", 켬: true },
|
||||
{ 글: "", 켬: true },
|
||||
]);
|
||||
eq("수 열 = 보이는 글(형식) · 수 차례", SF.filterItems(b, e, "s1", 1).map((x) => x.글), ["0.00", "1.50", "2.00", "3.00"]);
|
||||
const on = SF.filterCommand(b, e, "s1", { 범위: "A1:B6", 조건: { A: ["터파기", ""] } });
|
||||
eq("걸러질 행", on.걸러짐, [2, 5]);
|
||||
const before = JSON.stringify(canon(b.시트));
|
||||
const fx = C.applyCommand(b, on);
|
||||
eq("걸러짐 · 사용자 숨김은 따로", b.시트[0].행, { 3: { 숨김: true, 걸러짐: true }, 6: { 걸러짐: true } });
|
||||
eq("목록 켬 = 조건", SF.filterItems(b, e, "s1", 0).map((x) => x.켬), [false, false, true, true]);
|
||||
eq("걸러도 값 · 합계 그대로", col(b, "D", 1, 1), ["8"]);
|
||||
C.applyCommand(b, fx.undo);
|
||||
eq("필터 조건 되돌림", JSON.stringify(canon(b.시트)) === before, true);
|
||||
C.applyCommand(b, on);
|
||||
const two = SF.filterCommand(b, e, "s1", { 범위: "A1:B6", 조건: { A: ["터파기", ""], B: ["2.00", "3.00"] } });
|
||||
eq("두 열 조건 = 모두 맞아야", two.걸러짐, [1, 2, 5]);
|
||||
// 행 넣기 · 열 지우기 때 필터 따라감
|
||||
C.applyCommand(b, { 종류: "행넣기", 시트: "s1", at: 3, 수: 1 });
|
||||
eq("행 넣기 = 범위 늘어남 · 걸러짐 밀림", [b.시트[0].필터.범위, b.시트[0].행["7"]], ["A1:B7", { 걸러짐: true }]);
|
||||
C.applyCommand(b, { 종류: "열지우기", 시트: "s1", at: 0, 수: 1 });
|
||||
eq("조건 열을 지우면 조건 빠짐", b.시트[0].필터, { 범위: "A1:A7", 조건: {} });
|
||||
C.applyCommand(b, { 종류: "행지우기", 시트: "s1", at: 0, 수: 1 });
|
||||
eq("머리 행 지우면 필터 없어짐", b.시트[0].필터, undefined);
|
||||
// 끄기 — 걸러짐 풀리고 사용자 숨김은 남음
|
||||
const c = make();
|
||||
e = G.createCalcEngine(c);
|
||||
C.applyCommand(c, SF.filterCommand(c, e, "s1", { 범위: "A1:B6", 조건: { A: ["잡석"] } }));
|
||||
const off = C.applyCommand(c, SF.filterCommand(c, e, "s1", null));
|
||||
eq("끄기", [c.시트[0].필터, c.시트[0].행], [undefined, { 3: { 숨김: true } }]);
|
||||
C.applyCommand(c, off.undo);
|
||||
eq("끄기 되돌림", c.시트[0].행["2"], { 걸러짐: true });
|
||||
});
|
||||
|
||||
process.stdout.write(JSON.stringify(results));
|
||||
@@ -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)",
|
||||
|
||||
@@ -438,7 +438,11 @@ function createGrid(ctx) {
|
||||
const r0 = Math.floor(root.scrollTop / H);
|
||||
return { r0, r1: r0 + Math.floor(root.clientHeight / H) - 2, c0: 0, c1: COLS - 1 };
|
||||
},
|
||||
editorSlot: (r, c) => ({ layer: content, box: cellBox(r, c) }),
|
||||
// C 와 같은 꼴 — box 는 격자 뿌리 기준(스크롤 뺌)
|
||||
editorSlot: (r, c) => {
|
||||
const b = cellBox(r, c);
|
||||
return { layer: content, box: { ...b, x: b.x - root.scrollLeft, y: b.y - root.scrollTop } };
|
||||
},
|
||||
destroy: () => root.remove(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -81,3 +81,15 @@ def test_fill_target(r):
|
||||
["B2:B4", "C2:C4"],
|
||||
["B2:C3", "B4:C4"],
|
||||
]
|
||||
|
||||
|
||||
def test_formula_hint(r):
|
||||
assert r["hint"] == [
|
||||
{"prefix": "su"},
|
||||
{"prefix": "ro"},
|
||||
None,
|
||||
{"func": "ROUND", "arg": 1},
|
||||
{"func": "SUM", "arg": 2},
|
||||
{"func": "IF", "arg": 1},
|
||||
None,
|
||||
]
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""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.
|
||||
엔진 = 엔진 파일만 따로 묶은 크기 · 합 = 실제 앱 빌드에서 스프레드시트를 열 때 받는 조각
|
||||
(공용 조각까지 넉넉히 셈) · 화면 = 합 − 엔진.
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -15,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 가 없음")
|
||||
|
||||
@@ -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']}"
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""산출근거 스프레드시트 정렬 · 자동 필터 엔진 — 엑셀 규칙표.
|
||||
|
||||
정렬 차례(수 < 글 < 참거짓 < 오류 · 빈 칸 늘 맨 뒤 · 같은 값 원래 차례 · 여러 기준 · 대소문자) ·
|
||||
정렬 뒤 식(복사처럼 상대 참조만 · 범위 밖 식은 그대로) · 서식 · 메모 · 걸러진 행 제자리 · 병합 막음 ·
|
||||
필터 값 목록(보이는 글 · 빈 칸 맨 뒤) · 걸러짐(사용자 숨김과 따로) · 행열 넣기 · 지우기 때 필터 따라감 · 되돌리기.
|
||||
화면(대화 상자 · 필터 단추)은 harness_sortfilter.html 을 ORCA 로.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
GROUPS = ["sort_order", "sort_formulas", "sort_rules", "filter"]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def results():
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_spreadsheet_sortfilter.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=120,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("group", GROUPS)
|
||||
def test_묶음(results, group):
|
||||
assert group in results
|
||||
assert results[group] == []
|
||||
Reference in New Issue
Block a user