fix(spreadsheet): 16장 도구 막대 · 메뉴 · 대화 창 전수 점검 — 흠 스무 곳 고침

M02 면적산출 SA01010100 에서 도구 막대 · 데이터 메뉴 · 우클릭 · 셀 서식 여섯 탭 · 찾기 · 메모 ·
이름 관리 · 조건부 서식 · 유효성 · 이동 · 틀 고정 · 칸 넣기/지우기 · 데이터 도구 · 윤곽 ·
수식 분석 · 되돌리기/다시 · 저장 안 하고 떠나기 · 다시 열기를 빈 값 · 같은 이름 · 긴 이름 ·
취소 · Esc · Enter · 밝은/어두운 테마 예외까지 돌려 찾은 흠.
- 격자(C): 틀고정이 있으면 본문 판이 그릴 행열을 틀고정 폭만큼 덜 셈 — 아래 · 오른쪽 끝이
  빈 칸으로 남던 것(29 행 고정 때 1 행만 그림) · visibleRange 도 같음 · reveal 은 바로 그림
- 도구 막대: 세로 정렬 기본 표시 「위」 → 「아래」(칸 실제와 맞춤) · 크기 409 넘으면 409 ·
  테두리 · 단위 판이 단추 위로 떠 머리까지 덮던 것 → 단추 아래 · ✕ · 수식 안내 · 확대 글
  대비(흐림 투명도 없앰 · --color-text-secondary · 밝은 5.88 · 어두운 7.91)
- 병합(도구 막대 · 우클릭): 왼위 밖 값이 말없이 지워지던 것 → 엑셀처럼 먼저 물음(confirmMerge)
- 셀 서식 · 이동 · 칸 넣기/지우기 · 텍스트 나누기 · 중복 제거 · 연속 채우기 · 메모 창: 열 때
  초점이 창 밖(BODY)이라 Esc · Enter 가 안 먹던 것 → dialogKeys(창 안 초점 · Esc 닫기 ·
  Enter 실행) · 셀 서식 창 탭마다 높이가 널뛰던 것 → 본문 최소 높이
- 우클릭 메뉴(ui_template 공용): Esc 로 닫힘 · 화면 밖이면 안쪽으로 당김 · 데이터 ▾ 메뉴와 알약
  우클릭 메뉴가 화면 좌표를 부품 기준 자리로 써 오른쪽 아래로 떨어지던 것 고침 · 행 · 열 머리
  메뉴에 잘라내기 · 붙여넣기(안내) 더함
- 자동 필터 ▼ 가 칸 밑에 깔려 눌리지 않던 것(z-index) · 값 목록이 격자 아래 끝에서 잘리면
  위로 뒤집음
- Σ: 여러 칸을 고르면 첫 칸에 =SUM() 편집을 열어 Enter 로 값을 덮던 것 → 엑셀처럼 열마다
  바로 아래(또는 고름 끝 빈 칸)에 합계를 바로 넣음(autoSumCells)
- 조건부 서식: 빈 값이 「보다큼 0」 규칙이 되던 것 · #RRGGBB 아닌 색 · 색 없는 규칙 → 안내 후 막음
- 이름 관리: 255 자 넘는 이름 막음 · 긴 이름에 창이 화면 끝까지 늘던 것 · 시트 id(s1) 대신
  시트 이름 · 「이름 바꾸기」 가 브라우저 prompt(자동 취소됨)라 안 되던 것 → 줄 안에서 고침
- 표시 형식 「@」 를 수에 걸면 빈 칸이 되던 것 → 엑셀처럼 일반으로 보임
- 연속 데이터 채우기 · 텍스트 나누기가 칸 서식을 지우던 것 → 서식 유지
- 틀 고정 「지금 칸 기준」 을 먼 칸(Z1000)에서 하면 고정 판이 격자를 다 덮어 못 움직이던 것 →
  화면 안쪽 칸에서만(안내)

ORCA(IP http://100.72.32.27:5181 · 메인 폴더 탭 · 판정은 수치): 목록 80 항목 밝은 80/80 ·
어두운 80/80 · 사각형 겹침 · 잘림 · 화면 밖 · 대비 3 미만 0 건. 시험 — test_stage3_autosum
(여러 칸 넷 더함) · test_stage2_data_tools · test_stage2_format_code · spreadsheet_b_test_entry
197/197(@ 하나 더함) · test_spreadsheet_size(css gzip 3121 · 합 67403) 통과. 시험으로 바꾼 칸은
저장 안 하고 「다시 열기」 로 버림.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W9YEkMnYQnNnRZRPXoxFsw
This commit is contained in:
2026-09-28 22:57:48 +09:00
co-authored by Claude Opus 5.5
parent 6606ef167a
commit 7389335ca9
24 changed files with 261 additions and 63 deletions
+1
View File
@@ -274,6 +274,7 @@
top: 4px;
right: 16px;
z-index: 21;
max-width: min(720px, calc(100% - 32px));
max-height: calc(100% - 8px);
overflow: auto;
border: 1px solid var(--color-border);
@@ -9,6 +9,10 @@
* ========================================================================== */
import type { MapContextMenuItem } from "@ui/ui_template_context_menu";
import { showConfirmDialog } from "@ui/ui_template_elements";
import { parseA1 } from "./spreadsheet_address";
import { st } from "./spreadsheet_text";
import type { CellRange } from "./spreadsheet_types";
import type { SpreadsheetContext } from "./spreadsheet_view_types";
const TEXT = {
@@ -83,6 +87,37 @@ const CTRL_SHIFT: Record<string, Act> = {
const SHEET_ACTS = new Set<Act>(["next", "prev", "newSheet"]);
/** 병합 전 확인 — 왼위 밖 칸에 값이 있으면 지워짐을 물음(엑셀과 같음) · 없으면 바로 true */
export async function confirmMerge(ctx: SpreadsheetContext, range: CellRange): Promise<boolean> {
const lost = Object.entries(ctx.sheet().칸).some(([a1, cell]) => {
const p = parseA1(a1);
if (!p || (cell.값 === undefined && cell.식 === undefined)) return false;
const inside = p.r >= range.r0 && p.r <= range.r1 && p.c >= range.c0 && p.c <= range.c1;
return inside && (p.r !== range.r0 || p.c !== range.c0);
});
return !lost || showConfirmDialog(st("MergeLoseConfirm"), st("Merge"));
}
/** 창 키 — Esc 닫기 · Enter 실행(여러 줄 글 · 단추 · 한글 조합 중은 그대로) · 창 안에 초점을 넣음 */
export function dialogKeys(overlay: HTMLElement, close: () => void, run?: () => void): void {
overlay.addEventListener("keydown", (e) => {
const t = e.target as HTMLElement;
if (e.key === "Escape") close();
else if (e.key !== "Enter" || !run || e.isComposing) return;
else if (t.tagName === "TEXTAREA" || t.tagName === "BUTTON") return;
else run();
e.preventDefault();
e.stopPropagation();
});
queueMicrotask(() => {
if (overlay.contains(document.activeElement)) return;
const first = overlay.querySelector<HTMLElement>("input:not([type=hidden]), select, textarea");
if (first) return first.focus();
overlay.tabIndex = -1;
overlay.focus();
});
}
/** body 에 뜬 창(.ui-confirm)이 다 닫히면 초점을 격자로 — 창을 연 바로 뒤에 부름 */
export function watchClose(back: () => void): void {
const w = new MutationObserver(() => {
+33 -1
View File
@@ -13,7 +13,7 @@
import { rangeToA1, toA1 } from "./spreadsheet_address";
import { isFrac } from "./spreadsheet_functions_math";
import type { CellRange } from "./spreadsheet_types";
import type { Cell, CellRange } from "./spreadsheet_types";
import type { SpreadsheetContext } from "./spreadsheet_view_types";
function isNumericCell(ctx: SpreadsheetContext, sheet: string, r: number, c: number): boolean {
@@ -61,6 +61,38 @@ export function autoSumRange(ctx: SpreadsheetContext): CellRange | null {
return verticalRun(ctx, sheet, r, c, 0) ?? horizontalRun(ctx, sheet, r, c, 0);
}
/** 여러 칸을 고른 채 Σ — 엑셀처럼 열마다(한 행만 골랐으면 행 끝) 고름 끝 빈 칸 또는 바로 다음 빈 칸에
* 합계 식을 바로 넣을 칸들. 고른 칸이 하나거나 넣을 빈 칸이 없으면 null. */
export function autoSumCells(ctx: SpreadsheetContext): Record<string, Cell> | null {
const sel = ctx.selection.범위[0];
if (!sel || (sel.r0 === sel.r1 && sel.c0 === sel.c1)) return null;
const sheet = ctx.sheet();
const empty = (r: number, c: number): boolean => {
const x = sheet.칸[toA1(r, c)];
return !x || (x.식 === undefined && (x.값 === undefined || x.값 === ""));
};
const across = sel.r0 === sel.r1;
const out: Record<string, Cell> = {};
const lines = across
? [sel.r0]
: Array.from({ length: sel.c1 - sel.c0 + 1 }, (_, i) => sel.c0 + i);
for (const k of lines) {
const at = (i: number): [number, number] => (across ? [k, i] : [i, k]);
const first = across ? sel.c0 : sel.r0;
const last = across ? sel.c1 : sel.r1;
const end = empty(...at(last)) ? last : last + 1;
if (!empty(...at(end))) continue;
const [r, c] = at(end);
const [r0, c0] = at(first);
const [r1, c1] = at(end - 1);
const key = toA1(r, c);
const 식 = `SUM(${rangeToA1({ r0, c0, r1, c1 })})`;
const 서식 = sheet.칸[key]?.서식;
out[key] = 서식 === undefined ? { 식 } : { 식, 서식 };
}
return Object.keys(out).length ? out : null;
}
/** 활성 칸에 `=SUM(...)` 을 바로 넣음(칸 명령 — 편집기를 안 거침). */
export function applyAutoSum(ctx: SpreadsheetContext): void {
const range = autoSumRange(ctx);
+17 -4
View File
@@ -18,7 +18,13 @@
* 4) 도구 모음 · 우클릭 메뉴에 [조건부 서식] 항목을 두고 `createCondFmtPanel(ctx)` 를 띄움.
* ========================================================================== */
import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements";
import {
createButton,
createInputField,
createSelectField,
el,
showToast,
} from "@ui/ui_template_elements";
import { inRange, rangeToA1 } from "./spreadsheet_address";
import type {
CellRange,
@@ -186,10 +192,14 @@ export function createCondFmtPanel(ctx: SpreadsheetContext): PartHandle {
const 범위 = ctx.selection.범위;
if (!범위.length) return;
const 조건 = buildCondition(kindSelect.select.value, opSelect.select.value, val1, val2);
if (!조건) return;
if (!조건) return showToast("조건 값을 넣을 것", "warning");
const colors = [fill.input.value.trim(), fontColor.input.value.trim()];
if (colors.some((c) => c && !/^#[0-9a-f]{6}$/i.test(c)))
return showToast("색은 #RRGGBB 꼴로", "warning");
if (!colors[0] && !colors[1]) return showToast("채움색이나 글자색을 넣을 것", "warning");
const 스타일: CondFmtStyle = {};
if (fill.input.value) 스타일.채움 = fill.input.value;
if (fontColor.input.value) 스타일.글자색 = fontColor.input.value;
if (colors[0]) 스타일.채움 = colors[0];
if (colors[1]) 스타일.글자색 = colors[1];
addCondFmtRule(ctx, { 범위: [...범위], 조건, 스타일 });
render();
});
@@ -200,11 +210,14 @@ export function createCondFmtPanel(ctx: SpreadsheetContext): PartHandle {
a: ReturnType<typeof createInputField>,
b: ReturnType<typeof createInputField>,
): CondFmtCondition | null {
const blank = (f: typeof a): boolean => f.input.value.trim() === "";
if (kind === "비교") {
if (blank(a)) return null;
const n = Number(a.input.value);
return Number.isFinite(n) ? { 종류: "비교", 연산: op as CondFmtCompareOp, 값: n } : null;
}
if (kind === "사이") {
if (blank(a) || blank(b)) return null;
const 아래 = Number(a.input.value);
const 위 = Number(b.input.value);
return Number.isFinite(아래) && Number.isFinite(위) ? { 종류: "사이", 아래, 위 } : null;
@@ -13,7 +13,7 @@ import "./spreadsheet_stage3.css"; // sub4 패널 스타일(잇는 쪽이 붙임
import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu";
import { el, showToast } from "@ui/ui_template_elements";
import { parseRange, rangeToA1, toA1 } from "./spreadsheet_address";
import { autoSumRange } from "./spreadsheet_autosum";
import { autoSumCells, autoSumRange } from "./spreadsheet_autosum";
import { isError } from "./spreadsheet_eval";
import type { FilterHandle } from "./spreadsheet_filter";
import { createCondFmtPanel } from "./spreadsheet_condfmt";
@@ -167,7 +167,9 @@ export function mountDataMenu(ctx: SpreadsheetContext, back: () => void): DataMe
}
function openMenu(anchor: HTMLElement): void {
const r = anchor.getBoundingClientRect();
const a = anchor.getBoundingClientRect();
const o = ctx.root.getBoundingClientRect(); // 메뉴는 부품 뿌리 기준 자리(칸 우클릭 메뉴와 같음)
const r = { left: a.left - o.left, bottom: a.bottom - o.top };
const items: MapContextMenuItem[] = [
[TEXT.sort, () => void sortDialog()],
[TEXT.filterItem, () => (toggleFilter(), back())],
@@ -259,6 +261,8 @@ export function mountDataMenu(ctx: SpreadsheetContext, back: () => void): DataMe
/** 자동 합계 — 엑셀처럼 =SUM(잡은 범위) 로 편집을 열고 Enter 는 사용자 몫 */
function autosum(): void {
const cells = autoSumCells(ctx);
if (cells) return ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: cells });
const g = autoSumRange(ctx);
ctx.editor.begin(g ? `=SUM(${rangeToA1(g)})` : "=SUM()");
}
@@ -14,6 +14,7 @@
import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements";
import { fracToString } from "@ui/sheet/ui_template_sheet_frac";
import { dialogKeys } from "./spreadsheet_actions";
import { colName, toA1 } from "./spreadsheet_address";
import type { Cell, CellAddress, CellInput, CellRange, Scalar } from "./spreadsheet_types";
import type { SpreadsheetContext } from "./spreadsheet_view_types";
@@ -97,6 +98,7 @@ function modal(title: string, body: HTMLElement[], onRun: () => void, runLabel =
);
overlay.append(panel);
document.body.append(overlay);
dialogKeys(overlay, close, () => (onRun(), close()));
}
function readRange(ctx: SpreadsheetContext): CellRange | null {
@@ -138,7 +140,7 @@ export function splitFixedWidth(line: string, breaks: number[]): string[] {
return out;
}
/** 나뉜 줄들을 `anchor` 를 왼위로 삼아 칸에 씀(빈 글은 칸 지움). */
/** 나뉜 줄들을 `anchor` 를 왼위로 삼아 칸에 씀(빈 글은 값만 지움 · 칸 서식은 그대로). */
export function applyTextToColumns(
ctx: SpreadsheetContext,
anchor: CellAddress,
@@ -147,7 +149,12 @@ export function applyTextToColumns(
const cells: Record<string, Cell | null> = {};
rows.forEach((cols, ri) => {
cols.forEach((text, ci) => {
cells[toA1(anchor.r + ri, anchor.c + ci)] = text === "" ? null : { 값: cellInputFrom(text) };
const key = toA1(anchor.r + ri, anchor.c + ci);
const 서식 = ctx.sheet().칸[key]?.서식;
if (text !== "")
cells[key] =
서식 === undefined ? { 값: cellInputFrom(text) } : { 값: cellInputFrom(text), 서식 };
else cells[key] = 서식 === undefined ? null : { 서식 };
});
});
ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: cells });
@@ -377,7 +384,9 @@ export function applyFillSeries(
values.forEach((v, i) => {
const r = direction === "열" ? anchor.r + i : anchor.r;
const c = direction === "행" ? anchor.c + i : anchor.c;
cells[toA1(r, c)] = { 값: v };
const key = toA1(r, c);
const 서식 = ctx.sheet().칸[key]?.서식;
cells[key] = 서식 === undefined ? { 값: v } : { 값: v, 서식 };
});
ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: cells });
}
@@ -209,6 +209,12 @@ export function attachFilter(ctx: SpreadsheetContext): FilterHandle {
panel.style.top = `${box.y + box.h}px`;
sync();
layer.append(panel);
// 격자 아래 · 오른쪽 끝에 걸리면 위 · 왼쪽으로 뒤집음(잘려서 [확인] 이 안 보이지 않게)
const p = panel.getBoundingClientRect();
const g = ctx.grid.root.getBoundingClientRect();
if (p.bottom > g.bottom && p.top - box.h - p.height >= g.top)
panel.style.top = `${box.y - p.height}px`;
if (p.right > g.right) panel.style.left = `${Math.max(0, box.x + box.w - p.width)}px`;
document.addEventListener("mousedown", onOutside, true);
}
@@ -47,6 +47,7 @@
.ss-fmtdlg__body {
flex: 1;
min-height: 300px;
overflow-y: auto;
padding: var(--spacing-12) 2px;
}
@@ -22,6 +22,7 @@ import {
type Category,
type NegStyle,
} from "./spreadsheet_format_code";
import { dialogKeys } from "./spreadsheet_actions";
import { isAutoInk } from "./spreadsheet_cellstyle";
import { formatValue } from "./spreadsheet_numfmt";
import type { BorderLine, BorderSide, CellStyle, HAlign, VAlign } from "./spreadsheet_types";
@@ -480,35 +481,29 @@ export function openFormatDialog(ctx: SpreadsheetContext): void {
tabsBar.append(btn);
});
function apply(): void {
ctx.dispatch({ 종류: "서식", 시트: ctx.selection.시트, 범위: ctx.selection.범위, 바꿀: draft });
const isMerged = (ctx.sheet().병합 ?? []).includes(rangeToA1(range));
if (mergeWanted !== isMerged) {
ctx.dispatch({
종류: mergeWanted ? "병합" : "병합풀기",
시트: ctx.selection.시트,
범위: range,
});
}
close();
}
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();
},
}),
createButton({ label: TEXT.ok, variant: "filled", onClick: apply }),
],
});
panel.append(actions);
showTab(0);
document.body.append(overlay);
dialogKeys(overlay, close, apply);
}
+16 -8
View File
@@ -652,16 +652,19 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
const scrollW = Math.max(0, w - headColW() - frozenW);
const scrollH = Math.max(0, h - headRowH() - frozenH);
const frozenColsVisible = visibleIndices(colGeo, 0, frozenCols - 1);
const scrollColFrom = Math.max(frozenCols, colGeo.indexAt(paneBR.scrollLeft));
const scrollColTo = Math.min(extent.cols - 1, colGeo.indexAt(paneBR.scrollLeft + scrollW) + 1);
// 본문 판의 스크롤 0 = 틀고정 경계(frozenW · frozenH) — 시트 자리로 바꿔 재야 아래 · 오른쪽 끝까지 그림
const left = frozenW + paneBR.scrollLeft;
const top = frozenH + paneBR.scrollTop;
const scrollColFrom = Math.max(frozenCols, colGeo.indexAt(left));
const scrollColTo = Math.min(extent.cols - 1, colGeo.indexAt(left + scrollW) + 1);
const scrollColsVisible = visibleIndices(
colGeo,
scrollColFrom,
Math.max(scrollColFrom, scrollColTo),
);
const frozenRowsVisible = visibleIndices(rowGeo, 0, frozenRows - 1);
const scrollRowFrom = Math.max(frozenRows, rowGeo.indexAt(paneBR.scrollTop));
const scrollRowTo = Math.min(extent.rows - 1, rowGeo.indexAt(paneBR.scrollTop + scrollH) + 1);
const scrollRowFrom = Math.max(frozenRows, rowGeo.indexAt(top));
const scrollRowTo = Math.min(extent.rows - 1, rowGeo.indexAt(top + scrollH) + 1);
const scrollRowsVisible = visibleIndices(
rowGeo,
scrollRowFrom,
@@ -896,6 +899,7 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
function reveal(r: number, c: number): void {
ensureExtent(r, c);
const [top0, left0] = [paneBR.scrollTop, paneBR.scrollLeft];
if (r >= frozenRows) {
const y0 = rowGeo.offset(r) - rowGeo.offset(frozenRows);
const y1 = y0 + rowGeo.size(r);
@@ -912,16 +916,20 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
if (x0 < viewLeft) paneBR.scrollLeft = x0;
else if (x1 > viewRight) paneBR.scrollLeft = x1 - paneBR.clientWidth;
}
// scroll 이벤트는 다음 그림 때 옴 — 먼 칸으로 뛸 때 한 틀 비어 보이지 않게 바로 그림
if (paneBR.scrollTop !== top0 || paneBR.scrollLeft !== left0) onScroll();
}
function visibleRange(): CellRange {
const scrollW = Math.max(0, root.clientWidth - headColW() - frozenW);
const scrollH = Math.max(0, root.clientHeight - headRowH() - frozenH);
const top = frozenH + paneBR.scrollTop;
const left = frozenW + paneBR.scrollLeft;
return {
r0: rowGeo.indexAt(paneBR.scrollTop),
c0: colGeo.indexAt(paneBR.scrollLeft),
r1: Math.min(extent.rows - 1, rowGeo.indexAt(paneBR.scrollTop + scrollH)),
c1: Math.min(extent.cols - 1, colGeo.indexAt(paneBR.scrollLeft + scrollW)),
r0: Math.max(frozenRows, rowGeo.indexAt(top)),
c0: Math.max(frozenCols, colGeo.indexAt(left)),
r1: Math.min(extent.rows - 1, rowGeo.indexAt(top + scrollH)),
c1: Math.min(extent.cols - 1, colGeo.indexAt(left + scrollW)),
};
}
@@ -16,6 +16,7 @@
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
import { dialogKeys } from "./spreadsheet_actions";
import { parseA1, toA1 } from "./spreadsheet_address";
import type { Cell, CellRange, Sheet } from "./spreadsheet_types";
import type { SpreadsheetContext } from "./spreadsheet_view_types";
@@ -165,6 +166,7 @@ function modal(
);
overlay.append(panel);
document.body.append(overlay);
dialogKeys(overlay, close, () => (onOk(choice()), close()));
}
export function openInsertCellsDialog(ctx: SpreadsheetContext): void {
+2 -1
View File
@@ -209,7 +209,8 @@ export function mountLinks(
e.preventDefault();
const inList = opts?.모드 === "자재" && items.some((i) => i.키 === item.키);
if (ctx.readOnly || !(hit || inList)) return;
menu.open(e.clientX, e.clientY, [
const o = ctx.root.getBoundingClientRect(); // 메뉴는 부품 뿌리 기준 자리
menu.open(e.clientX - o.left, e.clientY - o.top, [
...(hit ? [[TEXT.unlink, () => (unlink(), back())] as [string, () => void]] : []),
...(inList
? [
+15 -6
View File
@@ -14,6 +14,7 @@
import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu";
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { rangeToA1 } from "./spreadsheet_address";
import { confirmMerge, dialogKeys } from "./spreadsheet_actions";
import { deleteComment, getComment, setComment } from "./spreadsheet_comments";
import { st } from "./spreadsheet_text";
import { MAX_COLS, MAX_ROWS, type CellRange } from "./spreadsheet_types";
@@ -67,6 +68,7 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle {
panel.append(textarea, actions);
overlay.append(panel);
document.body.append(overlay);
dialogKeys(overlay, done);
textarea.focus();
textarea.select();
}
@@ -87,12 +89,15 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle {
],
[
isMerged(range) ? st("MenuUnmerge") : st("MenuMerge"),
() =>
ctx.dispatch(
isMerged(range)
? { 종류: "병합풀기", 시트: ctx.selection.시트, 범위: range }
: { 종류: "병합", 시트: ctx.selection.시트, 범위: range },
),
async () => {
const merged = isMerged(range);
if (!merged && !(await confirmMerge(ctx, range))) return;
ctx.dispatch({
종류: merged ? "병합풀기" : "병합",
시트: ctx.selection.시트,
범위: range,
});
},
],
[
note ? st("MenuCommentEdit") : st("MenuCommentInsert"),
@@ -108,7 +113,9 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle {
const at = Math.min(...rows);
const 수 = rows.length;
return [
[st("MenuCut"), () => cutCopy("cut")],
[st("MenuCopy"), () => cutCopy("copy")],
[st("MenuPasteHint"), () => showToast(st("MenuPasteHint"), "info")],
[
st("MenuInsertRow"),
() => ctx.dispatch({ 종류: "행넣기", 시트: ctx.selection.시트, at, 수 }),
@@ -146,7 +153,9 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle {
const at = Math.min(...cols);
const 수 = cols.length;
return [
[st("MenuCut"), () => cutCopy("cut")],
[st("MenuCopy"), () => cutCopy("copy")],
[st("MenuPasteHint"), () => showToast(st("MenuPasteHint"), "info")],
[
st("MenuInsertCol"),
() => ctx.dispatch({ 종류: "열넣기", 시트: ctx.selection.시트, at, 수 }),
+19 -5
View File
@@ -29,6 +29,7 @@ const NAME_RE = /^[\p{L}_][\p{L}\p{N}_.]*$/u;
/** 이름 규칙 — 글자 · 밑줄로 시작 · 칸 주소처럼 안 보임 · 겹치지 않음. 어기면 까닭 글 · 되면 null. */
export function invalidNameReason(name: string, existing: string[]): string | null {
if (!name) return "이름이 비었음";
if (name.length > 255) return "255 글자까지";
if (!NAME_RE.test(name)) return "글자 · 숫자 · 밑줄만(첫 글자는 글자 · 밑줄)";
if (CELL_LIKE.test(name)) return "칸 주소처럼 보이는 이름은 못 씀";
if (RESERVED.test(name)) return "TRUE · FALSE 는 못 씀";
@@ -168,13 +169,26 @@ export function createNameManagerPanel(ctx: SpreadsheetContext): PartHandle {
list.replaceChildren(
...listNames(ctx.book).map(({ 이름, 시트, 범위 }) => {
const li = el("li", { className: "ss-names__row" });
const label = el("span", { text: `${이름} = ${시트}!${rangeToA1(범위)}` });
const sheetName = ctx.book.시트.find((s) => s.id === 시트)?.이름 ?? 시트;
const label = el("span", { text: `${이름} = ${sheetName}!${rangeToA1(범위)}` });
const rename = createButton({ label: "이름 바꾸기", variant: "ghost" });
// 창 안에서 바로 고침(브라우저 prompt 는 자동화 창에서 저절로 취소됨) — Enter 바꿈 · Esc 그만
rename.addEventListener("click", () => {
const next = window.prompt("새 이름", 이름);
if (!next || next === 이름) return;
showError(renameName(ctx, 이름, next));
render();
const input = el("input", { className: "ui-input" }) as HTMLInputElement;
input.value = 이름;
const finish = (commit: boolean): void => {
const next = input.value.trim();
if (commit && next && next !== 이름) showError(renameName(ctx, 이름, next));
render();
};
input.addEventListener("keydown", (e) => {
if (e.key !== "Enter" && e.key !== "Escape") return;
e.stopPropagation();
finish(e.key === "Enter");
});
label.replaceWith(input);
input.focus();
input.select();
});
const del = createButton({ label: "지움", variant: "danger" });
del.addEventListener("click", () => {
@@ -589,6 +589,9 @@ function formatNumber(value: Frac, code: string | undefined): FormattedText {
return { 글: (forceMinus ? "-" : "") + body, 정렬: "right", ...extra };
}
if (!toks.some((t) => t.k === "digit" || t.k === "general")) {
// 글 자리(@)뿐인 형식을 수에 걸면 엑셀처럼 일반으로 보임(빈 칸이 되지 않게)
if (toks.some((t) => t.k === "text"))
return { 글: generalFormat(value), 정렬: "right", ...extra };
let out = "";
for (const t of toks) if (t.k === "lit") out += t.ch;
return { 글: out, 정렬: "right", ...extra };
@@ -70,6 +70,7 @@
position: absolute;
left: 0;
top: 0;
z-index: 5;
width: 0;
height: 0;
overflow: visible;
@@ -47,6 +47,8 @@
.ss-validation__row > span:first-of-type,
.ss-names__row > span:first-of-type {
flex: 1;
min-width: 0;
overflow-wrap: anywhere;
}
.ss-condfmt__swatch {
@@ -109,5 +111,5 @@
}
.ss-statusbar__zoom {
color: var(--color-text-muted);
color: var(--color-text-secondary);
}
@@ -39,6 +39,10 @@ const TEXT = {
Wrap: ["줄 바꿈", "Wrap text"],
Merge: ["병합", "Merge"],
Unmerge: ["병합 해제", "Unmerge"],
MergeLoseConfirm: [
"왼위 칸 값만 남고 나머지 칸 값은 지워짐 — 병합할까?",
"Merging keeps only the upper-left value — merge anyway?",
],
Border: ["테두리", "Border"],
Border_none: ["없음", "None"],
Border_all: ["모두", "All"],
@@ -89,13 +89,15 @@
padding: 0 4px;
border: 0;
background: none;
color: var(--color-text-muted);
color: var(--color-text-secondary);
font-size: 0.72rem;
cursor: pointer;
}
.ss-toolbar__popover {
position: absolute;
top: calc(100% + 4px);
left: 0;
z-index: var(--z-dropdown);
display: flex;
flex-direction: column;
@@ -163,19 +165,18 @@
border: 0;
padding: 0;
background: none;
color: var(--color-text-muted);
color: var(--color-text-secondary);
font-size: 0.72rem;
text-align: right;
opacity: 0.6;
cursor: pointer;
}
.ss-toolbar__formula-hint:hover {
opacity: 1;
color: var(--color-text-body);
}
.ss-toolbar__formula-hint-detail {
color: var(--color-text-muted);
color: var(--color-text-secondary);
font-size: 0.68rem;
text-align: right;
}
@@ -11,6 +11,7 @@
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
import { confirmMerge } from "./spreadsheet_actions";
import { colName, rangeToA1, toA1 } from "./spreadsheet_address";
import { cellToText, textToCell } from "./spreadsheet_editor";
import { st } from "./spreadsheet_text";
@@ -151,7 +152,7 @@ export function mountToolbar(ctx: SpreadsheetContext): PartHandle {
}) as HTMLInputElement;
sizeInput.addEventListener("change", () => {
const n = Number(sizeInput.value);
patch({ 크기: sizeInput.value && Number.isFinite(n) && n > 0 ? n : null });
patch({ 크기: sizeInput.value && Number.isFinite(n) && n > 0 ? Math.min(n, 409) : null });
});
// ── 굵게 · 기울임 · 밑줄 · 취소선 ──────────────────────────────────────
@@ -240,10 +241,11 @@ export function mountToolbar(ctx: SpreadsheetContext): PartHandle {
// ── 병합 ───────────────────────────────────────────────────────────────
const mergeBtn = createButton({ label: st("Merge"), variant: "ghost" });
mergeBtn.addEventListener("click", () => {
mergeBtn.addEventListener("click", async () => {
const range = ctx.selection.범위[0];
if (!range) return;
const merged = (ctx.sheet().병합 ?? []).includes(rangeToA1(range));
if (!merged && !(await confirmMerge(ctx, range))) return;
ctx.dispatch({ 종류: merged ? "병합풀기" : "병합", 시트: ctx.selection.시트, 범위: range });
});
@@ -503,7 +505,7 @@ export function mountToolbar(ctx: SpreadsheetContext): PartHandle {
textColor.value = style.글자색 ?? "#000000";
fillColor.value = style.채움 ?? "#ffffff";
hAlignSelect.value = style.가로 ?? "general";
vAlignSelect.value = style.세로 ?? "top";
vAlignSelect.value = style.세로 ?? "bottom";
wrapBtn.classList.toggle("is-on", !!style.줄바꿈);
numfmtInput.value = style.형식 ?? "";
const range = ctx.selection.범위[0];
@@ -11,7 +11,8 @@
* 단추 아래 작은 메뉴가 뜸.
* ========================================================================== */
import { createButton, createInputField, el } from "@ui/ui_template_elements";
import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements";
import { dialogKeys } from "./spreadsheet_actions";
import { parseRange, rangeToA1 } from "./spreadsheet_address";
import type { CellRange } from "./spreadsheet_types";
import type { SpreadsheetContext } from "./spreadsheet_view_types";
@@ -106,6 +107,7 @@ export function openGoToDialog(ctx: SpreadsheetContext): void {
);
overlay.append(panel);
document.body.append(overlay);
dialogKeys(overlay, close);
addressField.input.focus();
addressField.input.select();
}
@@ -135,6 +137,13 @@ export function freezeFirstColumn(ctx: SpreadsheetContext): void {
/** 활성 칸의 왼위 자리를 기준으로 그 위 · 왼쪽을 고정(활성 칸이 A1 이면 사실상 풀기와 같음). */
export function freezeAtActiveCell(ctx: SpreadsheetContext): void {
const at = ctx.selection.활성;
// 고정 판이 격자를 거의 다 덮으면 나머지가 안 움직임(시트 머리부터 고정) — 화면 안쪽 칸만
const box = ctx.grid.cellBox(at.r, at.c);
const g = ctx.grid.root;
if (box.y > g.clientHeight * 0.8 || box.x > g.clientWidth * 0.8) {
showToast("고정할 칸이 너무 아래 · 오른쪽 — 화면 안쪽 칸에서 고정할 것", "warning");
return;
}
ctx.dispatch({ 종류: "틀고정", 시트: ctx.selection.시트, 행: at.r, 열: at.c });
}
@@ -414,6 +414,7 @@ ok("0.00", fmt(frac(BigInt(150), 100n), "0.00") === "1.50", fmt(frac(150n, 100n)
ok("#,##0", fmt(frac(1234567n), "#,##0") === "1,234,567", fmt(frac(1234567n), "#,##0"));
ok("천 나눔 끝쉼표", fmt(frac(1234000n), "#,##0,") === "1,234", fmt(frac(1234000n), "#,##0,"));
ok("퍼센트", fmt(frac(50n, 100n), "0%") === "50%", fmt(frac(50n, 100n), "0%"));
ok("글 형식(@) 수는 일반으로", fmt(frac(2200n), "@") === "2200", fmt(frac(2200n), "@"));
// 실무 구조도 xlsx 세 벌 실측 코드(2026-09-27 대조) — 회귀 방지.
ok(
@@ -2,8 +2,19 @@
* test_stage3_autosum.ts — spreadsheet_autosum 시험. 돌리기: npx tsx <이 파일>.
* ========================================================================== */
import { applyAutoSum, autoSumRange } from "../../../A00_Common/spreadsheet/spreadsheet_autosum.ts";
import { assertEqual, fracLiteral, makeBook, makeCtx, makeSheet } from "./test_stage2_helpers.ts";
import {
applyAutoSum,
autoSumCells,
autoSumRange,
} from "../../../A00_Common/spreadsheet/spreadsheet_autosum.ts";
import {
assertEqual,
fracLiteral,
makeBook,
makeCtx,
makeSheet,
v,
} from "./test_stage2_helpers.ts";
// 위로 연속 숫자 셋 — A1,A2,A3 숫자 · A4(활성) 빈칸
const sheet = makeSheet("s1", {});
@@ -56,5 +67,22 @@ assertEqual(
"고른 범위 안에서 활성 위만 잡음",
);
// 여러 칸을 고른 채 Σ — 고른 칸을 덮지 않고 열마다 바로 아래 빈 칸에 합계(엑셀)
const colSheet = makeSheet("s2", { B1: v(1), B2: v(2), C1: v(3), C2: v(4), C3: v(9) });
const ctxCols = makeCtx(makeBook([colSheet]), "s2");
ctxCols.selection.활성 = { r: 0, c: 1 };
ctxCols.selection.범위 = [{ r0: 0, c0: 1, r1: 1, c1: 2 }];
assertEqual(
autoSumCells(ctxCols),
{ B3: { 식: "SUM(B1:B2)" } },
"열마다 아래 빈 칸 · 찬 칸(C3)은 안 덮음",
);
ctxCols.selection.범위 = [{ r0: 0, c0: 1, r1: 2, c1: 1 }];
assertEqual(autoSumCells(ctxCols), { B3: { 식: "SUM(B1:B2)" } }, "고름 끝이 빈 칸이면 그 칸에");
ctxCols.selection.범위 = [{ r0: 0, c0: 1, r1: 0, c1: 2 }];
assertEqual(autoSumCells(ctxCols), { D1: { 식: "SUM(B1:C1)" } }, "한 행이면 오른쪽 끝 다음 칸에");
ctxCols.selection.범위 = [{ r0: 0, c0: 1, r1: 0, c1: 1 }];
assertEqual(autoSumCells(ctxCols), null, "한 칸이면 null(편집으로 엶)");
if (process.exitCode) process.exit(process.exitCode);
console.log("spreadsheet_autosum 시험 끝");
+17
View File
@@ -45,6 +45,17 @@ function bindGlobalClose(): void {
},
true,
);
// Esc 도 열린 메뉴를 닫음 — 메뉴가 열렸을 때만 키를 먹음(편집 취소 등 아래로 안 샘)
document.addEventListener(
"keydown",
(event) => {
if (event.key !== "Escape" || !openMenus.size) return;
openMenus.forEach((menu) => menu.close());
event.stopPropagation();
event.preventDefault();
},
true,
);
}
/** `blockClass`는 페이지 CSS 블록명(예: `b04-map`, `b05-drainage`). */
@@ -119,6 +130,12 @@ export function createMapContextMenu(blockClass: string): MapContextMenu {
element.style.left = `${x}px`;
element.style.top = `${y}px`;
element.hidden = false;
// 화면 밖으로 나가면 안쪽으로 당김(오른쪽 · 아래 끝에서 연 메뉴)
const box = element.getBoundingClientRect();
const dx = Math.max(0, box.right - window.innerWidth + 4);
const dy = Math.max(0, box.bottom - window.innerHeight + 4);
if (dx) element.style.left = `${Math.max(x - dx, x - box.left)}px`;
if (dy) element.style.top = `${Math.max(y - dy, y - box.top)}px`;
},
close,
contains: (target) => target instanceof Node && element.contains(target),