From cd2690450e39be9114604013faa9d1a2707f413d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 28 Sep 2026 22:33:55 +0900 Subject: [PATCH] =?UTF-8?q?feat(spreadsheet):=20=EA=B8=B4=20=EA=B8=80?= =?UTF-8?q?=EC=9D=B4=20=EC=98=86=20=EB=B9=88=20=EC=B9=B8=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=EB=84=98=EC=B3=90=20=EB=B3=B4=EC=9D=B4=EA=B2=8C(=EC=97=91?= =?UTF-8?q?=EC=85=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 왼쪽 · 일반 맞춤 글은 오른쪽으로 · 오른쪽 맞춤은 왼쪽으로 · 가운데는 양쪽으로 — 옆 칸이 빌 때만 - 비지 않은 칸 · 병합 칸 · 틀 고정 경계에서 멈춤 · 가운데 맞춤이 한쪽만 막히면 그쪽을 잘라 냄 - 줄 바꿈 켠 칸 · 숫자(####) · 오류는 안 넘침 · 덮은 칸 사이 눈금선 감춤 · 고치는 중에는 활성 칸 넘침 감춤 - 폭 계산은 새 파일 spreadsheet_spill.ts(격자 파일이 이미 700 줄 넘어 따로) · 정확한 글 폭은 글 요소 scrollWidth 를 판마다 한 번에 읽음 - ORCA(M02 면적산출 편집 창) 수치 확인 — 왼 · 오른 · 가운데 넘침 글 잘림 0 · 막힘 · 병합 · 틀 고정 멈춤 · 스크롤 · 확대 뒤 칸에 붙어 있음 · 비지 않은 칸과 겹침 0 · 실제 문서 새 그림 겹침 0 · 크기 시험 통과 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BYN9gMie8e6pXzSxJNb6Xt --- A00_Common/spreadsheet/spreadsheet_grid.css | 15 +++++ A00_Common/spreadsheet/spreadsheet_grid.ts | 48 +++++++++++++++ A00_Common/spreadsheet/spreadsheet_spill.ts | 61 +++++++++++++++++++ .../spreadsheet/helper_spreadsheet_d.cjs | 16 +++++ .../spreadsheet/test_spreadsheet_d_input.py | 11 ++++ 5 files changed, 151 insertions(+) create mode 100644 A00_Common/spreadsheet/spreadsheet_spill.ts diff --git a/A00_Common/spreadsheet/spreadsheet_grid.css b/A00_Common/spreadsheet/spreadsheet_grid.css index 3dca48a5..03c72697 100644 --- a/A00_Common/spreadsheet/spreadsheet_grid.css +++ b/A00_Common/spreadsheet/spreadsheet_grid.css @@ -248,3 +248,18 @@ .aislo-grid__overlay > * { pointer-events: auto; } + +/* 긴 글 넘침(`spreadsheet_spill.ts`) — 옆 빈 칸 위로 글을 폄 · 덮은 칸 사이 눈금선은 감춤(엑셀) · + * 고치는 중에는 활성 칸 넘침을 감춤(편집기와 안 겹치게). */ +.aislo-cell.is-spill { + overflow: visible; + z-index: 1; +} + +.aislo-grid--gridlines .aislo-cell.is-spill-r { + border-right-color: transparent; +} + +.ss--editing .aislo-cell.is-selected.is-spill > .aislo-cell__text { + visibility: hidden; +} diff --git a/A00_Common/spreadsheet/spreadsheet_grid.ts b/A00_Common/spreadsheet/spreadsheet_grid.ts index 22c28a0c..07eff56e 100644 --- a/A00_Common/spreadsheet/spreadsheet_grid.ts +++ b/A00_Common/spreadsheet/spreadsheet_grid.ts @@ -37,6 +37,7 @@ import { } from "./spreadsheet_headers"; import { resolveCondFmtStyle, type CondFmtValue } from "./spreadsheet_condfmt"; import { formatValue } from "./spreadsheet_numfmt"; +import { spillOf, type SpillAlign, type SpillSlot } from "./spreadsheet_spill"; import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types"; import type { CellRange, @@ -139,6 +140,19 @@ function buildMergeMap(sheet: Sheet): Map { let measureRuler: CanvasRenderingContext2D | null = null; /** 글 너비(px) — 칸 재기용(수가 칸 폭보다 길면 `#####`). */ +const SPILL_ALIGN: Partial> = { + general: "left", + left: "left", + right: "right", + center: "center", +}; + +function resetSpill(cell: HTMLElement): void { + cell.classList.remove("is-spill", "is-spill-r"); + const t = cell.firstElementChild as HTMLElement | null; + if (t) t.style.width = t.style.marginLeft = t.style.clipPath = ""; +} + function measureText(text: string, font: string): number { measureRuler ??= document.createElement("canvas").getContext("2d"); if (!measureRuler) return text.length * 7; @@ -417,6 +431,16 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle { const sheet = ctx.sheet(); const book = ctx.book; const keep = new Set(); + // 긴 글 넘침 — 줄마다 칸 자리를 모았다가 판을 다 그린 뒤 폄(옆 칸이 빈지 알아야 해서) + const slots = new Map>(); + const longs: { + r: number; + c: number; + el: HTMLElement; + own: number; + need: number; + align: SpillAlign; + }[] = []; for (const r of rowsVisible) { for (const c of colsVisible) { const merge = merges.get(`${r},${c}`); @@ -475,6 +499,16 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle { } } setCellText(cellEl, text, display.title || comment || undefined); + resetSpill(cellEl); + if (!merge) { + (slots.get(r0) ?? slots.set(r0, new Map()).get(r0)!).set(c0, { w, empty: text === "" }); + const align = SPILL_ALIGN[display.hAlign]; + if (align && text && !display.isNumeric && !display.isError && !style.줄바꿈) { + // 캔버스 폭은 거르기만(글꼴 대체로 조금 어긋남) — 정확한 폭은 아래서 글 요소 scrollWidth 로 + const need = measureText(text, cellFont(style, book, zoom)) + 4; + if (need > w * 0.9) longs.push({ r: r0, c: c0, el: cellEl, own: w, need, align }); + } + } cellEl.classList.toggle("is-error", display.isError); cellEl.classList.toggle("has-comment", !!comment); cellEl.classList.toggle( @@ -483,6 +517,20 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle { ); } } + // 글 폭 + 칸 안 여백(양쪽 2px) · 오른 선 1px — 읽기를 먼저 몰아 한 번만 배치 계산 + for (const l of longs) l.need = (l.el.firstElementChild as HTMLElement).scrollWidth + 5; + for (const l of longs) { + const sp = spillOf(slots.get(l.r)!, l.c, l.own, l.need, l.align); + if (!sp) continue; + l.el.classList.add("is-spill"); + const t = l.el.firstElementChild as HTMLElement; + t.style.width = `calc(100% + ${sp.left + sp.right}px)`; + t.style.marginLeft = `${-sp.left}px`; + if (sp.clipLeft || sp.clipRight) + t.style.clipPath = `inset(0 ${sp.clipRight}px 0 ${sp.clipLeft}px)`; + for (let k = sp.from; k < sp.to; k++) + pool.used.get(`${l.r},${k}`)?.classList.add("is-spill-r"); + } poolSweep(pool, keep); } diff --git a/A00_Common/spreadsheet/spreadsheet_spill.ts b/A00_Common/spreadsheet/spreadsheet_spill.ts new file mode 100644 index 00000000..bda33667 --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_spill.ts @@ -0,0 +1,61 @@ +/* ============================================================================= + * spreadsheet_spill.ts + * 긴 글 넘침(엑셀) — 칸보다 긴 글을 옆 빈 칸 위로 보임. 왼쪽 · 일반 맞춤은 오른쪽으로 · 오른쪽 맞춤은 + * 왼쪽으로 · 가운데는 양쪽으로. 비지 않은 칸 · 병합 칸 · 판 끝(틀 고정 경계 · 그린 범위 밖)에서 멈춤. + * 격자(`spreadsheet_grid.ts`)가 판 하나를 그릴 때 줄마다 칸 자리(`slots`)를 모아 부름 — 계산만 여기. + * ========================================================================== */ + +/** 같은 판 · 같은 줄 · 병합 아닌 칸 하나(병합 칸 · 안 그린 칸은 넣지 않음 → 거기서 멈춤) */ +export interface SpillSlot { + w: number; + empty: boolean; +} + +/** 넘침 — 왼 · 오른쪽으로 더 쓸 폭(px) · 덮은 칸 열 범위 · 가운데 맞춤이 한쪽만 막혀 잘라 낼 폭 */ +export interface Spill { + left: number; + right: number; + from: number; + to: number; + /** 가운데 맞춤 — 글 상자는 양쪽 반씩 늘리고 막힌 쪽은 이만큼 잘라 냄(글은 칸 가운데 그대로) */ + clipLeft: number; + clipRight: number; +} + +export type SpillAlign = "left" | "right" | "center"; + +/** 칸 c(폭 own)의 글 폭 need 가 넘치면 정렬 쪽 빈 칸으로 늘린 결과 · 안 넘치거나 못 늘리면 null */ +export function spillOf( + slots: Map, + c: number, + own: number, + need: number, + align: SpillAlign, +): Spill | null { + if (need <= own) return null; + let from = c; + let to = c; + const grow = (dir: -1 | 1, want: number): number => { + let got = 0; + for (let k = c + dir; got < want; k += dir) { + const s = slots.get(k); + if (!s || !s.empty) break; + got += s.w; + if (dir < 0) from = k; + else to = k; + } + return got; + }; + if (align === "center") { + const half = (need - own) / 2; + const l = Math.min(grow(-1, half), half); + const r = Math.min(grow(1, half), half); + if (!l && !r) return null; + return { left: half, right: half, from, to, clipLeft: half - l, clipRight: half - r }; + } + const got = grow(align === "right" ? -1 : 1, need - own); + if (!got) return null; + return align === "right" + ? { left: got, right: 0, from, to, clipLeft: 0, clipRight: 0 } + : { left: 0, right: got, from, to, clipLeft: 0, clipRight: 0 }; +} diff --git a/resources/tester/spreadsheet/helper_spreadsheet_d.cjs b/resources/tester/spreadsheet/helper_spreadsheet_d.cjs index 505a7af1..e9326499 100644 --- a/resources/tester/spreadsheet/helper_spreadsheet_d.cjs +++ b/resources/tester/spreadsheet/helper_spreadsheet_d.cjs @@ -7,6 +7,7 @@ const sel = load(path.join(SS, "spreadsheet_selection.ts")); const ed = load(path.join(SS, "spreadsheet_editor.ts")); const mouse = load(path.join(SS, "spreadsheet_mouse.ts")); const addr = load(path.join(SS, "spreadsheet_address.ts")); +const spill = load(path.join(SS, "spreadsheet_spill.ts")); const sample = require(path.join(SS, "spreadsheet_sample.json")); const s1 = sample.시트[0]; @@ -69,6 +70,21 @@ const out = { (t) => ed.textToCell(t, { 서식: 4 }), ), textToCellNoStyle: ed.textToCell("", {}), + // 긴 글 넘침 — 칸 0..5 폭 50 · 3 은 값 있음 · 칸 2 에서 폭 130 글 + spill: (() => { + const row = (full) => + new Map([0, 1, 2, 3, 4, 5].map((c) => [c, { w: 50, empty: !full.includes(c) }])); + const f = (sp) => sp && [sp.left, sp.right, sp.from, sp.to, sp.clipLeft, sp.clipRight]; + return { + left: f(spill.spillOf(row([2, 3]), 2, 50, 130, "left")), + leftFree: f(spill.spillOf(row([2]), 2, 50, 130, "left")), + right: f(spill.spillOf(row([2]), 2, 50, 130, "right")), + center: f(spill.spillOf(row([2]), 2, 50, 130, "center")), + centerBlocked: f(spill.spillOf(row([2, 3]), 2, 50, 130, "center")), + fits: f(spill.spillOf(row([2]), 2, 50, 40, "left")), + edge: f(spill.spillOf(new Map([[2, { w: 50, empty: false }]]), 2, 50, 130, "left")), + }; + })(), // 친 모양대로 숫자 형식 · 식 대문자 · 닫는 괄호 채움(엑셀) typedFormat: ["50%", "12.5%", "1,234.5", "1,000", "7", "abc", "1e3%"].map(ed.typedFormat), upperFormula: ["sum(a1:b2)+round(c3,0)", "'시트 a'!a1*true", '"abc"&a1', "내이름*2"].map( diff --git a/resources/tester/spreadsheet/test_spreadsheet_d_input.py b/resources/tester/spreadsheet/test_spreadsheet_d_input.py index 74c1c7db..71a8495e 100644 --- a/resources/tester/spreadsheet/test_spreadsheet_d_input.py +++ b/resources/tester/spreadsheet/test_spreadsheet_d_input.py @@ -128,3 +128,14 @@ def test_upper_formula(r): def test_close_parens(r): assert r["closeParens"] == ["=(1+2)", "=SUM(1,ROUND(2,0))", None, None, '=")"&(1)'] + + +def test_spill(r): + sp = r["spill"] + assert sp["left"] is None # 오른 칸(3)에 값 → 못 넘침 + assert sp["leftFree"] == [0, 100, 2, 4, 0, 0] # 빈 칸 둘 덮음 + assert sp["right"] == [100, 0, 0, 2, 0, 0] + assert sp["center"] == [40, 40, 1, 3, 0, 0] + assert sp["centerBlocked"] == [40, 40, 1, 2, 0, 40] # 오른쪽 막힘 → 오른쪽 반 잘라 냄 + assert sp["fits"] is None + assert sp["edge"] is None # 판 끝(그린 칸 없음)