diff --git a/A00_Common/spreadsheet/spreadsheet_cellstyle.ts b/A00_Common/spreadsheet/spreadsheet_cellstyle.ts index 375998a7..4ba194bb 100644 --- a/A00_Common/spreadsheet/spreadsheet_cellstyle.ts +++ b/A00_Common/spreadsheet/spreadsheet_cellstyle.ts @@ -91,25 +91,26 @@ export function resolveBoxBorders( }; } -/** 빈 글 = 인라인 테두리를 안 그림(css `aislo-grid--gridlines` 눈금선이 비쳐 보임). */ -function borderCss(side?: BorderSide): string { +/** 빈 글 = 인라인 테두리를 안 그림(css `aislo-grid--gridlines` 눈금선이 비쳐 보임). `scale` = 확대 · 축소 배율(선 굵기도 같이). */ +function borderCss(side: BorderSide | undefined, scale: number): string { if (!side) return ""; const color = side.색 ?? "#000000"; + const w = (px: number): number => Math.max(1, Math.round(px * scale)); switch (side.선) { case "hair": - return `1px dotted ${color}`; + return `${w(1)}px dotted ${color}`; case "thin": - return `1px solid ${color}`; + return `${w(1)}px solid ${color}`; case "medium": - return `2px solid ${color}`; + return `${w(2)}px solid ${color}`; case "thick": - return `3px solid ${color}`; + return `${w(3)}px solid ${color}`; case "double": - return `3px double ${color}`; + return `${w(3)}px double ${color}`; case "dotted": - return `1px dotted ${color}`; + return `${w(1)}px dotted ${color}`; case "dashed": - return `1px dashed ${color}`; + return `${w(1)}px dashed ${color}`; } } @@ -129,16 +130,18 @@ const VALIGN_CSS: Record = { bottom: "flex-end", }; -/** 칸 하나에 서식 · 겹쳐 고른 테두리를 입힘. `hAlign` 은 부른 쪽이 정함(일반 정렬은 값 종류로 갈림 — B `formatValue` 의 `정렬`). */ +/** 칸 하나에 서식 · 겹쳐 고른 테두리를 입힘. `hAlign` 은 부른 쪽이 정함(일반 정렬은 값 종류로 갈림 — B `formatValue` 의 `정렬`). + * `scale` = 확대 · 축소 배율(칸 글자 크기 · 테두리 굵기가 같이 커짐 · 기본 1). */ export function applyCellStyle( cell: HTMLElement, style: CellStyle, borders: ResolvedBorders, hAlign: HAlign, + scale = 1, ): void { const s = cell.style; s.fontFamily = style.글꼴 ?? ""; - s.fontSize = style.크기 ? `${style.크기}pt` : ""; + s.fontSize = style.크기 ? `${style.크기 * scale}pt` : ""; s.fontWeight = style.굵게 ? "700" : "400"; s.fontStyle = style.기울임 ? "italic" : "normal"; s.textDecorationLine = [style.밑줄 && "underline", style.취소선 && "line-through"] @@ -146,10 +149,10 @@ export function applyCellStyle( .join(" "); s.color = style.글자색 ?? ""; s.backgroundColor = style.채움 ?? ""; - s.borderTop = borderCss(borders.위); - s.borderLeft = borderCss(borders.왼); - s.borderBottom = borderCss(borders.아래); - s.borderRight = borderCss(borders.오른); + s.borderTop = borderCss(borders.위, scale); + s.borderLeft = borderCss(borders.왼, scale); + s.borderBottom = borderCss(borders.아래, scale); + s.borderRight = borderCss(borders.오른, scale); s.justifyContent = VALIGN_CSS[style.세로 ?? "bottom"]; s.whiteSpace = style.줄바꿈 ? "normal" : "nowrap"; diff --git a/A00_Common/spreadsheet/spreadsheet_grid.css b/A00_Common/spreadsheet/spreadsheet_grid.css index 024233ba..9cf34e1d 100644 --- a/A00_Common/spreadsheet/spreadsheet_grid.css +++ b/A00_Common/spreadsheet/spreadsheet_grid.css @@ -152,8 +152,12 @@ } .aislo-grid--gridlines .aislo-cell { - border-right: 1px solid var(--aislo-grid-line); - border-bottom: 1px solid var(--aislo-grid-line); + border-right-style: solid; + border-bottom-style: solid; + border-right-width: var(--aislo-grid-gridline-w, 1px); + border-bottom-width: var(--aislo-grid-gridline-w, 1px); + border-right-color: var(--aislo-grid-line); + border-bottom-color: var(--aislo-grid-line); } .aislo-cell__text { diff --git a/A00_Common/spreadsheet/spreadsheet_grid.ts b/A00_Common/spreadsheet/spreadsheet_grid.ts index 2a7d5fbd..4963d5b3 100644 --- a/A00_Common/spreadsheet/spreadsheet_grid.ts +++ b/A00_Common/spreadsheet/spreadsheet_grid.ts @@ -49,10 +49,12 @@ import type { SpreadsheetContext, } from "./spreadsheet_view_types"; -const HEAD_ROW_H = 24; -const HEAD_COL_W = 46; +const HEAD_ROW_H_BASE = 24; +const HEAD_COL_W_BASE = 46; const RESIZE_HOTZONE = 4; const REF_COLORS = ["#1a73e8", "#e8710a", "#0f9d58", "#a142f4", "#d93025"]; +const ZOOM_MIN = 0.1; +const ZOOM_MAX = 4; /** 칸 div 재사용 — 새 보이는 칸 집합에 없는 건 떼어 다음 판에 다시 씀. */ interface CellPool { @@ -125,6 +127,17 @@ function buildMergeMap(sheet: Sheet): Map { return map; } +/** 확대 · 축소 — 밑감(1 배) 자리 재기를 그대로 두고 픽셀만 곱함(수는 그대로 · 화면만 커짐). */ +function scaledGeometry(base: LineGeometry, factor: number): LineGeometry { + if (factor === 1) return base; + return { + size: (i) => base.size(i) * factor, + offset: (i) => base.offset(i) * factor, + total: (i) => base.total(i) * factor, + indexAt: (px) => base.indexAt(px / factor), + }; +} + interface CellDisplay { text: string; hAlign: HAlign; @@ -207,8 +220,10 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle { const colHeadPools = { frozen: createPool(), scroll: createPool() }; const rowHeadPools = { frozen: createPool(), scroll: createPool() }; - let colGeo: LineGeometry = buildColGeometry(ctx.book, ctx.sheet()); - let rowGeo: LineGeometry = buildRowGeometry(ctx.book, ctx.sheet()); + let baseColGeo: LineGeometry = buildColGeometry(ctx.book, ctx.sheet()); + let baseRowGeo: LineGeometry = buildRowGeometry(ctx.book, ctx.sheet()); + let colGeo: LineGeometry = baseColGeo; + let rowGeo: LineGeometry = baseRowGeo; let extent = computeExtent(ctx.sheet()); let merges = buildMergeMap(ctx.sheet()); let frozenRows = 0; @@ -216,6 +231,9 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle { let frozenW = 0; let frozenH = 0; let lastSheetId = ""; + let zoom = 1; + const headRowH = (): number => Math.round(HEAD_ROW_H_BASE * zoom); + const headColW = (): number => Math.round(HEAD_COL_W_BASE * zoom); const resize = new ResizeObserver(() => layoutPanes()); resize.observe(root); @@ -224,45 +242,48 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle { function layoutPanes(): void { const w = root.clientWidth; const h = root.clientHeight; + root.style.fontFamily = ctx.book.기본?.글꼴 ?? ""; + root.style.fontSize = ctx.book.기본?.크기 ? `${ctx.book.기본.크기 * zoom}pt` : `${10 * zoom}pt`; + root.style.setProperty("--aislo-grid-gridline-w", `${Math.max(1, Math.round(zoom))}px`); frozenW = colGeo.total(frozenCols); frozenH = rowGeo.total(frozenRows); - corner.style.width = `${HEAD_COL_W}px`; - corner.style.height = `${HEAD_ROW_H}px`; - colHeadFrozen.style.left = `${HEAD_COL_W}px`; + corner.style.width = `${headColW()}px`; + corner.style.height = `${headRowH()}px`; + colHeadFrozen.style.left = `${headColW()}px`; colHeadFrozen.style.top = "0"; colHeadFrozen.style.width = `${frozenW}px`; - colHeadFrozen.style.height = `${HEAD_ROW_H}px`; - colHeadScroll.style.left = `${HEAD_COL_W + frozenW}px`; + colHeadFrozen.style.height = `${headRowH()}px`; + colHeadScroll.style.left = `${headColW() + frozenW}px`; colHeadScroll.style.top = "0"; - colHeadScroll.style.width = `${Math.max(0, w - HEAD_COL_W - frozenW)}px`; - colHeadScroll.style.height = `${HEAD_ROW_H}px`; + colHeadScroll.style.width = `${Math.max(0, w - headColW() - frozenW)}px`; + colHeadScroll.style.height = `${headRowH()}px`; rowHeadFrozen.style.left = "0"; - rowHeadFrozen.style.top = `${HEAD_ROW_H}px`; - rowHeadFrozen.style.width = `${HEAD_COL_W}px`; + rowHeadFrozen.style.top = `${headRowH()}px`; + rowHeadFrozen.style.width = `${headColW()}px`; rowHeadFrozen.style.height = `${frozenH}px`; rowHeadScroll.style.left = "0"; - rowHeadScroll.style.top = `${HEAD_ROW_H + frozenH}px`; - rowHeadScroll.style.width = `${HEAD_COL_W}px`; - rowHeadScroll.style.height = `${Math.max(0, h - HEAD_ROW_H - frozenH)}px`; - paneTL.style.left = `${HEAD_COL_W}px`; - paneTL.style.top = `${HEAD_ROW_H}px`; + rowHeadScroll.style.top = `${headRowH() + frozenH}px`; + rowHeadScroll.style.width = `${headColW()}px`; + rowHeadScroll.style.height = `${Math.max(0, h - headRowH() - frozenH)}px`; + paneTL.style.left = `${headColW()}px`; + paneTL.style.top = `${headRowH()}px`; paneTL.style.width = `${frozenW}px`; paneTL.style.height = `${frozenH}px`; - paneTR.style.left = `${HEAD_COL_W + frozenW}px`; - paneTR.style.top = `${HEAD_ROW_H}px`; - paneTR.style.width = `${Math.max(0, w - HEAD_COL_W - frozenW)}px`; + paneTR.style.left = `${headColW() + frozenW}px`; + paneTR.style.top = `${headRowH()}px`; + paneTR.style.width = `${Math.max(0, w - headColW() - frozenW)}px`; paneTR.style.height = `${frozenH}px`; - paneBL.style.left = `${HEAD_COL_W}px`; - paneBL.style.top = `${HEAD_ROW_H + frozenH}px`; + paneBL.style.left = `${headColW()}px`; + paneBL.style.top = `${headRowH() + frozenH}px`; paneBL.style.width = `${frozenW}px`; - paneBL.style.height = `${Math.max(0, h - HEAD_ROW_H - frozenH)}px`; - paneBR.style.left = `${HEAD_COL_W + frozenW}px`; - paneBR.style.top = `${HEAD_ROW_H + frozenH}px`; - paneBR.style.width = `${Math.max(0, w - HEAD_COL_W - frozenW)}px`; - paneBR.style.height = `${Math.max(0, h - HEAD_ROW_H - frozenH)}px`; + paneBL.style.height = `${Math.max(0, h - headRowH() - frozenH)}px`; + paneBR.style.left = `${headColW() + frozenW}px`; + 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)`; - overlay.style.left = `${HEAD_COL_W}px`; - overlay.style.top = `${HEAD_ROW_H}px`; + overlay.style.left = `${headColW()}px`; + overlay.style.top = `${headRowH()}px`; overlay.style.right = "0"; overlay.style.bottom = "0"; renderVisible(); @@ -280,6 +301,22 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle { } paneBR.addEventListener("scroll", onScroll); + /** Ctrl+휠 확대 · 축소(10~400%) — 칸 · 머리 · 선 굵기가 같이 커짐(`headRowH`·`headColW`·`applyCellStyle` scale). */ + function onWheel(ev: WheelEvent): void { + if (!ev.ctrlKey) return; + ev.preventDefault(); + const before = zoom; + const next = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoom * Math.exp(-ev.deltaY * 0.0015))); + if (next === before) return; + zoom = next; + colGeo = scaledGeometry(baseColGeo, zoom); + rowGeo = scaledGeometry(baseRowGeo, zoom); + paneBR.scrollLeft *= zoom / before; + paneBR.scrollTop *= zoom / before; + layoutPanes(); + } + root.addEventListener("wheel", onWheel, { passive: false }); + const gridlines = (): boolean => ctx.sheet().보기?.눈금선 !== false; function renderRowOfCells( @@ -329,7 +366,7 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle { extent.cols - 1, ); const display = cellDisplay(ctx, sheet, r0, c0, style.가로 ?? "general", style.형식); - applyCellStyle(cellEl, style, borders, display.hAlign); + applyCellStyle(cellEl, style, borders, display.hAlign, zoom); if (display.color) cellEl.style.color = display.color; const comment = sheet.comments?.[addr]; setCellText(cellEl, display.text, display.title || comment || undefined); @@ -420,8 +457,8 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle { root.classList.toggle("aislo-grid--gridlines", gridlines()); const w = root.clientWidth; const h = root.clientHeight; - const scrollW = Math.max(0, w - HEAD_COL_W - frozenW); - const scrollH = Math.max(0, h - HEAD_ROW_H - frozenH); + 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 scrollColFrom = Math.max(frozenCols, colGeo.indexAt(paneBR.scrollLeft)); const scrollColTo = Math.min(extent.cols - 1, colGeo.indexAt(paneBR.scrollLeft + scrollW) + 1); @@ -485,10 +522,10 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle { function render(): void { const sheet = ctx.sheet(); - root.style.fontFamily = ctx.book.기본?.글꼴 ?? ""; - root.style.fontSize = ctx.book.기본?.크기 ? `${ctx.book.기본.크기}pt` : ""; - colGeo = buildColGeometry(ctx.book, sheet); - rowGeo = buildRowGeometry(ctx.book, sheet); + baseColGeo = buildColGeometry(ctx.book, sheet); + baseRowGeo = buildRowGeometry(ctx.book, sheet); + colGeo = scaledGeometry(baseColGeo, zoom); + rowGeo = scaledGeometry(baseRowGeo, zoom); extent = computeExtent(sheet); merges = buildMergeMap(sheet); frozenRows = Math.min(sheet.틀고정?.행 ?? 0, extent.rows); @@ -534,8 +571,8 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle { const box = boxOf(r, c); const inFrozenCol = c < frozenCols; const inFrozenRow = r < frozenRows; - const x = HEAD_COL_W + (inFrozenCol ? box.x : box.x - paneBR.scrollLeft); - const y = HEAD_ROW_H + (inFrozenRow ? box.y : box.y - paneBR.scrollTop); + const x = headColW() + (inFrozenCol ? box.x : box.x - paneBR.scrollLeft); + const y = headRowH() + (inFrozenRow ? box.y : box.y - paneBR.scrollTop); return { x, y, w: box.w, h: box.h }; } @@ -559,8 +596,8 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle { const inFrozenCol = range.c0 < frozenCols; const inFrozenRow = range.r0 < frozenRows; const screen: CellBox = { - x: HEAD_COL_W + (inFrozenCol ? box.x : box.x - paneBR.scrollLeft), - y: HEAD_ROW_H + (inFrozenRow ? box.y : box.y - paneBR.scrollTop), + x: headColW() + (inFrozenCol ? box.x : box.x - paneBR.scrollLeft), + y: headRowH() + (inFrozenRow ? box.y : box.y - paneBR.scrollTop), w: box.w, h: box.h, }; @@ -579,8 +616,8 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle { const inFrozenRow = sel.활성.r < frozenRows; const handle = document.createElement("div"); handle.className = "aislo-grid__fill-handle"; - handle.style.left = `${HEAD_COL_W + (inFrozenCol ? active.x : active.x - paneBR.scrollLeft) + active.w - 4}px`; - handle.style.top = `${HEAD_ROW_H + (inFrozenRow ? active.y : active.y - paneBR.scrollTop) + active.h - 4}px`; + handle.style.left = `${headColW() + (inFrozenCol ? active.x : active.x - paneBR.scrollLeft) + active.w - 4}px`; + handle.style.top = `${headRowH() + (inFrozenRow ? active.y : active.y - paneBR.scrollTop) + active.h - 4}px`; selLayer!.append(handle); } @@ -606,8 +643,8 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle { const inFrozenRow = ref.범위.r0 < frozenRows; const node = document.createElement("div"); node.className = "aislo-grid__ref-highlight"; - node.style.left = `${HEAD_COL_W + (inFrozenCol ? merged.x : merged.x - paneBR.scrollLeft)}px`; - node.style.top = `${HEAD_ROW_H + (inFrozenRow ? merged.y : merged.y - paneBR.scrollTop)}px`; + node.style.left = `${headColW() + (inFrozenCol ? merged.x : merged.x - paneBR.scrollLeft)}px`; + node.style.top = `${headRowH() + (inFrozenRow ? merged.y : merged.y - paneBR.scrollTop)}px`; node.style.width = `${merged.w}px`; node.style.height = `${merged.h}px`; node.style.borderColor = REF_COLORS[ref.색번호 % REF_COLORS.length]; @@ -620,17 +657,17 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle { const x = clientX - bounds.left; const y = clientY - bounds.top; if (x < 0 || y < 0 || x > root.clientWidth || y > root.clientHeight) return null; - if (x < HEAD_COL_W && y < HEAD_ROW_H) return { kind: "corner", r: 0, c: 0 }; + if (x < headColW() && y < headRowH()) return { kind: "corner", r: 0, c: 0 }; const contentX = - x < HEAD_COL_W ? -1 : x - HEAD_COL_W + (x - HEAD_COL_W > frozenW ? paneBR.scrollLeft : 0); + x < headColW() ? -1 : x - headColW() + (x - headColW() > frozenW ? paneBR.scrollLeft : 0); const contentY = - y < HEAD_ROW_H ? -1 : y - HEAD_ROW_H + (y - HEAD_ROW_H > frozenH ? paneBR.scrollTop : 0); - if (y < HEAD_ROW_H) { + y < headRowH() ? -1 : y - headRowH() + (y - headRowH() > frozenH ? paneBR.scrollTop : 0); + if (y < headRowH()) { const c = colGeo.indexAt(contentX); const edge = Math.abs(colGeo.offset(c + 1) - contentX) <= RESIZE_HOTZONE; return { kind: edge ? "colEdge" : "colHead", r: 0, c }; } - if (x < HEAD_COL_W) { + if (x < headColW()) { const r = rowGeo.indexAt(contentY); const edge = Math.abs(rowGeo.offset(r + 1) - contentY) <= RESIZE_HOTZONE; return { kind: edge ? "rowEdge" : "rowHead", r, c: 0 }; @@ -669,8 +706,8 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle { } function visibleRange(): CellRange { - const scrollW = Math.max(0, root.clientWidth - HEAD_COL_W - frozenW); - const scrollH = Math.max(0, root.clientHeight - HEAD_ROW_H - frozenH); + const scrollW = Math.max(0, root.clientWidth - headColW() - frozenW); + const scrollH = Math.max(0, root.clientHeight - headRowH() - frozenH); return { r0: rowGeo.indexAt(paneBR.scrollTop), c0: colGeo.indexAt(paneBR.scrollLeft),