diff --git a/A00_Common/spreadsheet/spreadsheet.css b/A00_Common/spreadsheet/spreadsheet.css index 87a49d13..4577256a 100644 --- a/A00_Common/spreadsheet/spreadsheet.css +++ b/A00_Common/spreadsheet/spreadsheet.css @@ -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); diff --git a/A00_Common/spreadsheet/spreadsheet_actions.ts b/A00_Common/spreadsheet/spreadsheet_actions.ts index 9fae231f..bf5dc6c1 100644 --- a/A00_Common/spreadsheet/spreadsheet_actions.ts +++ b/A00_Common/spreadsheet/spreadsheet_actions.ts @@ -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 = { const SHEET_ACTS = new Set(["next", "prev", "newSheet"]); +/** 병합 전 확인 — 왼위 밖 칸에 값이 있으면 지워짐을 물음(엑셀과 같음) · 없으면 바로 true */ +export async function confirmMerge(ctx: SpreadsheetContext, range: CellRange): Promise { + 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("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(() => { diff --git a/A00_Common/spreadsheet/spreadsheet_autosum.ts b/A00_Common/spreadsheet/spreadsheet_autosum.ts index defa1eda..fc051c4b 100644 --- a/A00_Common/spreadsheet/spreadsheet_autosum.ts +++ b/A00_Common/spreadsheet/spreadsheet_autosum.ts @@ -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 | 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 = {}; + 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); diff --git a/A00_Common/spreadsheet/spreadsheet_condfmt.ts b/A00_Common/spreadsheet/spreadsheet_condfmt.ts index e9168ecd..4e07e779 100644 --- a/A00_Common/spreadsheet/spreadsheet_condfmt.ts +++ b/A00_Common/spreadsheet/spreadsheet_condfmt.ts @@ -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, b: ReturnType, ): 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; diff --git a/A00_Common/spreadsheet/spreadsheet_data_menu.ts b/A00_Common/spreadsheet/spreadsheet_data_menu.ts index 307c2293..63390c33 100644 --- a/A00_Common/spreadsheet/spreadsheet_data_menu.ts +++ b/A00_Common/spreadsheet/spreadsheet_data_menu.ts @@ -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()"); } diff --git a/A00_Common/spreadsheet/spreadsheet_data_tools.ts b/A00_Common/spreadsheet/spreadsheet_data_tools.ts index 4857c782..94e727c9 100644 --- a/A00_Common/spreadsheet/spreadsheet_data_tools.ts +++ b/A00_Common/spreadsheet/spreadsheet_data_tools.ts @@ -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 = {}; 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 }); } diff --git a/A00_Common/spreadsheet/spreadsheet_filter.ts b/A00_Common/spreadsheet/spreadsheet_filter.ts index 328267d2..db29c516 100644 --- a/A00_Common/spreadsheet/spreadsheet_filter.ts +++ b/A00_Common/spreadsheet/spreadsheet_filter.ts @@ -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); } diff --git a/A00_Common/spreadsheet/spreadsheet_format_dialog.css b/A00_Common/spreadsheet/spreadsheet_format_dialog.css index 9d48ee59..89f79741 100644 --- a/A00_Common/spreadsheet/spreadsheet_format_dialog.css +++ b/A00_Common/spreadsheet/spreadsheet_format_dialog.css @@ -47,6 +47,7 @@ .ss-fmtdlg__body { flex: 1; + min-height: 300px; overflow-y: auto; padding: var(--spacing-12) 2px; } diff --git a/A00_Common/spreadsheet/spreadsheet_format_dialog.ts b/A00_Common/spreadsheet/spreadsheet_format_dialog.ts index a743c20f..51a2b576 100644 --- a/A00_Common/spreadsheet/spreadsheet_format_dialog.ts +++ b/A00_Common/spreadsheet/spreadsheet_format_dialog.ts @@ -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); } diff --git a/A00_Common/spreadsheet/spreadsheet_grid.ts b/A00_Common/spreadsheet/spreadsheet_grid.ts index 99811a22..41e696fc 100644 --- a/A00_Common/spreadsheet/spreadsheet_grid.ts +++ b/A00_Common/spreadsheet/spreadsheet_grid.ts @@ -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)), }; } diff --git a/A00_Common/spreadsheet/spreadsheet_insert_dialog.ts b/A00_Common/spreadsheet/spreadsheet_insert_dialog.ts index 59b8009d..9f3989db 100644 --- a/A00_Common/spreadsheet/spreadsheet_insert_dialog.ts +++ b/A00_Common/spreadsheet/spreadsheet_insert_dialog.ts @@ -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 { diff --git a/A00_Common/spreadsheet/spreadsheet_links.ts b/A00_Common/spreadsheet/spreadsheet_links.ts index 0be3ca67..f3e858f8 100644 --- a/A00_Common/spreadsheet/spreadsheet_links.ts +++ b/A00_Common/spreadsheet/spreadsheet_links.ts @@ -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 ? [ diff --git a/A00_Common/spreadsheet/spreadsheet_menu.ts b/A00_Common/spreadsheet/spreadsheet_menu.ts index cc4d585b..39293393 100644 --- a/A00_Common/spreadsheet/spreadsheet_menu.ts +++ b/A00_Common/spreadsheet/spreadsheet_menu.ts @@ -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, 수 }), diff --git a/A00_Common/spreadsheet/spreadsheet_names.ts b/A00_Common/spreadsheet/spreadsheet_names.ts index af2a36f9..ba7619de 100644 --- a/A00_Common/spreadsheet/spreadsheet_names.ts +++ b/A00_Common/spreadsheet/spreadsheet_names.ts @@ -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", () => { diff --git a/A00_Common/spreadsheet/spreadsheet_numfmt.ts b/A00_Common/spreadsheet/spreadsheet_numfmt.ts index 506bd21b..c146710b 100644 --- a/A00_Common/spreadsheet/spreadsheet_numfmt.ts +++ b/A00_Common/spreadsheet/spreadsheet_numfmt.ts @@ -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 }; diff --git a/A00_Common/spreadsheet/spreadsheet_sortfilter.css b/A00_Common/spreadsheet/spreadsheet_sortfilter.css index 2d545d29..36244d64 100644 --- a/A00_Common/spreadsheet/spreadsheet_sortfilter.css +++ b/A00_Common/spreadsheet/spreadsheet_sortfilter.css @@ -70,6 +70,7 @@ position: absolute; left: 0; top: 0; + z-index: 5; width: 0; height: 0; overflow: visible; diff --git a/A00_Common/spreadsheet/spreadsheet_stage3.css b/A00_Common/spreadsheet/spreadsheet_stage3.css index 453ab197..d7420bd7 100644 --- a/A00_Common/spreadsheet/spreadsheet_stage3.css +++ b/A00_Common/spreadsheet/spreadsheet_stage3.css @@ -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); } diff --git a/A00_Common/spreadsheet/spreadsheet_text.ts b/A00_Common/spreadsheet/spreadsheet_text.ts index 29d5b1da..7207a032 100644 --- a/A00_Common/spreadsheet/spreadsheet_text.ts +++ b/A00_Common/spreadsheet/spreadsheet_text.ts @@ -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"], diff --git a/A00_Common/spreadsheet/spreadsheet_toolbar.css b/A00_Common/spreadsheet/spreadsheet_toolbar.css index 80a619e5..f9776ec1 100644 --- a/A00_Common/spreadsheet/spreadsheet_toolbar.css +++ b/A00_Common/spreadsheet/spreadsheet_toolbar.css @@ -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; } diff --git a/A00_Common/spreadsheet/spreadsheet_toolbar.ts b/A00_Common/spreadsheet/spreadsheet_toolbar.ts index e736c526..5ff07182 100644 --- a/A00_Common/spreadsheet/spreadsheet_toolbar.ts +++ b/A00_Common/spreadsheet/spreadsheet_toolbar.ts @@ -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]; diff --git a/A00_Common/spreadsheet/spreadsheet_view_menu.ts b/A00_Common/spreadsheet/spreadsheet_view_menu.ts index c2436459..4302401d 100644 --- a/A00_Common/spreadsheet/spreadsheet_view_menu.ts +++ b/A00_Common/spreadsheet/spreadsheet_view_menu.ts @@ -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 }); } diff --git a/resources/tester/spreadsheet/spreadsheet_b_test_entry.ts b/resources/tester/spreadsheet/spreadsheet_b_test_entry.ts index 004fe333..5e65a6b0 100644 --- a/resources/tester/spreadsheet/spreadsheet_b_test_entry.ts +++ b/resources/tester/spreadsheet/spreadsheet_b_test_entry.ts @@ -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( diff --git a/resources/tester/spreadsheet/test_stage3_autosum.ts b/resources/tester/spreadsheet/test_stage3_autosum.ts index f0736521..8b32856d 100644 --- a/resources/tester/spreadsheet/test_stage3_autosum.ts +++ b/resources/tester/spreadsheet/test_stage3_autosum.ts @@ -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 시험 끝"); diff --git a/ui_template/ui_template_context_menu.ts b/ui_template/ui_template_context_menu.ts index 3998986c..60ebf277 100644 --- a/ui_template/ui_template_context_menu.ts +++ b/ui_template/ui_template_context_menu.ts @@ -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),