From 76f917813be3edc3c79f9e39a6849c8259279add Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 27 Sep 2026 22:24:30 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix(spreadsheet):=20D=20=ED=8E=B8=EC=A7=91?= =?UTF-8?q?=EA=B8=B0=20=E2=80=94=20=ED=8B=80=EB=A6=B0=20=EC=8B=9D(?= =?UTF-8?q?=EC=9D=B8=EC=9E=90=20=EC=88=98=20=EB=93=B1)=EC=9D=80=20Enter=20?= =?UTF-8?q?=EB=A1=9C=20=ED=99=95=EC=A0=95=ED=95=98=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 확정 때 = 로 시작하면 parseFormula(sub3 a3434e3c) — 실패면 편집 유지 · 오류 알림 · 커서를 오류 자리로(엑셀처럼) - M02 [스프레드시트 시험] ORCA: =SUM() Enter 막힘 · 「SUM 인수가 너무 적음(1 개부터)」 알림 · =SUM(1,2) → 3 - 크기 시험 통과(화면 28,613 B) · D 규칙 시험 9 통과 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01L6KXAabDTenEU7hrDQmCKY --- A00_Common/spreadsheet/spreadsheet_editor.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/A00_Common/spreadsheet/spreadsheet_editor.ts b/A00_Common/spreadsheet/spreadsheet_editor.ts index fc4de019..3ede2592 100644 --- a/A00_Common/spreadsheet/spreadsheet_editor.ts +++ b/A00_Common/spreadsheet/spreadsheet_editor.ts @@ -13,7 +13,7 @@ import { moveFormula } from "./spreadsheet_refshift"; import { colName, parseA1, parseRange, rangeToA1, toA1 } from "./spreadsheet_address"; import { funcHelp, suggestFunctions, type FuncHelp } from "./spreadsheet_func_help"; import { defineName, resolveName } from "./spreadsheet_names"; -import { tokenize } from "./spreadsheet_parser"; +import { parseFormula, tokenize } from "./spreadsheet_parser"; import { cellsOf, move, selectCell } from "./spreadsheet_selection"; import { checkValue, findValidation } from "./spreadsheet_validation"; import type { Cell, CellAddress, CellStyle, Sheet } from "./spreadsheet_types"; @@ -356,9 +356,19 @@ export function createEditor(ctx: SpreadsheetContext): Editor { focus(); } - /** 칸에 적음 — 유효성(sub4) 「막음」 을 어기면 안 적고 false(편집 그대로 · 엑셀처럼 다시 치게) */ + /** 칸에 적음 — 식이 틀렸거나(인자 수 등) 유효성(sub4) 「막음」 을 어기면 안 적고 false(편집 그대로 · 엑셀처럼 다시 치게) */ function write(cells: CellAddress[], value: string, from: CellAddress): boolean { if (!st) return true; + const parsed = value.length > 1 && value[0] === "=" ? parseFormula(value) : null; + if (parsed && !parsed.ok) { + showToast(parsed.message, "error"); + const node = src(); + const at = Math.min(parsed.at + 1, node.value.length); + st.mode = "edit"; // 방향키가 글자 사이를 움직이게 + st.point = null; + node.setSelectionRange(at, at); + return false; + } const sheet = sheetOf(st.sheet); const out: Record = {}; const warn = new Set(); From b59de25664a019c17171c9358e095da07d6699ef Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 27 Sep 2026 22:25:22 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix(spreadsheet):=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=EC=A4=84=20=EC=96=B4=EB=91=90=EC=9A=B4=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EA=B8=80=EC=9E=90=20=EC=95=88=20=EB=B3=B4?= =?UTF-8?q?=EC=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit spreadsheet_stage3.css .ss-statusbar·.ss-statusbar__zoom 에 테마 글자색 토큰(--color-text-body·--color-text-muted) 지정 — 전엔 색을 안 지정해 어두운 바탕에 기본 검정 글자가 겹침. ORCA(포트 5179)에서 라이트·다크 둘 다 계산된 색으로 확인: 다크 글자 rgb(228,224,240)/바탕 rgb(30,25,48), 라이트 글자 rgb(51,51,51)/바탕 rgb(246,247,250) — 둘 다 읽힘. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EUypcnp5d1gU2aeKh9F2H7 --- A00_Common/spreadsheet/spreadsheet_stage3.css | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/A00_Common/spreadsheet/spreadsheet_stage3.css b/A00_Common/spreadsheet/spreadsheet_stage3.css index 68cf46de..549d2588 100644 --- a/A00_Common/spreadsheet/spreadsheet_stage3.css +++ b/A00_Common/spreadsheet/spreadsheet_stage3.css @@ -98,14 +98,16 @@ background: var(--color-surface); border-top: 1px solid var(--color-border); font-size: 0.8rem; + color: var(--color-text-body); } .ss-statusbar__stats { display: flex; gap: var(--spacing-8); flex: 1; + color: var(--color-text-body); } .ss-statusbar__zoom { - color: var(--color-border); + color: var(--color-text-muted); } From 35d0ce3853b23535f04da792e38df5fece793675 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 27 Sep 2026 22:32:20 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix(spreadsheet):=20=EC=97=91=EC=85=80=20?= =?UTF-8?q?=EB=B6=99=EC=97=AC=EB=84=A3=EA=B8=B0=20=EC=84=9C=EC=8B=9D=20?= =?UTF-8?q?=C2=B7=20=EB=B3=91=ED=95=A9=20=EB=B6=80=EB=B6=84=20=EB=8D=AE?= =?UTF-8?q?=EC=96=B4=EC=93=B0=EA=B8=B0=20=EB=A7=89=EA=B8=B0=20=C2=B7=20?= =?UTF-8?q?=EB=8F=84=EA=B5=AC=20=EB=AA=A8=EC=9D=8C=20=EA=B8=B0=EB=B3=B8=20?= =?UTF-8?q?=EC=84=9C=EC=8B=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - parseHtmlTable 이 td 인라인 style · ` 안 `.xl65 {...}` 짜임(엑셀이 인라인 대신 클래스로 서식을 실을 때) → 클래스별 선언. */ +function parseClassRules(doc: Document): Record> { + const css = Array.from(doc.querySelectorAll("style")) + .map((s) => s.textContent ?? "") + .join("\n"); + const rules: Record> = {}; + for (const m of css.matchAll(/\.([a-zA-Z0-9_-]+)\s*\{([^}]*)\}/g)) { + rules[m[1]] = parseStyleAttr(m[2]); + } + return rules; +} + +function styleFromTd( + cellEl: HTMLTableCellElement, + classRules: Record>, +): CellStyle | undefined { + const decls: Record = {}; + for (const cls of cellEl.classList) Object.assign(decls, classRules[cls]); + const raw = cellEl.getAttribute("style"); + if (raw) Object.assign(decls, parseStyleAttr(raw)); + if (!Object.keys(decls).length) return undefined; + const style: CellStyle = {}; + const weight = decls["font-weight"]; + if (weight === "bold" || Number(weight) >= 700) style.굵게 = true; + if (decls["font-style"] === "italic") style.기울임 = true; + const color = cssColorToHex(decls["color"]); + if (color) style.글자색 = color; + const bg = cssColorToHex(decls["background-color"] ?? decls["background"]); + if (bg) style.채움 = bg; + const align = decls["text-align"]; + if (align === "left" || align === "center" || align === "right") style.가로 = align; + if (decls["mso-number-format"]) style.형식 = unescapeMsoNumFmt(decls["mso-number-format"]); + const 테두리: NonNullable = {}; + const sideMap: [string, "위" | "아래" | "왼" | "오른"][] = [ + ["border-top", "위"], + ["border-bottom", "아래"], + ["border-left", "왼"], + ["border-right", "오른"], + ]; + for (const [prop, side] of sideMap) { + const b = parseBorderShorthand(decls[prop] ?? decls["border"] ?? ""); + if (b) 테두리[side] = b; + } + if (Object.keys(테두리).length) style.테두리 = 테두리; + return Object.keys(style).length ? style : undefined; +} + function parseHtmlTable(html: string, at: CellAddress): ClipBlock | null { const doc = new DOMParser().parseFromString(html, "text/html"); const table = doc.querySelector("table"); if (!table) return null; const walk = walkTable(table); const sheetsMode = /data-sheets-formula/.test(html); + const classRules = parseClassRules(doc); const 칸: Record = {}; for (const [key, cellEl] of walk.owner) { const [r, c] = key.split(",").map(Number); @@ -229,8 +331,14 @@ function parseHtmlTable(html: string, at: CellAddress): ClipBlock | null { 칸[key] = { 식: r1c1ToA1(formula.replace(/^=/, ""), target) }; continue; } - if (!text) continue; - 칸[key] = cellFromText(text); + const style = styleFromTd(cellEl, classRules); + if (!text) { + if (style) 칸[key] = { 서식: style }; + continue; + } + const clip = cellFromText(text); + if (style) clip.서식 = style; + 칸[key] = clip; } return { rows: walk.rows, @@ -286,7 +394,48 @@ function activeRange(ctx: SpreadsheetContext): CellRange { ); } +function rangesOverlap(a: CellRange, b: CellRange): boolean { + return a.r0 <= b.r1 && a.r1 >= b.r0 && a.c0 <= b.c1 && a.c1 >= b.c0; +} + +function rangeContains(outer: CellRange, inner: CellRange): boolean { + return ( + outer.r0 <= inner.r0 && outer.r1 >= inner.r1 && outer.c0 <= inner.c0 && outer.c1 >= inner.c1 + ); +} + +/** 붙일 자리가 기존 병합의 일부만 걸치면 막음(엑셀 「병합된 셀의 일부를 바꿀 수 없습니다」) — 병합을 + * 통째로 덮거나(허용) · 병합 한 칸에 값 하나만 얹는 것(허용)은 됨. */ +function findBlockingMerge(sheet: Sheet, target: CellRange): boolean { + const isSingleCell = target.r0 === target.r1 && target.c0 === target.c1; + for (const m of sheet.병합 ?? []) { + const mr = parseRange(m); + if (!mr || !rangesOverlap(target, mr)) continue; + if (rangeContains(target, mr)) continue; + if (isSingleCell && rangeContains(mr, target)) continue; + return true; + } + return false; +} + function pasteBlock(ctx: SpreadsheetContext, block: ClipBlock, at: CellAddress): void { + const target: CellRange = { + r0: at.r, + c0: at.c, + r1: at.r + block.rows - 1, + c1: at.c + block.cols - 1, + }; + const sheet = ctx.sheet(); + if (findBlockingMerge(sheet, target)) { + showToast(st("ClipboardMergeBlocked"), "error"); + return; + } + // 붙일 자리가 기존 병합을 통째로 덮으면 먼저 풂(엑셀도 큰 덩어리를 얹으면 병합이 풀리고 칸마다 값이 옴) — + // 안 풀면 A `applyCommand` 가 병합 속 왼위 아닌 칸에 값을 넣으려 할 때 막음. + const unmergeCmds: Command[] = (sheet.병합 ?? []) + .map((m) => parseRange(m)) + .filter((m): m is CellRange => !!m && rangeContains(target, m)) + .map((범위) => ({ 종류: "병합풀기", 시트: ctx.selection.시트, 범위 })); const shift = block.원점 ? { dr: at.r - block.원점.r, dc: at.c - block.원점.c } : null; const 칸: Record = {}; const styleCmds: Command[] = []; @@ -307,7 +456,11 @@ function pasteBlock(ctx: SpreadsheetContext, block: ClipBlock, at: CellAddress): }); } } - const commands: Command[] = [{ 종류: "칸", 시트: ctx.selection.시트, 칸 }, ...styleCmds]; + const commands: Command[] = [ + ...unmergeCmds, + { 종류: "칸", 시트: ctx.selection.시트, 칸 }, + ...styleCmds, + ]; for (const m of block.병합) { commands.push({ 종류: "병합", diff --git a/A00_Common/spreadsheet/spreadsheet_text.ts b/A00_Common/spreadsheet/spreadsheet_text.ts index 65c96852..df33f3d5 100644 --- a/A00_Common/spreadsheet/spreadsheet_text.ts +++ b/A00_Common/spreadsheet/spreadsheet_text.ts @@ -91,6 +91,10 @@ const TEXT = { MenuCommentEdit: ["메모 편집", "Edit comment"], MenuCommentDelete: ["메모 삭제", "Delete comment"], ClipboardPasteFailed: ["붙여넣기를 읽지 못함", "Could not read paste content"], + ClipboardMergeBlocked: [ + "병합된 셀의 일부를 바꿀 수 없습니다", + "Can't change part of a merged cell", + ], } as const; export function st(key: keyof typeof TEXT): string { diff --git a/A00_Common/spreadsheet/spreadsheet_toolbar.ts b/A00_Common/spreadsheet/spreadsheet_toolbar.ts index 30846b46..21d20b15 100644 --- a/A00_Common/spreadsheet/spreadsheet_toolbar.ts +++ b/A00_Common/spreadsheet/spreadsheet_toolbar.ts @@ -53,11 +53,16 @@ const BORDER_LINES: BorderLine[] = [ "dashed", ]; -/** 활성 칸에 지금 걸린 서식 — 칸 자기 것 없으면 열 · 행 · 그다음 기본(0). */ +/** 활성 칸에 지금 걸린 서식 — 칸 자기 것 없으면 열 · 행 · 시트 기본(`Sheet.기본.서식`) · 그다음 0. */ function styleAt(ctx: SpreadsheetContext, r: number, c: number): CellStyle { const sheet = ctx.sheet(); const cell = sheet.칸[toA1(r, c)]; - const idx = cell?.서식 ?? sheet.열?.[colName(c)]?.서식 ?? sheet.행?.[String(r + 1)]?.서식 ?? 0; + const idx = + cell?.서식 ?? + sheet.열?.[colName(c)]?.서식 ?? + sheet.행?.[String(r + 1)]?.서식 ?? + sheet.기본?.서식 ?? + 0; return ctx.book.서식[idx] ?? {}; }