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