Files
Aislo/A00_Common/spreadsheet/spreadsheet_format_dialog.ts
T
eomsangdonandClaude Sonnet 5 e1fc529f45 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
2026-09-27 21:10:46 +09:00

514 lines
19 KiB
TypeScript

/* =============================================================================
* 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);
}