엑셀과 같은 탭 여섯 — 표시 형식(범주·소수 자리·천 단위·음수 모양·사용자 지정 코드·미리보기, 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
514 lines
19 KiB
TypeScript
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);
|
|
}
|