From e1fc529f45c53e519314bd293a256ddc1d4599be Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 27 Sep 2026 21:10:46 +0900 Subject: [PATCH] =?UTF-8?q?feat(spreadsheet):=202=EB=8B=A8=EA=B3=84=20?= =?UTF-8?q?=E2=80=94=20=EC=85=80=20=EC=84=9C=EC=8B=9D=20=EB=8C=80=ED=99=94?= =?UTF-8?q?=EC=83=81=EC=9E=90(Ctrl+1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 엑셀과 같은 탭 여섯 — 표시 형식(범주·소수 자리·천 단위·음수 모양·사용자 지정 코드·미리보기, B의 numfmt로 실제 그림) · 맞춤(가로·세로·줄 바꿈·셀 병합·들여쓰기는 자리만) · 글꼴(실시간 미리보기) · 테두리(선 모양·색·위치별 적용·미리보기) · 채우기 · 보호(브레인 지시대로 자리만). [확인] 누를 때 서식 명령 하나(+ 병합 바뀌면 병합·병합풀기)로 나감 — 도구 모음처럼 바로 안 바꿈. 표시 형식 코드 짓기는 DOM 없는 별도 파일(spreadsheet_format_code.ts)로 빼서 시험 가능하게 함 (대화상자 자체는 이 저장소에 jsdom 이 없어 단위 시험 밖 — Ctrl+1 을 이은 뒤 ORCA 로 확인할 것). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01K7JS47SacjPxZ718QsZKyr --- .../spreadsheet/spreadsheet_format_code.ts | 58 ++ .../spreadsheet/spreadsheet_format_dialog.css | 145 +++++ .../spreadsheet/spreadsheet_format_dialog.ts | 513 ++++++++++++++++++ .../spreadsheet/test_stage2_format_code.ts | 72 +++ 4 files changed, 788 insertions(+) create mode 100644 A00_Common/spreadsheet/spreadsheet_format_code.ts create mode 100644 A00_Common/spreadsheet/spreadsheet_format_dialog.css create mode 100644 A00_Common/spreadsheet/spreadsheet_format_dialog.ts create mode 100644 resources/tester/spreadsheet/test_stage2_format_code.ts diff --git a/A00_Common/spreadsheet/spreadsheet_format_code.ts b/A00_Common/spreadsheet/spreadsheet_format_code.ts new file mode 100644 index 00000000..93ed11ae --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_format_code.ts @@ -0,0 +1,58 @@ +/* ============================================================================= + * spreadsheet_format_code.ts (2단계 · 주인 sub_laptop_1) + * 셀 서식 대화상자(표시 형식 탭)의 범주 → 형식 코드 짓기 — DOM · CSS 없음(시험이 바로 부름). + * 실무 자주 쓰는 모양만(일반 · 숫자 · 통화 · 회계 · 백분율 · 텍스트 · 사용자 지정). + * ========================================================================== */ + +export const CATEGORIES = [ + "일반", + "숫자", + "통화", + "회계", + "백분율", + "텍스트", + "사용자 지정", +] as const; +export type Category = (typeof CATEGORIES)[number]; +export type NegStyle = "minus" | "paren" | "red-minus" | "red-paren"; + +/** 범주 · 소수 자릿수 · 천단위 · 음수 표시로 형식 코드를 지음. */ +export function buildCode( + cat: Category, + decimals: number, + thousands: boolean, + neg: NegStyle, +): string { + if (cat === "일반") return ""; + if (cat === "텍스트") return "@"; + const digits = decimals > 0 ? `0.${"0".repeat(decimals)}` : "0"; + const grouped = thousands ? `#,##${digits}` : digits; + if (cat === "백분율") return `${grouped}%`; + const prefix = cat === "통화" ? '"₩"' : ""; + const positive = `${prefix}${grouped}`; + if (cat === "회계") { + const acc = decimals > 0 ? "0.00" : "0"; + return `_-* #,##${acc}_-;-* #,##${acc}_-;_-* "-"_-;_-@_-`; + } + switch (neg) { + case "minus": + return positive; + case "paren": + return `${positive};(${positive})`; + case "red-minus": + return `${positive};[Red]-${positive}`; + case "red-paren": + return `${positive};[Red](${positive})`; + } +} + +/** 있는 형식 코드를 보고 범주를 어림(대화상자를 열 때 초기 범주 고르기). */ +export function guessCategory(code: string | undefined): Category { + if (!code) return "일반"; + if (code === "@") return "텍스트"; + if (code.includes("%")) return "백분율"; + if (code.includes("_-*")) return "회계"; + if (code.includes("₩")) return "통화"; + if (/[0#]/.test(code)) return "숫자"; + return "사용자 지정"; +} diff --git a/A00_Common/spreadsheet/spreadsheet_format_dialog.css b/A00_Common/spreadsheet/spreadsheet_format_dialog.css new file mode 100644 index 00000000..9d48ee59 --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_format_dialog.css @@ -0,0 +1,145 @@ +/* ============================================================================= + * spreadsheet_format_dialog.css (2단계 · 주인 sub_laptop_1) + * 셀 서식 대화상자 — 뿌리 배경(`.ui-confirm`)은 공용 확인 모달과 같은 것을 그대로 씀. + * 색 · 간격은 테마 토큰만(`ui_template_theme.css`). + * ========================================================================== */ + +.ss-fmtdlg__panel { + display: flex; + flex-direction: column; + width: min(560px, 92vw); + max-height: 84vh; + padding: var(--spacing-16); + border-radius: var(--radius-cards); + background: var(--color-surface-raised); + box-shadow: var(--shadow-lg); +} + +.ss-fmtdlg__title { + margin: 0 0 var(--spacing-8); + font-size: 1rem; + color: var(--color-text-body); +} + +.ss-fmtdlg__tabs { + display: flex; + flex-wrap: wrap; + gap: 2px; + border-bottom: 1px solid var(--color-border); +} + +.ss-fmtdlg__tabbtn { + padding: 6px 12px; + border: 1px solid var(--color-border); + border-bottom: 0; + border-radius: var(--radius-sm) var(--radius-sm) 0 0; + background: var(--color-paper); + color: var(--color-text-secondary); + font-size: 0.82rem; + cursor: pointer; +} + +.ss-fmtdlg__tabbtn.is-active { + background: var(--color-canvas); + color: var(--color-text-body); + font-weight: 600; +} + +.ss-fmtdlg__body { + flex: 1; + overflow-y: auto; + padding: var(--spacing-12) 2px; +} + +.ss-fmtdlg__tab { + display: flex; + flex-direction: column; + gap: var(--spacing-8); +} + +.ss-fmtdlg__row { + display: flex; + align-items: center; + gap: var(--spacing-8); +} + +.ss-fmtdlg__label { + width: 140px; + flex-shrink: 0; + color: var(--color-text-secondary); + font-size: 0.82rem; +} + +.ss-fmtdlg__row select, +.ss-fmtdlg__row input[type="text"], +.ss-fmtdlg__row input[type="number"], +.ss-fmtdlg__code { + height: 28px; + padding: 0 6px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-canvas); + color: var(--color-text-body); + font-size: 0.82rem; +} + +.ss-fmtdlg__code { + flex: 1; +} + +.ss-fmtdlg__subrows { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + padding-left: var(--spacing-16); +} + +.ss-fmtdlg__preview-text { + padding: 4px 8px; + border: 1px dashed var(--color-border); + border-radius: var(--radius-sm); + font-family: monospace; +} + +.ss-fmtdlg__font-sample { + margin-top: var(--spacing-8); + padding: var(--spacing-12); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-canvas); +} + +.ss-fmtdlg__border-presets { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-4); +} + +.ss-fmtdlg__border-preview { + width: 96px; + height: 64px; + margin-top: var(--spacing-8); + background: var(--color-canvas); +} + +.ss-fmtdlg__fill-swatch { + width: 96px; + height: 40px; + margin-top: var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); +} + +.ss-fmtdlg__note { + margin: 0 0 var(--spacing-8); + color: var(--color-text-muted); + font-size: 0.8rem; +} + +.ss-fmtdlg__actions { + display: flex; + justify-content: flex-end; + gap: var(--spacing-8); + padding-top: var(--spacing-12); + border-top: 1px solid var(--color-border); +} diff --git a/A00_Common/spreadsheet/spreadsheet_format_dialog.ts b/A00_Common/spreadsheet/spreadsheet_format_dialog.ts new file mode 100644 index 00000000..65d67581 --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_format_dialog.ts @@ -0,0 +1,513 @@ +/* ============================================================================= + * spreadsheet_format_dialog.ts (2단계 · 주인 sub_laptop_1) + * 셀 서식 대화상자(Ctrl+1) — 엑셀과 같은 탭 여섯: 표시 형식 · 맞춤 · 글꼴 · 테두리 · 채우기 · 보호. + * [확인] 을 누를 때까지 아무 것도 안 바꿈(도구 모음처럼 칸마다 바로 안 바꿈) — 눌러야 + * 「서식」 명령 하나(+ 병합이 바뀌었으면 「병합」·「병합풀기」)로 한 번에 나감. + * + * 들여쓰기 · 보호(잠금·숨김)는 화면 자리만 — 0 계약 `CellStyle`·`Command` 에 그 칸이 없어 + * 실제로는 안 바뀜(보호는 브레인 지시대로 「자리만」). + * + * 잇는 법(D · sub7) — Ctrl+1(`spreadsheet_shortcuts.ts` 의 `openFormatDialog`) 을 + * `spreadsheet_keys.ts` navKey 에 새 case 로 더해 `openFormatDialog(ctx)` 를 부름. + * 우클릭 메뉴 [셀 서식] 도 같은 함수를 부르면 됨(`spreadsheet_menu.ts`). + * ========================================================================== */ + +import { createButton, el } from "@ui/ui_template_elements"; +import { toFrac } from "@ui/sheet/ui_template_sheet_frac"; +import { colName, rangeToA1, toA1 } from "./spreadsheet_address"; +import { + buildCode, + CATEGORIES, + guessCategory, + type Category, + type NegStyle, +} from "./spreadsheet_format_code"; +import { formatValue } from "./spreadsheet_numfmt"; +import type { BorderLine, BorderSide, CellStyle, HAlign, VAlign } from "./spreadsheet_types"; +import type { SpreadsheetContext } from "./spreadsheet_view_types"; +import "./spreadsheet_format_dialog.css"; + +const TEXT = { + title: "셀 서식", + tabNumber: "표시 형식", + tabAlign: "맞춤", + tabFont: "글꼴", + tabBorder: "테두리", + tabFill: "채우기", + tabProtect: "보호", + ok: "확인", + cancel: "취소", + category: "범주", + decimals: "소수 자릿수", + thousands: "천 단위 구분 기호 사용", + negative: "음수 표시 형식", + customCode: "사용자 지정 형식 코드", + preview: "보기", + hAlign: "가로", + vAlign: "세로", + wrap: "줄 바꿈", + merge: "셀 병합", + indent: "들여쓰기", + fontName: "글꼴", + fontSize: "크기", + bold: "굵게", + italic: "기울임", + underline: "밑줄", + strike: "취소선", + fontColor: "글자색", + lineStyle: "선 모양", + lineColor: "선 색", + none: "없음", + all: "모두", + top: "위", + bottom: "아래", + left: "왼", + right: "오른", + fillColor: "채움색", + clear: "지움", + protectNote: "칸 잠금 · 숨김은 자리만 둠(시트 보호 자체가 아직 없음)", + lock: "잠금", + hidden: "수식 숨김", +}; + +/** 활성 칸의 실제 서식 — 칸 → 행 → 열 → 시트 기본 → 0(계약 주석과 같은 차례). */ +function styleAt(ctx: SpreadsheetContext, r: number, c: number): CellStyle { + const sheet = ctx.sheet(); + const cell = sheet.칸[toA1(r, c)]; + const idx = cell?.서식 ?? sheet.행?.[String(r + 1)]?.서식 ?? sheet.열?.[colName(c)]?.서식 ?? 0; + return ctx.book.서식[idx] ?? {}; +} + +function row(label: string, ...controls: HTMLElement[]): HTMLElement { + return el("div", { + className: "ss-fmtdlg__row", + children: [el("span", { className: "ss-fmtdlg__label", text: label }), ...controls], + }); +} + +function borderCss(side?: BorderSide): string { + if (!side) return "none"; + const color = side.색 ?? "#000000"; + const width = side.선 === "medium" ? 2 : side.선 === "thick" || side.선 === "double" ? 3 : 1; + const style = side.선 === "hair" ? "dotted" : side.선 === "double" ? "double" : side.선; + return `${width}px ${style} ${color}`; +} + +export function openFormatDialog(ctx: SpreadsheetContext): void { + if (ctx.readOnly) return; + const range = ctx.selection.범위[0]; + if (!range) return; + const at = ctx.selection.활성; + const base = styleAt(ctx, at.r, at.c); + const draft: CellStyle = structuredClone(base); + const spansMany = range.r0 !== range.r1 || range.c0 !== range.c1; + let mergeWanted = (ctx.sheet().병합 ?? []).includes(rangeToA1(range)); + + const overlay = el("div", { className: "ui-confirm ss-fmtdlg" }); + const panel = el("div", { className: "ss-fmtdlg__panel" }); + const tabsBar = el("div", { className: "ss-fmtdlg__tabs" }); + const body = el("div", { className: "ss-fmtdlg__body" }); + overlay.append(panel); + panel.append(el("h2", { className: "ss-fmtdlg__title", text: TEXT.title }), tabsBar, body); + + const close = (): void => overlay.remove(); + overlay.addEventListener("click", (ev) => { + if (ev.target === overlay) close(); + }); + + // ── 표시 형식 ──────────────────────────────────────────────────────── + let category: Category = guessCategory(draft.형식); + let decimals = (draft.형식?.match(/\.0+/)?.[0].length ?? 1) - 1; + let thousands = draft.형식?.includes(",") ?? false; + let negStyle: NegStyle = draft.형식?.includes("[Red]") + ? draft.형식.includes("(") + ? "red-paren" + : "red-minus" + : draft.형식?.includes(";(") + ? "paren" + : "minus"; + let customCode = draft.형식 ?? ""; + let useCustom = category === "사용자 지정"; + + function numberTab(): HTMLElement { + const catSelect = el("select", { + children: CATEGORIES.map((c) => el("option", { attrs: { value: c }, text: c })), + }) as HTMLSelectElement; + catSelect.value = category; + + const decimalsInput = el("input", { + attrs: { type: "number", min: "0", max: "10" }, + }) as HTMLInputElement; + decimalsInput.value = String(Math.max(0, decimals)); + + const thousandsCheck = el("input", { attrs: { type: "checkbox" } }) as HTMLInputElement; + thousandsCheck.checked = thousands; + + const negSelect = el("select", { + children: [ + el("option", { attrs: { value: "minus" }, text: "-1,234.00" }), + el("option", { attrs: { value: "red-minus" }, text: "-1,234.00(빨강)" }), + el("option", { attrs: { value: "paren" }, text: "(1,234.00)" }), + el("option", { attrs: { value: "red-paren" }, text: "(1,234.00)(빨강)" }), + ], + }) as HTMLSelectElement; + negSelect.value = negStyle; + + const customInput = el("input", { className: "ss-fmtdlg__code" }) as HTMLInputElement; + customInput.value = customCode; + + const previewText = el("span", { className: "ss-fmtdlg__preview-text" }); + + const numberRows = el("div", { className: "ss-fmtdlg__subrows" }); + const customRow = row(TEXT.customCode, customInput); + + function syncControls(): void { + const showsNumberControls = + category === "숫자" || category === "통화" || category === "백분율"; + numberRows.hidden = !showsNumberControls; + customRow.hidden = category !== "사용자 지정"; + decimalsInput.disabled = category === "회계" ? false : !showsNumberControls; + } + + function refreshPreview(): void { + const code = useCustom ? customCode : buildCode(category, decimals, thousands, negStyle); + draft.형식 = code || undefined; + const cellValue = ctx.engine.value(ctx.selection.시트, at.r, at.c); + const sample = cellValue ?? toFrac(-1234.5); + previewText.textContent = formatValue(sample, code).글; + } + + catSelect.addEventListener("change", () => { + category = catSelect.value as Category; + useCustom = category === "사용자 지정"; + syncControls(); + refreshPreview(); + }); + decimalsInput.addEventListener("input", () => { + decimals = Math.max(0, Math.min(10, Number(decimalsInput.value) || 0)); + refreshPreview(); + }); + thousandsCheck.addEventListener("change", () => { + thousands = thousandsCheck.checked; + refreshPreview(); + }); + negSelect.addEventListener("change", () => { + negStyle = negSelect.value as NegStyle; + refreshPreview(); + }); + customInput.addEventListener("input", () => { + customCode = customInput.value; + refreshPreview(); + }); + + numberRows.append( + row(TEXT.decimals, decimalsInput), + row(TEXT.thousands, thousandsCheck), + row(TEXT.negative, negSelect), + ); + syncControls(); + refreshPreview(); + + return el("div", { + className: "ss-fmtdlg__tab", + children: [ + row(TEXT.category, catSelect), + numberRows, + customRow, + row(TEXT.preview, previewText), + ], + }); + } + + // ── 맞춤 ──────────────────────────────────────────────────────────── + function alignTab(): HTMLElement { + const hSelect = el("select", { + children: [ + ["general", "일반"], + ["left", "왼쪽"], + ["center", "가운데"], + ["right", "오른쪽"], + ["distributed", "균등 분할"], + ["centerContinuous", "선택 영역 가운데"], + ].map(([v, t]) => el("option", { attrs: { value: v }, text: t })), + }) as HTMLSelectElement; + hSelect.value = draft.가로 ?? "general"; + hSelect.addEventListener("change", () => (draft.가로 = hSelect.value as HAlign)); + + const vSelect = el("select", { + children: [ + ["top", "위"], + ["center", "가운데"], + ["bottom", "아래"], + ].map(([v, t]) => el("option", { attrs: { value: v }, text: t })), + }) as HTMLSelectElement; + vSelect.value = draft.세로 ?? "bottom"; + vSelect.addEventListener("change", () => (draft.세로 = vSelect.value as VAlign)); + + const wrapCheck = el("input", { attrs: { type: "checkbox" } }) as HTMLInputElement; + wrapCheck.checked = !!draft.줄바꿈; + wrapCheck.addEventListener("change", () => (draft.줄바꿈 = wrapCheck.checked)); + + const mergeCheck = el("input", { + attrs: { type: "checkbox", ...(spansMany ? {} : { disabled: "true" }) }, + }) as HTMLInputElement; + mergeCheck.checked = mergeWanted; + mergeCheck.addEventListener("change", () => (mergeWanted = mergeCheck.checked)); + + const indentInput = el("input", { + attrs: { type: "number", min: "0", max: "15", disabled: "true", title: "0 계약 밖 — 자리만" }, + }) as HTMLInputElement; + indentInput.value = "0"; + + return el("div", { + className: "ss-fmtdlg__tab", + children: [ + row(TEXT.hAlign, hSelect), + row(TEXT.vAlign, vSelect), + row(TEXT.wrap, wrapCheck), + row(TEXT.merge, mergeCheck), + row(TEXT.indent, indentInput), + ], + }); + } + + // ── 글꼴 ──────────────────────────────────────────────────────────── + function fontTab(): HTMLElement { + const nameInput = el("input") as HTMLInputElement; + nameInput.value = draft.글꼴 ?? ""; + const sizeInput = el("input", { + attrs: { type: "number", min: "1", max: "409", step: "0.5" }, + }) as HTMLInputElement; + sizeInput.value = draft.크기 != null ? String(draft.크기) : ""; + const boldCheck = el("input", { attrs: { type: "checkbox" } }) as HTMLInputElement; + boldCheck.checked = !!draft.굵게; + const italicCheck = el("input", { attrs: { type: "checkbox" } }) as HTMLInputElement; + italicCheck.checked = !!draft.기울임; + const underlineCheck = el("input", { attrs: { type: "checkbox" } }) as HTMLInputElement; + underlineCheck.checked = !!draft.밑줄; + const strikeCheck = el("input", { attrs: { type: "checkbox" } }) as HTMLInputElement; + strikeCheck.checked = !!draft.취소선; + const colorInput = el("input", { attrs: { type: "color" } }) as HTMLInputElement; + colorInput.value = draft.글자색 ?? "#000000"; + + const sample = el("div", { className: "ss-fmtdlg__font-sample", text: "가나다 ABC 123" }); + function refreshSample(): void { + sample.style.fontFamily = nameInput.value || ""; + sample.style.fontSize = sizeInput.value ? `${sizeInput.value}pt` : ""; + sample.style.fontWeight = boldCheck.checked ? "700" : "400"; + sample.style.fontStyle = italicCheck.checked ? "italic" : "normal"; + sample.style.textDecorationLine = [ + underlineCheck.checked && "underline", + strikeCheck.checked && "line-through", + ] + .filter(Boolean) + .join(" "); + sample.style.color = colorInput.value; + } + nameInput.addEventListener("input", () => { + draft.글꼴 = nameInput.value || undefined; + refreshSample(); + }); + sizeInput.addEventListener("input", () => { + const n = Number(sizeInput.value); + draft.크기 = sizeInput.value && n > 0 ? n : undefined; + refreshSample(); + }); + boldCheck.addEventListener("change", () => { + draft.굵게 = boldCheck.checked || undefined; + refreshSample(); + }); + italicCheck.addEventListener("change", () => { + draft.기울임 = italicCheck.checked || undefined; + refreshSample(); + }); + underlineCheck.addEventListener("change", () => { + draft.밑줄 = underlineCheck.checked || undefined; + refreshSample(); + }); + strikeCheck.addEventListener("change", () => { + draft.취소선 = strikeCheck.checked || undefined; + refreshSample(); + }); + colorInput.addEventListener("input", () => { + draft.글자색 = colorInput.value; + refreshSample(); + }); + refreshSample(); + + return el("div", { + className: "ss-fmtdlg__tab", + children: [ + row(TEXT.fontName, nameInput), + row(TEXT.fontSize, sizeInput), + row(TEXT.bold, boldCheck), + row(TEXT.italic, italicCheck), + row(TEXT.underline, underlineCheck), + row(TEXT.strike, strikeCheck), + row(TEXT.fontColor, colorInput), + sample, + ], + }); + } + + // ── 테두리 ──────────────────────────────────────────────────────────── + function borderTab(): HTMLElement { + const lineSelect = el("select", { + children: ( + ["hair", "thin", "medium", "thick", "double", "dotted", "dashed"] as BorderLine[] + ).map((v) => el("option", { attrs: { value: v }, text: v })), + }) as HTMLSelectElement; + const colorInput = el("input", { attrs: { type: "color" } }) as HTMLInputElement; + colorInput.value = "#000000"; + + const preview = el("div", { className: "ss-fmtdlg__border-preview" }); + function refreshPreview(): void { + const t = draft.테두리 ?? {}; + preview.style.borderTop = borderCss(t.위); + preview.style.borderBottom = borderCss(t.아래); + preview.style.borderLeft = borderCss(t.왼); + preview.style.borderRight = borderCss(t.오른); + } + refreshPreview(); + + const set = (sides: ("위" | "아래" | "왼" | "오른")[] | null): void => { + if (sides === null) { + draft.테두리 = undefined; + } else { + const cur = { ...(draft.테두리 ?? {}) }; + const side: BorderSide = { 선: lineSelect.value as BorderLine, 색: colorInput.value }; + for (const s of sides) cur[s] = side; + draft.테두리 = cur; + } + refreshPreview(); + }; + + // 「바깥(outline)」은 범위 가장자리 칸만 따로 골라야 해서(토대는 toolbar.ts cellsFor) 여기선 뺌 — + // 여러 칸을 한 서식 값으로 통째로 바꾸는 이 대화상자는 위·아래·왼·오른·모두만 (모두 = 칸마다 네 변). + const presets: [string, ("위" | "아래" | "왼" | "오른")[] | null][] = [ + [TEXT.none, null], + [TEXT.top, ["위"]], + [TEXT.bottom, ["아래"]], + [TEXT.left, ["왼"]], + [TEXT.right, ["오른"]], + [TEXT.all, ["위", "아래", "왼", "오른"]], + ]; + const presetRow = el("div", { + className: "ss-fmtdlg__border-presets", + children: presets.map(([label, sides]) => + createButton({ label, variant: "ghost", onClick: () => set(sides) }), + ), + }); + + return el("div", { + className: "ss-fmtdlg__tab", + children: [ + row(TEXT.lineStyle, lineSelect), + row(TEXT.lineColor, colorInput), + presetRow, + preview, + ], + }); + } + + // ── 채우기 ──────────────────────────────────────────────────────────── + function fillTab(): HTMLElement { + const colorInput = el("input", { attrs: { type: "color" } }) as HTMLInputElement; + colorInput.value = draft.채움 ?? "#ffffff"; + const swatch = el("div", { className: "ss-fmtdlg__fill-swatch" }); + function refresh(): void { + swatch.style.background = draft.채움 ?? "transparent"; + } + colorInput.addEventListener("input", () => { + draft.채움 = colorInput.value; + refresh(); + }); + const clearBtn = createButton({ + label: TEXT.clear, + variant: "ghost", + onClick: () => { + draft.채움 = undefined; + refresh(); + }, + }); + refresh(); + return el("div", { + className: "ss-fmtdlg__tab", + children: [row(TEXT.fillColor, colorInput, clearBtn), swatch], + }); + } + + // ── 보호(자리만) ──────────────────────────────────────────────────── + function protectTab(): HTMLElement { + const lockCheck = el("input", { + attrs: { type: "checkbox", checked: "true", disabled: "true" }, + }); + const hiddenCheck = el("input", { attrs: { type: "checkbox", disabled: "true" } }); + return el("div", { + className: "ss-fmtdlg__tab", + children: [ + el("p", { className: "ss-fmtdlg__note", text: TEXT.protectNote }), + row(TEXT.lock, lockCheck), + row(TEXT.hidden, hiddenCheck), + ], + }); + } + + const tabs: [string, () => HTMLElement][] = [ + [TEXT.tabNumber, numberTab], + [TEXT.tabAlign, alignTab], + [TEXT.tabFont, fontTab], + [TEXT.tabBorder, borderTab], + [TEXT.tabFill, fillTab], + [TEXT.tabProtect, protectTab], + ]; + + function showTab(index: number): void { + body.replaceChildren(tabs[index][1]()); + tabsBar.querySelectorAll(".ss-fmtdlg__tabbtn").forEach((btn, i) => { + btn.classList.toggle("is-active", i === index); + }); + } + + tabs.forEach(([label], i) => { + const btn = el("button", { + className: "ss-fmtdlg__tabbtn", + text: label, + attrs: { type: "button" }, + }); + btn.addEventListener("click", () => showTab(i)); + tabsBar.append(btn); + }); + + const actions = el("div", { + className: "ss-fmtdlg__actions", + children: [ + createButton({ label: TEXT.cancel, variant: "ghost", onClick: close }), + createButton({ + label: TEXT.ok, + variant: "filled", + onClick: () => { + ctx.dispatch({ + 종류: "서식", + 시트: ctx.selection.시트, + 범위: ctx.selection.범위, + 바꿀: draft, + }); + const isMerged = (ctx.sheet().병합 ?? []).includes(rangeToA1(range)); + if (mergeWanted !== isMerged) { + ctx.dispatch({ + 종류: mergeWanted ? "병합" : "병합풀기", + 시트: ctx.selection.시트, + 범위: range, + }); + } + close(); + }, + }), + ], + }); + panel.append(actions); + + showTab(0); + document.body.append(overlay); +} diff --git a/resources/tester/spreadsheet/test_stage2_format_code.ts b/resources/tester/spreadsheet/test_stage2_format_code.ts new file mode 100644 index 00000000..192d01f3 --- /dev/null +++ b/resources/tester/spreadsheet/test_stage2_format_code.ts @@ -0,0 +1,72 @@ +/* ============================================================================= + * test_stage2_format_code.ts — spreadsheet_format_code(셀 서식 대화상자의 표시 형식 로직) 시험. + * 돌리기: node --experimental-strip-types <이 파일> + * DOM · CSS 를 쓰는 spreadsheet_format_dialog.ts 자체는 이 저장소에 jsdom 이 없어(새 의존성을 + * 안 더함) 단위 시험 밖 — 실제 열고 닫는 확인은 D 가 Ctrl+1 을 이은 뒤 ORCA 로. + * ========================================================================== */ + +import { + buildCode, + guessCategory, +} from "../../../A00_Common/spreadsheet/spreadsheet_format_code.ts"; +import { formatValue } from "../../../A00_Common/spreadsheet/spreadsheet_numfmt.ts"; +import { toFrac } from "@ui/sheet/ui_template_sheet_frac"; +import { assertEqual } from "./test_stage2_helpers.ts"; + +assertEqual(buildCode("일반", 0, false, "minus"), "", "일반은 빈 코드"); +assertEqual(buildCode("텍스트", 0, false, "minus"), "@", "텍스트는 @"); +assertEqual(buildCode("숫자", 2, true, "minus"), "#,##0.00", "숫자 · 소수 2 · 천단위"); +assertEqual(buildCode("숫자", 0, false, "minus"), "0", "숫자 · 소수 0 · 천단위 없음"); +assertEqual(buildCode("백분율", 1, false, "minus"), "0.0%", "백분율"); +assertEqual(buildCode("통화", 2, true, "minus"), '"₩"#,##0.00', "통화"); +assertEqual(buildCode("숫자", 2, true, "paren"), "#,##0.00;(#,##0.00)", "괄호 음수"); +assertEqual(buildCode("숫자", 0, false, "red-minus"), "0;[Red]-0", "빨강 음수"); +assertEqual(buildCode("숫자", 0, false, "red-paren"), "0;[Red](0)", "빨강 괄호 음수"); +assertEqual( + buildCode("회계", 2, true, "minus"), + '_-* #,##0.00_-;-* #,##0.00_-;_-* "-"_-;_-@_-', + "회계(소수 2)", +); +assertEqual( + buildCode("회계", 0, true, "minus"), + '_-* #,##0_-;-* #,##0_-;_-* "-"_-;_-@_-', + "회계(소수 0)", +); + +assertEqual(guessCategory(undefined), "일반", "코드 없으면 일반"); +assertEqual(guessCategory(""), "일반", "빈 코드도 일반"); +assertEqual(guessCategory("@"), "텍스트", "@ 는 텍스트"); +assertEqual(guessCategory("0%"), "백분율", "% 있으면 백분율"); +assertEqual(guessCategory('_-* #,##0.00_-;-* #,##0.00_-;_-* "-"_-;_-@_-'), "회계", "회계 패턴"); +assertEqual(guessCategory('"₩"#,##0'), "통화", "통화 패턴"); +assertEqual(guessCategory("#,##0.00"), "숫자", "숫자 패턴"); +assertEqual( + guessCategory('"고정 글자"'), + "사용자 지정", + "숫자·%·통화·회계 표가 없으면 사용자 지정", +); + +// buildCode 로 지은 코드가 실제로 numfmt 로 잘 그려지는지(왕복 확인) +assertEqual( + formatValue(toFrac(1234.5), buildCode("숫자", 2, true, "minus")).글, + "1,234.50", + "숫자 코드 실제 그림", +); +assertEqual( + formatValue(toFrac(-1234.5), buildCode("숫자", 2, true, "paren")).글, + "(1,234.50)", + "괄호 음수 실제 그림", +); +assertEqual( + formatValue(toFrac(0.5), buildCode("백분율", 0, false, "minus")).글, + "50%", + "백분율 실제 그림", +); +assertEqual( + formatValue(toFrac(1234.5), buildCode("통화", 2, true, "minus")).글, + "₩1,234.50", + "통화 실제 그림", +); + +if (process.exitCode) process.exit(process.exitCode); +console.log("spreadsheet_format_code 시험 끝");