diff --git a/A00_Common/spreadsheet/spreadsheet_text.ts b/A00_Common/spreadsheet/spreadsheet_text.ts index df33f3d5..1928550f 100644 --- a/A00_Common/spreadsheet/spreadsheet_text.ts +++ b/A00_Common/spreadsheet/spreadsheet_text.ts @@ -95,6 +95,14 @@ const TEXT = { "병합된 셀의 일부를 바꿀 수 없습니다", "Can't change part of a merged cell", ], + Unit: ["단위", "Unit"], + UnitGroup_area: ["넓이·부피·길이", "Area · volume · length"], + UnitGroup_weight: ["무게·부피(액체)", "Weight · liquid volume"], + UnitGroup_count: ["개수", "Count"], + UnitGroup_rate: ["속도·각도·비율", "Speed · angle · rate"], + UnitGroup_op: ["연산·비교", "Operators"], + UnitRecent: ["최근 씀", "Recently used"], + UnitAsFormat: ["숫자 형식으로 붙이기", "Attach as number format"], } as const; export function st(key: keyof typeof TEXT): string { diff --git a/A00_Common/spreadsheet/spreadsheet_toolbar.css b/A00_Common/spreadsheet/spreadsheet_toolbar.css index d43a375f..214b2f33 100644 --- a/A00_Common/spreadsheet/spreadsheet_toolbar.css +++ b/A00_Common/spreadsheet/spreadsheet_toolbar.css @@ -119,6 +119,32 @@ gap: var(--spacing-4); } +.ss-toolbar__unit-pop { + max-height: 340px; + overflow-y: auto; +} + +.ss-toolbar__unit-section + .ss-toolbar__unit-section { + margin-top: var(--spacing-8); +} + +.ss-toolbar__unit-label { + margin-bottom: var(--spacing-4); + color: var(--color-text-muted); + font-size: 0.72rem; +} + +.ss-toolbar__unit-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: var(--spacing-4); +} + +.ss-toolbar__unit-btn { + min-width: 0; + padding: 4px 2px; +} + /* ── 시트 탭 ─────────────────────────────────────────────────────────────── */ .ss-tabs { diff --git a/A00_Common/spreadsheet/spreadsheet_toolbar.ts b/A00_Common/spreadsheet/spreadsheet_toolbar.ts index e5ffd09c..90577706 100644 --- a/A00_Common/spreadsheet/spreadsheet_toolbar.ts +++ b/A00_Common/spreadsheet/spreadsheet_toolbar.ts @@ -12,6 +12,7 @@ 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"; @@ -58,6 +59,43 @@ const BORDER_LINES: BorderLine[] = [ "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(); @@ -332,6 +370,88 @@ export function mountToolbar(ctx: SpreadsheetContext): PartHandle { 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); + root.append( group(undoBtn, redoBtn), group(fontInput, sizeInput), @@ -341,6 +461,7 @@ export function mountToolbar(ctx: SpreadsheetContext): PartHandle { group(mergeBtn), group(borderBtn, borderPop), group(numfmtInput), + group(unitBtn, unitPop), ); function refresh(): void { @@ -383,6 +504,7 @@ export function mountToolbar(ctx: SpreadsheetContext): PartHandle { refresh, destroy() { document.removeEventListener("click", closeBorderPop); + document.removeEventListener("click", closeUnitPop); root.remove(); }, }; diff --git a/resources/tester/spreadsheet/e_harness.ts b/resources/tester/spreadsheet/e_harness.ts index eae07abd..09a0775c 100644 --- a/resources/tester/spreadsheet/e_harness.ts +++ b/resources/tester/spreadsheet/e_harness.ts @@ -75,7 +75,9 @@ const grid: GridHandle = { destroy() {}, }; -function applyAndRefresh(command: import("../../../A00_Common/spreadsheet/spreadsheet_types").Command): void { +function applyAndRefresh( + command: import("../../../A00_Common/spreadsheet/spreadsheet_types").Command, +): void { const effect = applyCommand(book, command); if (effect.rebuild) engine.rebuild(book); else engine.update(effect.cells); @@ -126,7 +128,7 @@ const ctx: SpreadsheetContext = { }, grid, editor: { - editing: () => false, + editing: () => editingFlag, begin() {}, commit() {}, cancel() {}, @@ -134,6 +136,24 @@ const ctx: SpreadsheetContext = { }, }; +// 단위 기호 [편집 중이면 커서 자리에] 갈래 시험용 — 진짜 D 편집기 대신 숨은 textarea 하나로 흉내. +let editingFlag = false; +const fakeEditorTa = document.createElement("textarea"); +fakeEditorTa.id = "fake-editor-ta"; +root.append(fakeEditorTa); +(window as unknown as { __setEditing: (on: boolean, value?: string) => void }).__setEditing = ( + on, + value, +) => { + editingFlag = on; + if (on) { + if (value !== undefined) fakeEditorTa.value = value; + fakeEditorTa.focus(); + } else { + fakeEditorTa.blur(); + } +}; + const toolbar = mountToolbar(ctx); const tabs = mountTabs(ctx); attachMenu(ctx);