feat(spreadsheet): 2단계 — 셀 서식 대화상자(Ctrl+1)
엑셀과 같은 탭 여섯 — 표시 형식(범주·소수 자리·천 단위·음수 모양·사용자 지정 코드·미리보기, B의 numfmt로 실제 그림) · 맞춤(가로·세로·줄 바꿈·셀 병합·들여쓰기는 자리만) · 글꼴(실시간 미리보기) · 테두리(선 모양·색·위치별 적용·미리보기) · 채우기 · 보호(브레인 지시대로 자리만). [확인] 누를 때 서식 명령 하나(+ 병합 바뀌면 병합·병합풀기)로 나감 — 도구 모음처럼 바로 안 바꿈. 표시 형식 코드 짓기는 DOM 없는 별도 파일(spreadsheet_format_code.ts)로 빼서 시험 가능하게 함 (대화상자 자체는 이 저장소에 jsdom 이 없어 단위 시험 밖 — Ctrl+1 을 이은 뒤 ORCA 로 확인할 것). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K7JS47SacjPxZ718QsZKyr
This commit is contained in:
@@ -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 "사용자 지정";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 시험 끝");
|
||||
Reference in New Issue
Block a user