fix(spreadsheet): C 격자 — 브레인 최종 검증 흠 네 가지 고침

① 스크롤 영역이 빈 시트 기본 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVM3xZ1aUPcUTuW2WZSnwg
This commit is contained in:
2026-09-27 22:24:11 +09:00
co-authored by Claude Sonnet 5
parent a144d4019e
commit 7c1e95482d
2 changed files with 82 additions and 6 deletions
+78 -5
View File
@@ -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<string, CellRange> {
}
/** 확대 · 축소 — 밑감(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);
@@ -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;