From 7c1e95482dd2ccba782ec95850eb1ab5e00d69f1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 27 Sep 2026 22:24:11 +0900 Subject: [PATCH] =?UTF-8?q?fix(spreadsheet):=20C=20=EA=B2=A9=EC=9E=90=20?= =?UTF-8?q?=E2=80=94=20=EB=B8=8C=EB=A0=88=EC=9D=B8=20=EC=B5=9C=EC=A2=85=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=ED=9D=A0=20=EB=84=A4=20=EA=B0=80=EC=A7=80?= =?UTF-8?q?=20=EA=B3=A0=EC=B9=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ① 스크롤 영역이 빈 시트 기본 200행에서 안 늘어나 이름 상자 · Ctrl+방향키로 먼 칸 가도 화면이 안 따라가던 것 — reveal() 이 그 칸 밖이면 스페이서를 늘림(ensureExtent) · 스크롤 끝 가까이 닿아도 미리 늘림(growOnScrollEdge, 엑셀처럼). ② 열 폭 자동 맞춤 상한 없어 아주 긴 글로 약 4만 px 까지 늘던 것 — colCharsToPx 에 엑셀 상한(255 자) 캡을 걸어 렌더 쪽에서 막음. ③ 칸 폭보다 긴 수는 줄임표 대신 `#####`(엑셀과 같음) — 캔버스로 재 폭 넘으면 바꿔치기 · 글은 그대로 줄임표. ④ 틀 고정을 풀어도 왼위 · 위 · 왼 판에 칸이 하나 남던 것(A1 이 DOM 에 네 벌) — `Math.max(0, frozenCols-1)` 이 0 일 때도 0 을 줘 범위가 안 비던 계산 버그 고침. ORCA 수치 — ① 스크롤 영역 201,901px→1,001,901px 로 늘고 scrollTop 목표 자리로 이동 확인 · ② 5,702 자 요청에도 1,790px(255 자) 로 막힘 확인 · ③ 「123456789012345」→「#######」 확인 · ④ 해제 뒤 A1 DOM 칸 수 1 로 확인. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01VVM3xZ1aUPcUTuW2WZSnwg --- A00_Common/spreadsheet/spreadsheet_grid.ts | 83 +++++++++++++++++-- A00_Common/spreadsheet/spreadsheet_headers.ts | 5 +- 2 files changed, 82 insertions(+), 6 deletions(-) diff --git a/A00_Common/spreadsheet/spreadsheet_grid.ts b/A00_Common/spreadsheet/spreadsheet_grid.ts index 3dc806f1..7118a752 100644 --- a/A00_Common/spreadsheet/spreadsheet_grid.ts +++ b/A00_Common/spreadsheet/spreadsheet_grid.ts @@ -39,11 +39,13 @@ import { formatValue } from "./spreadsheet_numfmt"; import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types"; import type { CellRange, + CellStyle, ErrorValue, HAlign, Scalar, Sheet, SheetCellAddress, + Workbook, } from "./spreadsheet_types"; import type { CellBox, @@ -133,6 +135,25 @@ function buildMergeMap(sheet: Sheet): Map { } /** 확대 · 축소 — 밑감(1 배) 자리 재기를 그대로 두고 픽셀만 곱함(수는 그대로 · 화면만 커짐). */ +let measureRuler: CanvasRenderingContext2D | null = null; + +/** 글 너비(px) — 칸 재기용(수가 칸 폭보다 길면 `#####`). */ +function measureText(text: string, font: string): number { + measureRuler ??= document.createElement("canvas").getContext("2d"); + if (!measureRuler) return text.length * 7; + measureRuler.font = font; + return measureRuler.measureText(text).width; +} + +/** 칸 글꼴 CSS 글(캔버스 재기용) — `applyCellStyle` 이 칸에 입히는 글꼴과 같은 차례로 고름. */ +function cellFont(style: CellStyle, book: Workbook, scale: number): string { + const family = style.글꼴 || book.기본?.글꼴 || "굴림"; + const size = (style.크기 ?? book.기본?.크기 ?? 10) * scale; + const weight = style.굵게 ? "700" : "400"; + const italic = style.기울임 ? "italic " : ""; + return `${italic}${weight} ${size}pt ${family}`; +} + function scaledGeometry(base: LineGeometry, factor: number): LineGeometry { if (factor === 1) return base; return { @@ -148,6 +169,7 @@ interface CellDisplay { hAlign: HAlign; title: string; isError: boolean; + isNumeric: boolean; color?: string; } @@ -181,7 +203,9 @@ function cellDisplay( const isError = typeof scalar === "object" && scalar !== null && "error" in scalar; const hAlign: HAlign = hAlign0 !== "general" ? hAlign0 : formatted.정렬; const title = isError ? ((scalar as ErrorValue).why ?? formatted.글) : ""; - return { text: formatted.글, hAlign, title, isError, color: formatted.색 }; + const isNumeric = + !isError && scalar !== null && typeof scalar !== "string" && typeof scalar !== "boolean"; + return { text: formatted.글, hAlign, title, isError, isNumeric, color: formatted.색 }; } export function createGrid(ctx: SpreadsheetContext): GridHandle { @@ -304,7 +328,7 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle { paneBR.style.top = `${headRowH() + frozenH}px`; paneBR.style.width = `${Math.max(0, w - headColW() - frozenW)}px`; paneBR.style.height = `${Math.max(0, h - headRowH() - frozenH)}px`; - spacer.style.transform = `translate(${colGeo.total(extent.cols) - frozenW}px, ${rowGeo.total(extent.rows) - frozenH}px)`; + resizeSpacer(); overlay.style.left = `${headColW()}px`; overlay.style.top = `${headRowH()}px`; overlay.style.right = "0"; @@ -313,7 +337,45 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle { } /** `br` 스크롤 위치 → 세 판(머리 둘 · tr · bl)에 옮겨 그리고 보이는 칸을 다시. */ + function resizeSpacer(): void { + spacer.style.transform = `translate(${colGeo.total(extent.cols) - frozenW}px, ${rowGeo.total(extent.rows) - frozenH}px)`; + } + + /** 이름 상자 · Ctrl+방향키 등으로 간 칸이 지금 스크롤 영역 밖이면 그만큼 늘림(엑셀처럼 · 빈 시트도 안 막힘). */ + function ensureExtent(r: number, c: number): void { + let grown = false; + if (r >= extent.rows - 1) { + extent.rows = Math.min(MAX_ROWS, r + 100); + grown = true; + } + if (c >= extent.cols - 1) { + extent.cols = Math.min(MAX_COLS, c + 20); + grown = true; + } + if (grown) resizeSpacer(); + } + + /** 스크롤이 끝 가까이 닿으면 미리 늘림(휠 · 스크롤바로 끝까지 밀 때도 안 막힘). */ + function growOnScrollEdge(): void { + const edge = 100; + if ( + extent.rows < MAX_ROWS && + paneBR.scrollTop + paneBR.clientHeight >= paneBR.scrollHeight - edge + ) { + extent.rows = Math.min(MAX_ROWS, extent.rows + 100); + resizeSpacer(); + } + if ( + extent.cols < MAX_COLS && + paneBR.scrollLeft + paneBR.clientWidth >= paneBR.scrollWidth - edge + ) { + extent.cols = Math.min(MAX_COLS, extent.cols + 20); + resizeSpacer(); + } + } + function onScroll(): void { + growOnScrollEdge(); const left = paneBR.scrollLeft; const top = paneBR.scrollTop; colHeadInnerScroll.style.transform = `translateX(${-left}px)`; @@ -399,7 +461,17 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle { // 조건부 서식 글자색이 이기고(엑셀과 같음) · 없으면 숫자 형식([Red] 등)이 정한 색. if (display.color && !condStyle?.글자색) cellEl.style.color = display.color; const comment = sheet.comments?.[addr]; - setCellText(cellEl, display.text, display.title || comment || undefined); + // 수는 칸 폭보다 길면 `#####`(엑셀과 같음) · 글은 지금처럼 줄임표. + let text = display.text; + if (display.isNumeric && !style.줄바꿈) { + const font = cellFont(style, book, zoom); + const avail = w - 4; + if (measureText(text, font) > avail) { + const hashW = measureText("#", font) || 1; + text = "#".repeat(Math.max(1, Math.floor(avail / hashW))); + } + } + setCellText(cellEl, text, display.title || comment || undefined); cellEl.classList.toggle("is-error", display.isError); cellEl.classList.toggle("has-comment", !!comment); cellEl.classList.toggle( @@ -576,7 +648,7 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle { const h = root.clientHeight; const scrollW = Math.max(0, w - headColW() - frozenW); const scrollH = Math.max(0, h - headRowH() - frozenH); - const frozenColsVisible = visibleIndices(colGeo, 0, Math.max(0, frozenCols - 1)); + 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); const scrollColsVisible = visibleIndices( @@ -584,7 +656,7 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle { scrollColFrom, Math.max(scrollColFrom, scrollColTo), ); - const frozenRowsVisible = visibleIndices(rowGeo, 0, Math.max(0, frozenRows - 1)); + 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 scrollRowsVisible = visibleIndices( @@ -820,6 +892,7 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle { } function reveal(r: number, c: number): void { + ensureExtent(r, c); if (r >= frozenRows) { const y0 = rowGeo.offset(r) - rowGeo.offset(frozenRows); const y1 = y0 + rowGeo.size(r); diff --git a/A00_Common/spreadsheet/spreadsheet_headers.ts b/A00_Common/spreadsheet/spreadsheet_headers.ts index 50e4cc0b..1d62e1a5 100644 --- a/A00_Common/spreadsheet/spreadsheet_headers.ts +++ b/A00_Common/spreadsheet/spreadsheet_headers.ts @@ -16,8 +16,11 @@ export const DEFAULT_ROW_PT = 15; const CHAR_PX = 7; const CHAR_PAD_PX = 5; const PT_PX = 4 / 3; +/** 엑셀 열 폭 상한(글자 단위) — 이 위로 재는 값이 들어와도 격자는 여기서 막음(자동 맞춤 폭주 방어). */ +export const MAX_COL_CHARS = 255; -export const colCharsToPx = (chars: number): number => Math.round(chars * CHAR_PX + CHAR_PAD_PX); +export const colCharsToPx = (chars: number): number => + Math.round(Math.min(chars, MAX_COL_CHARS) * CHAR_PX + CHAR_PAD_PX); export const pxToColChars = (px: number): number => Math.max(0, (px - CHAR_PAD_PX) / CHAR_PX); export const ptToPx = (pt: number): number => Math.round(pt * PT_PX); export const pxToPt = (px: number): number => px / PT_PX;