Files
Aislo/A00_Common/spreadsheet/spreadsheet_toolbar.ts
T
eomsangdonandClaude Sonnet 5 a927ae64bc feat(spreadsheet): 엑셀 수식 복사 안내 · 값만 온 붙여넣기 세션 알림
- 도구 모음 오른쪽 끝에 흐린 글씨 안내 추가 — 「엑셀 수식까지: 엑셀에서 Ctrl+`
  → 복사 → 여기 Ctrl+V · 끝나면 엑셀 Ctrl+` 다시」 · 누르면 왜 그런지 두세 줄
  설명(엑셀 Ctrl+` = 「수식 표시」 켜기 · 그 상태로 복사해야 수식이 실림 · 그냥
  복사하면 값만 옴 · 붙인 뒤 엑셀에서 Ctrl+` 다시 눌러 원래대로)
- 도구 모음은 readOnly 면 안 붙는 기존 길을 그대로 써 보기 전용 카드에서는
  자동으로 숨음(새 분기 없음)
- 엑셀 HTML 붙여넣기(출처: "excel")인데 뭉치 전체에 식이 하나도 없으면 세션에
  한 번만 토스트 — 「값만 들어왔음 — 수식까지는 엑셀 Ctrl+` 뒤 복사」
  (sessionStorage 로 한 번 표시 기억 · 못 쓰면 매번 뜰 수 있으나 치명적이지 않음)
- ORCA 로 확인 — 안내 글 그대로 보임 · 눌러 설명 펼침/접힘 · 식 없는 엑셀
  붙여넣기 첫 번은 토스트 뜨고 세션 안 지운 채 두 번째는 안 뜸 · 식이 있으면
  (「=A1+B1」) 애초에 안 뜸 · 기존 클립보드 회귀 시험(TSV) 통과

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeeQRLknEpP4SFjVkjDWDs
2026-09-28 14:24:04 +09:00

533 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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<typeof st>[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();
},
};
}