From c48ce0575389279c16da688c3bedd4acd96d2474 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 16:44:22 +0900 Subject: [PATCH] =?UTF-8?q?fix(spreadsheet):=20=EC=85=80=20=EB=B0=80?= =?UTF-8?q?=EA=B8=B0=C2=B7=EB=8B=B9=EA=B8=B0=EA=B8=B0=EB=8F=84=20=EC=95=8C?= =?UTF-8?q?=EC=95=BD=20=EC=97=B0=EA=B2=B0=20=EB=94=B0=EB=9D=BC=EA=B0=90,?= =?UTF-8?q?=20=ED=96=89=EC=97=B4=20=EB=84=A3=EA=B8=B0=C2=B7=EC=A7=80?= =?UTF-8?q?=EC=9A=B0=EA=B8=B0=EC=97=90=20=EC=A1=B0=EA=B1=B4=EB=B6=80?= =?UTF-8?q?=EC=84=9C=EC=8B=9D=C2=B7=EC=9C=A0=ED=9A=A8=EC=84=B1=20=EB=B2=94?= =?UTF-8?q?=EC=9C=84=20=EA=B0=99=EC=9D=B4=20=EC=98=AE=EA=B9=80(104-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01MPcuokNm1ZR2kqgGGDfaJA --- .../spreadsheet/spreadsheet_commands.ts | 11 ++++ .../spreadsheet/spreadsheet_insert_dialog.ts | 59 +++++++++++++++++-- .../helper_spreadsheet_analysis.cjs | 18 ++++++ .../spreadsheet/test_stage2_insert_dialog.ts | 35 +++++++++++ 4 files changed, 118 insertions(+), 5 deletions(-) diff --git a/A00_Common/spreadsheet/spreadsheet_commands.ts b/A00_Common/spreadsheet/spreadsheet_commands.ts index 3432cfacf..0527c5d91 100644 --- a/A00_Common/spreadsheet/spreadsheet_commands.ts +++ b/A00_Common/spreadsheet/spreadsheet_commands.ts @@ -34,6 +34,8 @@ import type { CellStyle, Command, CommandEffect, + CondFmtRule, + DataValidationRule, Sheet, SheetCellAddress, Workbook, @@ -310,6 +312,15 @@ function insertDelete( const 범위 = shiftRange(d.범위); return 범위 ? [{ ...d, 범위 }] : []; }); + // 조건부서식 · 유효성 범위도 같이(104-2) — `remapCondFmtRanges`·`remapValidationRanges`(3단계) 와 + // 같은 규칙이지만 그 모듈을 안 끌어옴(기본 번들 크기 예산 `test_spreadsheet_size`) — 여기 그대로 적음. + const remapRanges = (rules: T[]): T[] => + rules.flatMap((rule) => { + const 범위 = rule.범위.map(shiftRange).filter((r): r is CellRange => r !== null); + return 범위.length ? [{ ...rule, 범위 }] : []; + }); + if (s.조건부서식) s.조건부서식 = remapRanges(s.조건부서식); + if (s.유효성) s.유효성 = remapRanges(s.유효성); const axis = rows ? "행" : "열"; const table = s[axis]; diff --git a/A00_Common/spreadsheet/spreadsheet_insert_dialog.ts b/A00_Common/spreadsheet/spreadsheet_insert_dialog.ts index 9f3989db8..d96cf9df3 100644 --- a/A00_Common/spreadsheet/spreadsheet_insert_dialog.ts +++ b/A00_Common/spreadsheet/spreadsheet_insert_dialog.ts @@ -18,7 +18,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 { Cell, CellLink, CellRange, Command, Sheet } from "./spreadsheet_types"; import type { SpreadsheetContext } from "./spreadsheet_view_types"; const TEXT = { @@ -51,6 +51,55 @@ function sheetUsedBounds(sheet: Sheet): { maxR: number; maxC: number } { return { maxR, maxC }; } +/** 밀기 · 당기기 범위 안 알약 연결도 같이 옮김(104-2) — `axis` 범위(행이면 열 범위 · 열이면 행 범위) 안 + * 알약만 · `dir` +1 밀기(삽입) · -1 당기기(삭제, `amount` 폭만큼 지운 자리의 알약은 빠짐). 안 바뀌면 `undefined` + * (되돌리기 짝에 빈 「시트통째」를 안 끼움). */ +function shiftLinks( + sheet: Sheet, + range: CellRange, + axis: "col" | "row", + dir: 1 | -1, + amount: number, +): CellLink[] | undefined { + const list = sheet.연결; + if (!list?.length) return undefined; + const lo = axis === "col" ? range.r0 : range.c0; + const hi = axis === "col" ? range.r1 : range.c1; + const at = axis === "col" ? range.c0 : range.r0; + let changed = false; + const next = list.flatMap((link): CellLink[] => { + const p = parseA1(link.칸); + if (!p) return [link]; + const along = axis === "col" ? p.r : p.c; + const cross = axis === "col" ? p.c : p.r; + if (along < lo || along > hi || cross < at) return [link]; // 범위 밖 · 밀리는 자리 앞 — 그대로 + if (dir < 0 && cross < at + amount) return (changed = true), []; // 지운 자리 — 빠짐 + changed = true; + const moved = cross + dir * amount; + return [{ ...link, 칸: axis === "col" ? toA1(p.r, moved) : toA1(moved, p.c) }]; + }); + return changed ? next : undefined; +} + +/** 「칸」 명령(값만) 앞에 — 알약이 움직였으면 「시트통째」(연결만 바꿈)를 한 묶음으로(되돌리기 한 걸음) */ +function dispatchShift( + ctx: SpreadsheetContext, + sheet: Sheet, + range: CellRange, + axis: "col" | "row", + dir: 1 | -1, + amount: number, + cells: Record, +): void { + const cellCmd: Command = { 종류: "칸", 시트: ctx.selection.시트, 칸: cells }; + const linked = shiftLinks(sheet, range, axis, dir, amount); + ctx.dispatch( + linked + ? { 종류: "묶음", 명령: [{ 종류: "시트통째", 시트: { ...sheet, 연결: linked } }, cellCmd] } + : cellCmd, + ); +} + // ═════════════════════════════════════════════════════════════════════════════ // 부분 범위 밀기 — 「칸」 명령으로 값 · 식 · 서식 번호만 옮김(위 머리 주석의 한계 참고) // ═════════════════════════════════════════════════════════════════════════════ @@ -66,7 +115,7 @@ export function insertShiftRight(ctx: SpreadsheetContext, range: CellRange): voi cells[toA1(r, c)] = src >= range.c0 ? (sheet.칸[toA1(r, src)] ?? null) : null; } } - ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: cells }); + dispatchShift(ctx, sheet, range, "col", 1, width, cells); } export function insertShiftDown(ctx: SpreadsheetContext, range: CellRange): void { @@ -80,7 +129,7 @@ export function insertShiftDown(ctx: SpreadsheetContext, range: CellRange): void cells[toA1(r, c)] = src >= range.r0 ? (sheet.칸[toA1(src, c)] ?? null) : null; } } - ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: cells }); + dispatchShift(ctx, sheet, range, "row", 1, height, cells); } export function deleteShiftLeft(ctx: SpreadsheetContext, range: CellRange): void { @@ -94,7 +143,7 @@ export function deleteShiftLeft(ctx: SpreadsheetContext, range: CellRange): void cells[toA1(r, c)] = src <= lastCol ? (sheet.칸[toA1(r, src)] ?? null) : null; } } - ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: cells }); + dispatchShift(ctx, sheet, range, "col", -1, width, cells); } export function deleteShiftUp(ctx: SpreadsheetContext, range: CellRange): void { @@ -108,7 +157,7 @@ export function deleteShiftUp(ctx: SpreadsheetContext, range: CellRange): void { cells[toA1(r, c)] = src <= lastRow ? (sheet.칸[toA1(src, c)] ?? null) : null; } } - ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: cells }); + dispatchShift(ctx, sheet, range, "row", -1, height, cells); } // ═════════════════════════════════════════════════════════════════════════════ diff --git a/resources/tester/spreadsheet/helper_spreadsheet_analysis.cjs b/resources/tester/spreadsheet/helper_spreadsheet_analysis.cjs index 996e25fc7..e5f02b676 100644 --- a/resources/tester/spreadsheet/helper_spreadsheet_analysis.cjs +++ b/resources/tester/spreadsheet/helper_spreadsheet_analysis.cjs @@ -229,4 +229,22 @@ section("names", () => { eq("통째 지움 → 이름 빠짐", [b.시트[0].이름정의.length, v("B1")], [0, "#NAME?"]); }); +// ── 5. 104-2 — 행 · 열 넣기 · 지우기 때 조건부서식 · 유효성 범위도 같이(기본 번들 안 — remap 을 그대로 옮겨 적음) ── +section("condfmt_validation_follow_rowcol", () => { + const { C } = require("./helper_spreadsheet_engine.cjs"); + const b = book({ A1: 1, B1: 2 }); + b.시트[0].조건부서식 = [ + { id: "cf1", 범위: [A.parseRange("A1:A1")], 조건: { 종류: "글포함", 글: "x" }, 스타일: {} }, + ]; + b.시트[0].유효성 = [ + { id: "v1", 범위: [A.parseRange("B1:B1")], 규칙: { 종류: "정수" }, 어기면: "막음", 메시지: "" }, + ]; + C.applyCommand(b, { 종류: "행넣기", 시트: "s1", at: 0, 수: 1 }); + eq("조건부서식 — 행 넣은 뒤 범위", A.rangeToA1(b.시트[0].조건부서식[0].범위[0]), "A2:A2"); + eq("유효성 — 행 넣은 뒤 범위", A.rangeToA1(b.시트[0].유효성[0].범위[0]), "B2:B2"); + C.applyCommand(b, { 종류: "열지우기", 시트: "s1", at: 0, 수: 1 }); // A 열 지움 + eq("조건부서식 — 범위를 다 잃으면 규칙째 빠짐", b.시트[0].조건부서식.length, 0); + eq("유효성 — B 열이 A 로 당겨짐", A.rangeToA1(b.시트[0].유효성[0].범위[0]), "A2:A2"); +}); + process.stdout.write(JSON.stringify(results)); diff --git a/resources/tester/spreadsheet/test_stage2_insert_dialog.ts b/resources/tester/spreadsheet/test_stage2_insert_dialog.ts index af571c203..d84eaed61 100644 --- a/resources/tester/spreadsheet/test_stage2_insert_dialog.ts +++ b/resources/tester/spreadsheet/test_stage2_insert_dialog.ts @@ -61,5 +61,40 @@ import { a1, assertEqual, makeBook, makeCtx, makeSheet, v } from "./test_stage2_ assertEqual(book.시트[0].칸[a1(2, 0)], undefined, "위로 당김 · 마지막 자리 빔"); } +// 104-2 — 밀기 · 당기기에도 알약 연결(`연결`)이 따라감. +// A1=1 B1=2 C1=3 · B1 에 알약 k1 · B1:B1 자리에 오른쪽으로 밀기 → 알약도 C1 로. +{ + const book = makeBook([ + makeSheet( + "s1", + { [a1(0, 0)]: v(1), [a1(0, 1)]: v(2), [a1(0, 2)]: v(3) }, + { 연결: [{ 키: "k1", 칸: a1(0, 1) }] }, + ), + ]); + const ctx = makeCtx(book, "s1"); + insertShiftRight(ctx, { r0: 0, c0: 1, r1: 0, c1: 1 }); + assertEqual(book.시트[0].연결, [{ 키: "k1", 칸: a1(0, 2) }], "오른쪽 밀기 · 알약 연결도 C1 로"); +} + +// A1=1 B1=2 C1=3 · A1 · B1 에 알약 둘 · A1:A1 삭제(왼쪽 당김) → A1 알약은 지운 자리라 빠짐 · B1 알약은 +// A1 로 당겨짐. +{ + const book = makeBook([ + makeSheet( + "s1", + { [a1(0, 0)]: v(1), [a1(0, 1)]: v(2), [a1(0, 2)]: v(3) }, + { + 연결: [ + { 키: "k1", 칸: a1(0, 0) }, + { 키: "k2", 칸: a1(0, 1) }, + ], + }, + ), + ]); + const ctx = makeCtx(book, "s1"); + deleteShiftLeft(ctx, { r0: 0, c0: 0, r1: 0, c1: 0 }); + assertEqual(book.시트[0].연결, [{ 키: "k2", 칸: a1(0, 0) }], "왼쪽 당김 · 지운 자리 알약은 빠짐 · 나머지는 당겨짐"); +} + if (process.exitCode) process.exit(process.exitCode); console.log("spreadsheet_insert_dialog 시험 끝");