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;