/* ============================================================================= * spreadsheet_toolbar.ts (주인 E) * 서식 도구 모음 — 되돌리기 · 다시 · 글꼴 · 크기 · 굵게 · 기울임 · 밑줄 · 취소선 · * 글자색 · 채움색 · 가로/세로 정렬 · 줄 바꿈 · 병합 · 테두리 · 숫자 형식. * 모두 `ctx.dispatch` 명령으로 — 이 파일은 문서를 직접 안 건드림. * 활성 칸 서식을 `refresh()` 때 단추 상태로. readOnly 면 안 붙임(root null). * * 이음(D `spreadsheet.ts` 잇는 자리) — `mountToolbar(ctx)` 한 번 부르고 `root`(null 이면 안 붙임)를 * 도구 모음 자리에 붙임. 고름 · 문서가 바뀔 때마다(자기 dispatch 뒤 포함) `refresh()` 를 부름 — * 스스로 구독하지 않음. `destroy()` 는 부품을 뗄 때. * ========================================================================== */ import { createButton, el } from "@ui/ui_template_elements"; import { colName, rangeToA1, toA1 } from "./spreadsheet_address"; import { cellToText, textToCell } from "./spreadsheet_editor"; import { st } from "./spreadsheet_text"; import type { BorderLine, BorderSide, CellRange, CellStyle, Command } from "./spreadsheet_types"; import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types"; import "./spreadsheet_toolbar.css"; type Side = "위" | "아래" | "왼" | "오른"; type BorderPreset = "none" | "all" | "outer" | "top" | "bottom" | "left" | "right"; /** 앱 기본 글꼴 · 크기 — `spreadsheet_grid.ts`(C) 의 `fontCss` 가 쓰는 기본과 같은 값(거울) · * 칸 · 행 · 열 · 시트 기본 어디에도 없을 때 이 값을 흐리게(placeholder) 보임. */ const APP_DEFAULT_FONT = "굴림"; const APP_DEFAULT_SIZE = 10; const FONTS = [ "굴림", "굴림체", "맑은 고딕", "나눔고딕", "돋움", "Arial", "Calibri", "Times New Roman", ]; const NUMFMT_PRESETS: [label: string, code: string][] = [ [st("NumberFormat_general"), ""], [st("NumberFormat_integer"), "0"], [st("NumberFormat_decimal2"), "0.00"], [st("NumberFormat_thousands"), "#,##0"], [st("NumberFormat_thousands2"), "#,##0.00"], [st("NumberFormat_percent"), "0%"], [st("NumberFormat_accounting"), '_-* #,##0.00_-;-* #,##0.00_-;_-* "-"_-;_-@_-'], [st("NumberFormat_text"), "@"], ]; const BORDER_LINES: BorderLine[] = [ "hair", "thin", "medium", "thick", "double", "dotted", "dashed", ]; type TextKey = Parameters[0]; const UNIT_GROUPS: [label: TextKey, items: string[]][] = [ ["UnitGroup_area", ["㎡", "㎥", "m", "㎝", "㎜", "㎞"]], ["UnitGroup_weight", ["kg", "t", "ℓ", "㎖"]], ["UnitGroup_count", ["개", "본", "식", "EA", "매"]], ["UnitGroup_rate", ["m/s", "°", "%", "‰"]], ["UnitGroup_op", ["×", "÷", "±", "≤", "≥", "Σ"]], ]; const UNIT_RECENT_MAX = 8; const UNIT_RECENT_KEY = "aislo-ss-recent-units"; function loadRecentUnits(): string[] { try { const raw = localStorage.getItem(UNIT_RECENT_KEY); return raw ? (JSON.parse(raw) as string[]) : []; } catch { return []; } } function pushRecentUnit(unit: string): string[] { const list = [unit, ...loadRecentUnits().filter((u) => u !== unit)].slice(0, UNIT_RECENT_MAX); try { localStorage.setItem(UNIT_RECENT_KEY, JSON.stringify(list)); } catch { /* 저장 못 해도 이번 넣기는 그대로 진행 */ } return list; } /** 숫자 형식 코드 끝 따옴표 글자(전에 붙인 단위)를 떼고 새 단위를 붙임 — 없으면 "0" 에서 시작. */ function attachUnitToFormat(code: string, unit: string): string { const base = (code || "0").replace(/"[^"]*"\s*$/, ""); return base + `"${unit}"`; } /** 활성 칸에 지금 걸린 서식 — 칸 자기 것 없으면 열 · 행 · 시트 기본(`Sheet.기본.서식`) · 그다음 0. */ function styleAt(ctx: SpreadsheetContext, r: number, c: number): CellStyle { const sheet = ctx.sheet(); const cell = sheet.칸[toA1(r, c)]; const idx = cell?.서식 ?? sheet.열?.[colName(c)]?.서식 ?? sheet.행?.[String(r + 1)]?.서식 ?? sheet.기본?.서식 ?? 0; return ctx.book.서식[idx] ?? {}; } export function mountToolbar(ctx: SpreadsheetContext): PartHandle { if (ctx.readOnly) return { root: null, refresh() {}, destroy() {} }; const root = el("div", { className: "ss-toolbar" }); const group = (...children: HTMLElement[]): HTMLElement => el("div", { className: "ss-toolbar__group", children }); const patch = (change: { [K in keyof CellStyle]?: CellStyle[K] | null }): void => { ctx.dispatch({ 종류: "서식", 시트: ctx.selection.시트, 범위: ctx.selection.범위, 바꿀: change, }); }; // ── 되돌리기 · 다시 ──────────────────────────────────────────────────── const undoBtn = createButton({ label: "↶", variant: "ghost", onClick: () => ctx.undo() }); undoBtn.title = st("Undo"); const redoBtn = createButton({ label: "↷", variant: "ghost", onClick: () => ctx.redo() }); redoBtn.title = st("Redo"); // ── 글꼴 · 크기 ──────────────────────────────────────────────────────── const fontInput = el("input", { className: "ss-toolbar__input ss-toolbar__input--font", attrs: { list: "ss-toolbar-fonts", "aria-label": st("FontName") }, }) as HTMLInputElement; const fontList = el("datalist", { attrs: { id: "ss-toolbar-fonts" }, children: FONTS.map((name) => el("option", { attrs: { value: name } })), }); fontInput.append(fontList); const commitFont = (): void => patch({ 글꼴: fontInput.value.trim() || null }); fontInput.addEventListener("change", commitFont); const sizeInput = el("input", { className: "ss-toolbar__input ss-toolbar__input--size", attrs: { type: "number", min: "1", max: "409", step: "0.5", "aria-label": st("FontSize") }, }) as HTMLInputElement; sizeInput.addEventListener("change", () => { const n = Number(sizeInput.value); patch({ 크기: sizeInput.value && Number.isFinite(n) && n > 0 ? n : null }); }); // ── 굵게 · 기울임 · 밑줄 · 취소선 ────────────────────────────────────── const boldBtn = createButton({ label: "B", variant: "ghost" }); boldBtn.title = st("Bold"); const italicBtn = createButton({ label: "I", variant: "ghost" }); italicBtn.title = st("Italic"); const underlineBtn = createButton({ label: "U", variant: "ghost" }); underlineBtn.title = st("Underline"); const strikeBtn = createButton({ label: "S", variant: "ghost" }); strikeBtn.title = st("Strike"); const toggles: [HTMLButtonElement, keyof CellStyle][] = [ [boldBtn, "굵게"], [italicBtn, "기울임"], [underlineBtn, "밑줄"], [strikeBtn, "취소선"], ]; for (const [btn, key] of toggles) { btn.addEventListener("click", () => patch({ [key]: !btn.classList.contains("is-on") })); } // ── 글자색 · 채움색 ──────────────────────────────────────────────────── const textColor = el("input", { className: "ss-toolbar__color", attrs: { type: "color", "aria-label": st("TextColor"), value: "#000000" }, }) as HTMLInputElement; textColor.addEventListener("change", () => patch({ 글자색: textColor.value })); const textColorClear = el("button", { className: "ss-toolbar__clear", text: "✕", attrs: { type: "button", title: st("ColorClear") }, }); textColorClear.addEventListener("click", () => patch({ 글자색: null })); const fillColor = el("input", { className: "ss-toolbar__color", attrs: { type: "color", "aria-label": st("FillColor"), value: "#ffffff" }, }) as HTMLInputElement; fillColor.addEventListener("change", () => patch({ 채움: fillColor.value })); const fillColorClear = el("button", { className: "ss-toolbar__clear", text: "✕", attrs: { type: "button", title: st("ColorClear") }, }); fillColorClear.addEventListener("click", () => patch({ 채움: null })); // ── 가로 · 세로 정렬 · 줄 바꿈 ───────────────────────────────────────── const HALIGN_LABEL = { general: st("Align_general"), left: st("Align_left"), center: st("Align_center"), right: st("Align_right"), distributed: st("Align_distributed"), centerContinuous: st("Align_centerContinuous"), } as const; const VALIGN_LABEL = { top: st("VAlign_top"), center: st("VAlign_center"), bottom: st("VAlign_bottom"), } as const; const hAlignSelect = el("select", { className: "ss-toolbar__select", attrs: { "aria-label": st("HAlign") }, children: (Object.keys(HALIGN_LABEL) as (keyof typeof HALIGN_LABEL)[]).map((v) => el("option", { attrs: { value: v }, text: HALIGN_LABEL[v] }), ), }) as HTMLSelectElement; hAlignSelect.addEventListener("change", () => patch({ 가로: hAlignSelect.value as CellStyle["가로"] }), ); const vAlignSelect = el("select", { className: "ss-toolbar__select", attrs: { "aria-label": st("VAlign") }, children: (Object.keys(VALIGN_LABEL) as (keyof typeof VALIGN_LABEL)[]).map((v) => el("option", { attrs: { value: v }, text: VALIGN_LABEL[v] }), ), }) as HTMLSelectElement; vAlignSelect.addEventListener("change", () => patch({ 세로: vAlignSelect.value as CellStyle["세로"] }), ); const wrapBtn = createButton({ label: st("Wrap"), variant: "ghost" }); wrapBtn.addEventListener("click", () => patch({ 줄바꿈: !wrapBtn.classList.contains("is-on") })); // ── 병합 ─────────────────────────────────────────────────────────────── const mergeBtn = createButton({ label: st("Merge"), variant: "ghost" }); mergeBtn.addEventListener("click", () => { const range = ctx.selection.범위[0]; if (!range) return; const merged = (ctx.sheet().병합 ?? []).includes(rangeToA1(range)); ctx.dispatch({ 종류: merged ? "병합풀기" : "병합", 시트: ctx.selection.시트, 범위: range }); }); // ── 테두리 ─────────────────────────────────────────────────────────── const borderBtn = createButton({ label: st("Border"), variant: "ghost" }); const borderPop = el("div", { className: "ss-toolbar__popover", attrs: { hidden: "true" } }); const lineSelect = el("select", { className: "ss-toolbar__select", children: BORDER_LINES.map((v) => el("option", { attrs: { value: v }, text: v })), }) as HTMLSelectElement; const lineColor = el("input", { className: "ss-toolbar__color", attrs: { type: "color", value: "#000000" }, }) as HTMLInputElement; const presetsRow = el("div", { className: "ss-toolbar__popover-presets" }); const presets: [BorderPreset, string][] = [ ["none", st("Border_none")], ["all", st("Border_all")], ["outer", st("Border_outer")], ["top", st("Border_top")], ["bottom", st("Border_bottom")], ["left", st("Border_left")], ["right", st("Border_right")], ]; for (const [preset, label] of presets) { const b = createButton({ label, variant: "ghost", onClick: () => { applyBorderPreset(preset); borderPop.hidden = true; }, }); presetsRow.append(b); } borderPop.append( el("div", { className: "ss-toolbar__popover-row", children: [el("span", { text: st("BorderLine") }), lineSelect], }), el("div", { className: "ss-toolbar__popover-row", children: [el("span", { text: st("BorderColor") }), lineColor], }), presetsRow, ); borderBtn.addEventListener("click", (ev) => { ev.stopPropagation(); borderPop.hidden = !borderPop.hidden; }); borderPop.addEventListener("click", (ev) => ev.stopPropagation()); const closeBorderPop = (): void => { borderPop.hidden = true; }; document.addEventListener("click", closeBorderPop); function cellsFor( range: CellRange, edge: BorderPreset, ): { r: number; c: number; sides: Side[] }[] { const { r0, c0, r1, c1 } = range; const out: { r: number; c: number; sides: Side[] }[] = []; for (let r = r0; r <= r1; r++) { for (let c = c0; c <= c1; c++) { const sides: Side[] = []; if (edge === "all") sides.push("위", "아래", "왼", "오른"); else { if ((edge === "outer" || edge === "top") && r === r0) sides.push("위"); if ((edge === "outer" || edge === "bottom") && r === r1) sides.push("아래"); if ((edge === "outer" || edge === "left") && c === c0) sides.push("왼"); if ((edge === "outer" || edge === "right") && c === c1) sides.push("오른"); } if (sides.length) out.push({ r, c, sides }); } } return out; } function applyBorderPreset(preset: BorderPreset): void { const range = ctx.selection.범위[0]; if (!range) return; if (preset === "none") { ctx.dispatch({ 종류: "서식", 시트: ctx.selection.시트, 범위: ctx.selection.범위, 바꿀: { 테두리: null }, }); return; } const style: BorderSide = { 선: lineSelect.value as BorderLine, 색: lineColor.value }; const cmds: Command[] = cellsFor(range, preset).map(({ r, c, sides }) => { const cur = { ...(styleAt(ctx, r, c).테두리 ?? {}) }; for (const s of sides) cur[s] = style; return { 종류: "서식", 시트: ctx.selection.시트, 범위: [{ r0: r, c0: c, r1: r, c1: c }], 바꿀: { 테두리: cur }, }; }); if (cmds.length === 1) ctx.dispatch(cmds[0]); else if (cmds.length > 1) ctx.dispatch({ 종류: "묶음", 명령: cmds }); } // ── 숫자 형식 ───────────────────────────────────────────────────────── const numfmtInput = el("input", { className: "ss-toolbar__input ss-toolbar__input--numfmt", attrs: { list: "ss-toolbar-numfmt", "aria-label": st("NumberFormat"), title: st("NumberFormat_hint"), }, }) as HTMLInputElement; const numfmtList = el("datalist", { attrs: { id: "ss-toolbar-numfmt" }, children: NUMFMT_PRESETS.map(([label, code]) => el("option", { attrs: { value: code }, text: `${label} (${code || st("NumberFormat_general")})`, }), ), }); numfmtInput.append(numfmtList); numfmtInput.addEventListener("change", () => patch({ 형식: numfmtInput.value || null })); // ── 단위 기호 ───────────────────────────────────────────────────────── const unitBtn = createButton({ label: `${st("Unit")} ▾`, variant: "ghost" }); const unitPop = el("div", { className: "ss-toolbar__popover ss-toolbar__unit-pop", attrs: { hidden: "true" }, }); const unitAsFormat = el("input", { attrs: { type: "checkbox", id: "ss-unit-as-format" } }); const unitAsFormatRow = el("label", { className: "ss-toolbar__popover-row", attrs: { for: "ss-unit-as-format" }, children: [unitAsFormat, el("span", { text: st("UnitAsFormat") })], }); const recentGrid = el("div", { className: "ss-toolbar__unit-grid" }); const recentSection = el("div", { className: "ss-toolbar__unit-section", attrs: { hidden: "true" }, children: [ el("div", { className: "ss-toolbar__unit-label", text: st("UnitRecent") }), recentGrid, ], }); function insertUnit(unit: string): void { if (ctx.editor.editing()) { const active = document.activeElement; if (active instanceof HTMLTextAreaElement && ctx.root.contains(active)) { const s = active.selectionStart ?? active.value.length; const e = active.selectionEnd ?? active.value.length; active.value = active.value.slice(0, s) + unit + active.value.slice(e); active.setSelectionRange(s + unit.length, s + unit.length); active.dispatchEvent(new Event("input", { bubbles: true })); } } else if (unitAsFormat.checked) { const { r, c } = ctx.selection.활성; patch({ 형식: attachUnitToFormat(styleAt(ctx, r, c).형식 ?? "", unit) }); } else { const { r, c } = ctx.selection.활성; const sheet = ctx.sheet(); const key = toA1(r, c); const text = cellToText(sheet.칸[key]) + unit; ctx.dispatch({ 종류: "칸", 시트: sheet.id, 칸: { [key]: textToCell(text, sheet.칸[key]) } }); } renderRecent(pushRecentUnit(unit)); unitPop.hidden = true; } function renderRecent(list: string[]): void { recentGrid.textContent = ""; recentSection.hidden = list.length === 0; for (const u of list) { const b = createButton({ label: u, variant: "ghost", onClick: () => insertUnit(u) }); b.className += " ss-toolbar__unit-btn"; recentGrid.append(b); } } renderRecent(loadRecentUnits()); unitPop.append(unitAsFormatRow, recentSection); for (const [labelKey, items] of UNIT_GROUPS) { const grid = el("div", { className: "ss-toolbar__unit-grid" }); for (const u of items) { const b = createButton({ label: u, variant: "ghost", onClick: () => insertUnit(u) }); b.className += " ss-toolbar__unit-btn"; grid.append(b); } unitPop.append( el("div", { className: "ss-toolbar__unit-section", children: [el("div", { className: "ss-toolbar__unit-label", text: st(labelKey) }), grid], }), ); } unitBtn.addEventListener("click", (ev) => { ev.stopPropagation(); unitPop.hidden = !unitPop.hidden; }); unitPop.addEventListener("click", (ev) => ev.stopPropagation()); const closeUnitPop = (): void => { unitPop.hidden = true; }; document.addEventListener("click", closeUnitPop); // ── 엑셀 수식 복사 안내(오른쪽 위 흐린 글씨 · 누르면 두세 줄 설명) ───────── const formulaHintBtn = el("button", { className: "ss-toolbar__formula-hint", attrs: { type: "button", title: st("FormulaHint_title") }, text: st("FormulaHint_label"), }); const formulaHintDetail = el("div", { className: "ss-toolbar__formula-hint-detail", attrs: { hidden: "true" }, text: st("FormulaHint_detail"), }); formulaHintBtn.addEventListener("click", (ev) => { ev.stopPropagation(); formulaHintDetail.hidden = !formulaHintDetail.hidden; }); const formulaHint = el("div", { className: "ss-toolbar__hint", children: [formulaHintBtn, formulaHintDetail], }); root.append( group(undoBtn, redoBtn), group(fontInput, sizeInput), group(boldBtn, italicBtn, underlineBtn, strikeBtn), group(textColor, textColorClear, fillColor, fillColorClear), group(hAlignSelect, vAlignSelect, wrapBtn), group(mergeBtn), group(borderBtn, borderPop), group(numfmtInput), group(unitBtn, unitPop), formulaHint, ); function refresh(): void { undoBtn.disabled = !ctx.history.canUndo(); redoBtn.disabled = !ctx.history.canRedo(); const { r, c } = ctx.selection.활성; const style = styleAt(ctx, r, c); fontInput.value = style.글꼴 ?? ""; fontInput.placeholder = style.글꼴 ? "" : (ctx.book.기본?.글꼴 ?? APP_DEFAULT_FONT); sizeInput.value = style.크기 != null ? String(style.크기) : ""; sizeInput.placeholder = style.크기 != null ? "" : String(ctx.book.기본?.크기 ?? APP_DEFAULT_SIZE); boldBtn.classList.toggle("is-on", !!style.굵게); italicBtn.classList.toggle("is-on", !!style.기울임); underlineBtn.classList.toggle("is-on", !!style.밑줄); strikeBtn.classList.toggle("is-on", !!style.취소선); textColor.value = style.글자색 ?? "#000000"; fillColor.value = style.채움 ?? "#ffffff"; hAlignSelect.value = style.가로 ?? "general"; vAlignSelect.value = style.세로 ?? "top"; wrapBtn.classList.toggle("is-on", !!style.줄바꿈); numfmtInput.value = style.형식 ?? ""; const range = ctx.selection.범위[0]; mergeBtn.textContent = ""; mergeBtn.append( el("span", { className: "ui-btn__label", text: range && (ctx.sheet().병합 ?? []).includes(rangeToA1(range)) ? st("Unmerge") : st("Merge"), }), ); } refresh(); return { root, refresh, destroy() { document.removeEventListener("click", closeBorderPop); document.removeEventListener("click", closeUnitPop); root.remove(); }, }; }