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:
@@ -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(() => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
? [
|
||||
|
||||
@@ -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, 수 }),
|
||||
|
||||
@@ -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 시험 끝");
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user