- 도구 모음: 글꼴 · 크기 · 굵게 · 기울임 · 밑줄 · 취소선 · 글자색 · 채움색 · 가로/세로 정렬 · 줄 바꿈 · 병합 · 테두리 · 숫자 형식 · 되돌리기 - 시트 탭: 더하기 · 이름 바꾸기 · 지우기 · 복사 · 끌어 옮기기 - 우클릭 메뉴: 칸 · 행 머리 · 열 머리별 명령(공용 `ui_template_context_menu` 재사용) - 클립보드: 안 → 안 서식 · 병합 · 수식 상대 이동 왕복 · 밖 → 안(엑셀 값 + 「수식 표시」 · 구글 R1C1) · 안 → 밖(TSV + 인라인 서식 HTML) - E 화면 시험 틀(A · B 몫 가짜 대역) · TSV 파싱 Node 시험 추가
373 lines
14 KiB
TypeScript
373 lines
14 KiB
TypeScript
/* =============================================================================
|
|
* spreadsheet_toolbar.ts (주인 E)
|
|
* 서식 도구 모음 — 되돌리기 · 다시 · 글꼴 · 크기 · 굵게 · 기울임 · 밑줄 · 취소선 ·
|
|
* 글자색 · 채움색 · 가로/세로 정렬 · 줄 바꿈 · 병합 · 테두리 · 숫자 형식.
|
|
* 모두 `ctx.dispatch` 명령으로 — 이 파일은 문서를 직접 안 건드림.
|
|
* 활성 칸 서식을 `refresh()` 때 단추 상태로. readOnly 면 안 붙임(root null).
|
|
* ========================================================================== */
|
|
|
|
import { createButton, el } from "@ui/ui_template_elements";
|
|
import { colName, rangeToA1, toA1 } from "./spreadsheet_address";
|
|
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";
|
|
|
|
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",
|
|
];
|
|
|
|
/** 활성 칸에 지금 걸린 서식 — 칸 자기 것 없으면 열 · 행 · 그다음 기본(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)]?.서식 ?? 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 }));
|
|
|
|
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),
|
|
);
|
|
|
|
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.글꼴 ?? "";
|
|
sizeInput.value = style.크기 != null ? String(style.크기) : "";
|
|
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);
|
|
root.remove();
|
|
},
|
|
};
|
|
}
|