From d5c5090b79827f0762a8db864acd5c39c2976f68 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 27 Sep 2026 20:30:17 +0900 Subject: [PATCH] =?UTF-8?q?feat(spreadsheet):=20C=20=EA=B2=A9=EC=9E=90=20?= =?UTF-8?q?=EA=B7=B8=EB=A6=AC=EA=B8=B0=20=E2=80=94=20DOM=204=20=ED=8C=90?= =?UTF-8?q?=20=C2=B7=20=EB=B3=91=ED=95=A9=20=C2=B7=20=ED=8B=80=EA=B3=A0?= =?UTF-8?q?=EC=A0=95=20=C2=B7=20=EC=88=A8=EA=B9=80=20=C2=B7=20=ED=8F=AD=20?= =?UTF-8?q?=EB=81=8C=EA=B8=B0=20=C2=B7=20=EC=84=9C=EC=8B=9D=20CSS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01VVM3xZ1aUPcUTuW2WZSnwg --- .../spreadsheet/spreadsheet_cellstyle.ts | 175 +++++ A00_Common/spreadsheet/spreadsheet_grid.css | 202 +++++ A00_Common/spreadsheet/spreadsheet_grid.ts | 705 +++++++++++++++++- A00_Common/spreadsheet/spreadsheet_headers.ts | 223 ++++++ 4 files changed, 1297 insertions(+), 8 deletions(-) create mode 100644 A00_Common/spreadsheet/spreadsheet_cellstyle.ts create mode 100644 A00_Common/spreadsheet/spreadsheet_grid.css create mode 100644 A00_Common/spreadsheet/spreadsheet_headers.ts diff --git a/A00_Common/spreadsheet/spreadsheet_cellstyle.ts b/A00_Common/spreadsheet/spreadsheet_cellstyle.ts new file mode 100644 index 00000000..28b2db92 --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_cellstyle.ts @@ -0,0 +1,175 @@ +/* ============================================================================= + * spreadsheet_cellstyle.ts (주인 C) + * 서식 → CSS — 글꼴 · 정렬(균등 분할 · 선택 영역 가운데 · 세로 · 줄 바꿈) · 채움색 · + * 테두리(hair · thin · medium · thick · double · dotted · dashed · 색) · 겹친 테두리 고르기. + * 칸 하나가 위 · 왼 이웃과 테두리를 나눠 가짐(표 `border-collapse` 방식) — 겹쳐 그리지 않게 + * 위 · 왼만 두 이웃 중 굵은 쪽으로 그리고 오른 · 아래는 시트 끝 칸만 그림(`resolveEdgeBorders`). + * 0 계약 머리 — 몸은 C 가 채움. 계약은 `spreadsheet_types.ts`. + * ========================================================================== */ + +import { colName, toA1 } from "./spreadsheet_address"; +import type { + BorderLine, + BorderSide, + CellStyle, + HAlign, + Sheet, + VAlign, + Workbook, +} from "./spreadsheet_types"; + +/** 선 굵기 차례 — 겹치면 굵은 쪽이 이김(엑셀과 완전히 같지 않음 · 근사). */ +const BORDER_WEIGHT: Record = { + hair: 0, + dotted: 1, + dashed: 2, + thin: 3, + double: 4, + medium: 5, + thick: 6, +}; + +/** 두 이웃 칸이 한 자리에 그리려는 테두리 중 굵은 쪽. 없으면 있는 쪽 · 둘 다 없으면 undefined. */ +export function strongerBorder(a?: BorderSide, b?: BorderSide): BorderSide | undefined { + if (!a) return b; + if (!b) return a; + return BORDER_WEIGHT[b.선] > BORDER_WEIGHT[a.선] ? b : a; +} + +/** 칸 하나가 실제로 그릴 네 변 — 위 · 왼은 이웃과 겹쳐 고름 · 오른 · 아래는 시트 끝(그 다음 칸이 없음)일 때만. */ +export interface ResolvedBorders { + 위?: BorderSide; + 아래?: BorderSide; + 왼?: BorderSide; + 오른?: BorderSide; +} + +/** `서식` 표 번호 — 칸 → 행 → 열 → 0(기본) 차례(칸 안의 계약 주석과 같음). */ +export function styleIndexAt(sheet: Sheet, r: number, c: number, key: string): number { + const cell = sheet.칸[key]; + if (cell?.서식 !== undefined) return cell.서식; + const rowFmt = sheet.행?.[String(r + 1)]?.서식; + if (rowFmt !== undefined) return rowFmt; + const colFmt = sheet.열?.[colName(c)]?.서식; + if (colFmt !== undefined) return colFmt; + return 0; +} + +/** (r, c) 칸이 쓸 서식 — 서식 표 밖 번호는 기본(빈 `{}`)으로. */ +export function cellStyleAt(book: Workbook, sheet: Sheet, r: number, c: number): CellStyle { + const key = toA1(r, c); + const idx = styleIndexAt(sheet, r, c, key); + return book.서식[idx] ?? {}; +} + +/** 칸 하나 · 병합 칸 한 덩이(box)의 테두리 — 네 변마다 안쪽 이웃과 겹쳐 굵은 쪽으로 고름. + * 병합이면 왼위(r0,c0) · 오른아래(r1,c1) 이 다른 칸이라 네 변을 따로 봄(테두리는 칸마다 있음). */ +export function resolveBoxBorders( + book: Workbook, + sheet: Sheet, + r0: number, + c0: number, + r1: number, + c1: number, + lastRow: number, + lastCol: number, +): ResolvedBorders { + const top = cellStyleAt(book, sheet, r0, c0).테두리?.위; + const aboveOf = r0 > 0 ? cellStyleAt(book, sheet, r0 - 1, c0).테두리?.아래 : undefined; + const left = cellStyleAt(book, sheet, r0, c0).테두리?.왼; + const leftOf = c0 > 0 ? cellStyleAt(book, sheet, r0, c0 - 1).테두리?.오른 : undefined; + const bottomOwn = cellStyleAt(book, sheet, r1, c0).테두리?.아래; + const belowOf = r1 < lastRow ? cellStyleAt(book, sheet, r1 + 1, c0).테두리?.위 : undefined; + const rightOwn = cellStyleAt(book, sheet, r0, c1).테두리?.오른; + const rightOf = c1 < lastCol ? cellStyleAt(book, sheet, r0, c1 + 1).테두리?.왼 : undefined; + return { + 위: strongerBorder(top, aboveOf), + 왼: strongerBorder(left, leftOf), + 아래: r1 >= lastRow ? bottomOwn : strongerBorder(bottomOwn, belowOf), + 오른: c1 >= lastCol ? rightOwn : strongerBorder(rightOwn, rightOf), + }; +} + +/** 빈 글 = 인라인 테두리를 안 그림(css `aislo-grid--gridlines` 눈금선이 비쳐 보임). */ +function borderCss(side?: BorderSide): string { + if (!side) return ""; + const color = side.색 ?? "#000000"; + switch (side.선) { + case "hair": + return `1px dotted ${color}`; + case "thin": + return `1px solid ${color}`; + case "medium": + return `2px solid ${color}`; + case "thick": + return `3px solid ${color}`; + case "double": + return `3px double ${color}`; + case "dotted": + return `1px dotted ${color}`; + case "dashed": + return `1px dashed ${color}`; + } +} + +/** 가로 정렬 → `text-align`(균등 분할 · 선택 영역 가운데는 근사: `justify` · `center`). */ +const HALIGN_CSS: Record = { + general: "left", + left: "left", + right: "right", + center: "center", + distributed: "justify", + centerContinuous: "center", +}; + +const VALIGN_CSS: Record = { + top: "flex-start", + center: "center", + bottom: "flex-end", +}; + +/** 칸 하나에 서식 · 겹쳐 고른 테두리를 입힘. `hAlign` 은 부른 쪽이 정함(일반 정렬은 값 종류로 갈림 — B `formatValue` 의 `정렬`). */ +export function applyCellStyle( + cell: HTMLElement, + style: CellStyle, + borders: ResolvedBorders, + hAlign: HAlign, +): void { + const s = cell.style; + s.fontFamily = style.글꼴 ?? ""; + s.fontSize = style.크기 ? `${style.크기}pt` : ""; + s.fontWeight = style.굵게 ? "700" : "400"; + s.fontStyle = style.기울임 ? "italic" : "normal"; + s.textDecorationLine = [style.밑줄 && "underline", style.취소선 && "line-through"] + .filter(Boolean) + .join(" "); + s.color = style.글자색 ?? ""; + s.backgroundColor = style.채움 ?? ""; + s.borderTop = borderCss(borders.위); + s.borderLeft = borderCss(borders.왼); + s.borderBottom = borderCss(borders.아래); + s.borderRight = borderCss(borders.오른); + s.justifyContent = VALIGN_CSS[style.세로 ?? "bottom"]; + s.whiteSpace = style.줄바꿈 ? "normal" : "nowrap"; + + let text = cell.firstElementChild as HTMLElement | null; + if (!text || !text.classList.contains("aislo-cell__text")) { + text = document.createElement("div"); + text.className = "aislo-cell__text"; + cell.replaceChildren(text); + } + text.style.textAlign = HALIGN_CSS[hAlign]; + text.style.textAlignLast = hAlign === "distributed" ? "justify" : ""; +} + +/** 칸 글 — 서식 CSS 와 따로 두어 `invalidate()` 가 값만 바꿀 때 서식을 다시 잴 필요가 없게. */ +export function setCellText(cell: HTMLElement, text: string, title?: string): void { + const node = cell.firstElementChild as HTMLElement | null; + if (node && node.classList.contains("aislo-cell__text")) { + node.textContent = text; + } else { + cell.textContent = text; + } + if (title) cell.title = title; + else cell.removeAttribute("title"); +} diff --git a/A00_Common/spreadsheet/spreadsheet_grid.css b/A00_Common/spreadsheet/spreadsheet_grid.css new file mode 100644 index 00000000..6ec0e9dd --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_grid.css @@ -0,0 +1,202 @@ +/* ============================================================================= + * spreadsheet_grid.css (주인 C) + * 격자 4 판 · 머리 · 칸 기본 모양. 색 · 간격은 여기 변수만(다른 파일은 이 클래스만 씀). + * ========================================================================== */ + +.aislo-grid { + --aislo-grid-line: #d9d9d9; + --aislo-grid-head-bg: #f3f3f3; + --aislo-grid-head-fg: #444; + --aislo-grid-head-border: #cfcfcf; + --aislo-grid-freeze-shadow: rgba(0, 0, 0, 0.12); + --aislo-grid-selected: #1a73e8; + + position: relative; + overflow: hidden; + width: 100%; + height: 100%; + font: + 10pt "굴림", + sans-serif; + background: #fff; + user-select: none; + -webkit-user-select: none; +} + +.aislo-grid__corner, +.aislo-grid__colhead-frozen, +.aislo-grid__colhead-scroll, +.aislo-grid__rowhead-frozen, +.aislo-grid__rowhead-scroll, +.aislo-grid__pane { + position: absolute; + overflow: hidden; +} + +.aislo-grid__pane--br { + overflow: auto; +} + +.aislo-grid__corner { + z-index: 5; + background: var(--aislo-grid-head-bg); + border-right: 1px solid var(--aislo-grid-head-border); + border-bottom: 1px solid var(--aislo-grid-head-border); +} + +.aislo-grid__colhead-frozen, +.aislo-grid__colhead-scroll { + z-index: 4; + background: var(--aislo-grid-head-bg); + border-bottom: 1px solid var(--aislo-grid-head-border); +} + +.aislo-grid__rowhead-frozen, +.aislo-grid__rowhead-scroll { + z-index: 4; + background: var(--aislo-grid-head-bg); + border-right: 1px solid var(--aislo-grid-head-border); +} + +.aislo-grid__colhead-frozen, +.aislo-grid__rowhead-frozen { + z-index: 6; + box-shadow: 2px 2px 4px var(--aislo-grid-freeze-shadow); +} + +.aislo-grid__colhead-inner, +.aislo-grid__rowhead-inner { + position: absolute; + top: 0; + left: 0; + will-change: transform; +} + +.aislo-grid__colhead, +.aislo-grid__rowhead { + position: absolute; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.82em; + font-weight: 600; + color: var(--aislo-grid-head-fg); + border-right: 1px solid var(--aislo-grid-head-border); + border-bottom: 1px solid var(--aislo-grid-head-border); + top: 0; +} + +.aislo-grid__rowhead { + left: 0; + top: auto; +} + +.aislo-grid__colhead--hidden-before, +.aislo-grid__rowhead--hidden-before { + border-left: 3px double #888; +} + +.aislo-grid__head-label { + pointer-events: none; + overflow: hidden; + text-overflow: ellipsis; +} + +.aislo-grid__resize { + position: absolute; + z-index: 2; +} + +.aislo-grid__resize--col { + top: 0; + right: -3px; + width: 6px; + height: 100%; + cursor: col-resize; +} + +.aislo-grid__resize--row { + left: 0; + bottom: -3px; + width: 100%; + height: 6px; + cursor: row-resize; +} + +.aislo-grid__pane-inner { + position: absolute; + top: 0; + left: 0; + will-change: transform; +} + +.aislo-grid__pane--br .aislo-grid__spacer { + position: absolute; + top: 0; + left: 0; + width: 1px; + height: 1px; + pointer-events: none; +} + +.aislo-cell { + position: absolute; + box-sizing: border-box; + display: flex; + flex-direction: column; + overflow: hidden; + padding: 0 2px; + background: #fff; + cursor: cell; +} + +.aislo-grid--gridlines .aislo-cell { + border-right: 1px solid var(--aislo-grid-line); + border-bottom: 1px solid var(--aislo-grid-line); +} + +.aislo-cell__text { + overflow: hidden; + text-overflow: ellipsis; + white-space: inherit; +} + +.aislo-cell.is-error { + color: #c00; +} + +.aislo-cell.is-selected { + outline: 2px solid var(--aislo-grid-selected); + outline-offset: -1px; + z-index: 1; +} + +.aislo-grid__ref-highlight { + position: absolute; + box-sizing: border-box; + pointer-events: none; + border: 1.5px solid; + z-index: 3; +} + +.aislo-grid__fill-handle { + position: absolute; + width: 6px; + height: 6px; + background: var(--aislo-grid-selected); + border: 1px solid #fff; + cursor: crosshair; + z-index: 3; +} + +.aislo-grid__overlay { + position: absolute; + inset: 0; + pointer-events: none; + z-index: 7; +} + +.aislo-grid__overlay > * { + pointer-events: auto; +} diff --git a/A00_Common/spreadsheet/spreadsheet_grid.ts b/A00_Common/spreadsheet/spreadsheet_grid.ts index 30f251ac..de32e299 100644 --- a/A00_Common/spreadsheet/spreadsheet_grid.ts +++ b/A00_Common/spreadsheet/spreadsheet_grid.ts @@ -3,16 +3,705 @@ * 격자 — DOM(칸 = 절대 위치 div) · 틀고정 4 판(왼위 · 위 · 왼 · 본문) · 판마다 보이는 행열만 그림 · * 병합 칸 = div 하나 · 숨긴 행열 건너뜀 · 눈금선 · 고름 · 참조 색 테두리 · 채우기 손잡이. * 칸 글은 엔진 값 → B `formatValue` → 서식 CSS(`spreadsheet_cellstyle.ts`). 머리 · 폭 끌기는 `spreadsheet_headers.ts`. - * 폭 · 높이 끌기는 `ctx.dispatch({종류:"열폭"|"행높이"})`. 0 계약 머리 — 몸은 C 가 채움. + * 폭 · 높이 끌기는 `ctx.dispatch({종류:"열폭"|"행높이"})`. + * + * 4 판 — 오른아래(`br`) 만 실제 스크롤바를 가짐(overflow:auto · 안에 전체 크기 스페이서) · + * 나머지 세 판은 overflow:hidden 이고 `br` 의 scroll 을 그대로 옮겨 받음(틀고정 방향만). + * 보이는 칸만 = 판 크기 안에 들어오는 행열만 실제 div 로 그림(칸 재사용 — `CellPool`). * ========================================================================== */ -import type { GridHandle, SpreadsheetContext } from "./spreadsheet_view_types"; +import "./spreadsheet_grid.css"; +import { toFrac } from "@ui/sheet/ui_template_sheet_frac"; +import { colIndex, parseA1, parseRange, toA1 } from "./spreadsheet_address"; +import { + applyCellStyle, + cellStyleAt, + resolveBoxBorders, + setCellText, + type ResolvedBorders, +} from "./spreadsheet_cellstyle"; +import { + buildColGeometry, + buildRowGeometry, + commitColWidth, + commitRowHeight, + makeColHeaderCell, + makeRowHeaderCell, + startDragResize, + visibleIndices, + type LineGeometry, +} from "./spreadsheet_headers"; +import { formatValue } from "./spreadsheet_numfmt"; +import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types"; +import type { + CellRange, + ErrorValue, + HAlign, + Scalar, + Sheet, + SheetCellAddress, +} from "./spreadsheet_types"; +import type { + CellBox, + GridHandle, + HitResult, + RefHighlight, + SpreadsheetContext, +} from "./spreadsheet_view_types"; -const todo = (): never => { - throw new Error("spreadsheet_grid: 아직 없음(C)"); -}; +const HEAD_ROW_H = 24; +const HEAD_COL_W = 46; +const RESIZE_HOTZONE = 4; +const REF_COLORS = ["#1a73e8", "#e8710a", "#0f9d58", "#a142f4", "#d93025"]; -/** `ctx.grid` 는 D 가 이 결과로 채움 — 만들 때 `ctx.grid` · `ctx.editor` 를 부르지 말 것 */ -export function createGrid(_ctx: SpreadsheetContext): GridHandle { - return todo(); +/** 칸 div 재사용 — 새 보이는 칸 집합에 없는 건 떼어 다음 판에 다시 씀. */ +interface CellPool { + used: Map; + free: HTMLElement[]; +} +const createPool = (): CellPool => ({ used: new Map(), free: [] }); + +function poolGet( + pool: CellPool, + key: string, + container: HTMLElement, + className: string, +): HTMLElement { + const found = pool.used.get(key); + if (found) return found; + const node = pool.free.pop() ?? document.createElement("div"); + node.className = className; + pool.used.set(key, node); + container.append(node); + return node; +} + +function poolSweep(pool: CellPool, keep: Set): void { + for (const [key, node] of pool.used) { + if (keep.has(key)) continue; + pool.used.delete(key); + node.remove(); + pool.free.push(node); + } +} + +function toScalar(value: number | string | boolean): Scalar { + return typeof value === "number" ? toFrac(value) : value; +} + +/** 시트에 실제 쓰인 자리 + 여유(엑셀 최대 자리까지 다 쓰지 않음 — 스크롤판 스페이서를 적당히 둠). */ +function computeExtent(sheet: Sheet): { rows: number; cols: number } { + let maxR = 0; + let maxC = 0; + for (const key of Object.keys(sheet.칸)) { + const addr = parseA1(key); + if (!addr) continue; + maxR = Math.max(maxR, addr.r); + maxC = Math.max(maxC, addr.c); + } + for (const key of Object.keys(sheet.행 ?? {})) maxR = Math.max(maxR, Number(key) - 1); + for (const key of Object.keys(sheet.열 ?? {})) maxC = Math.max(maxC, colIndex(key)); + for (const m of sheet.병합 ?? []) { + const range = parseRange(m); + if (!range) continue; + maxR = Math.max(maxR, range.r1); + maxC = Math.max(maxC, range.c1); + } + return { + rows: Math.min(MAX_ROWS, Math.max(maxR + 100, 200)), + cols: Math.min(MAX_COLS, Math.max(maxC + 20, 60)), + }; +} + +function buildMergeMap(sheet: Sheet): Map { + const map = new Map(); + for (const m of sheet.병합 ?? []) { + const range = parseRange(m); + if (!range) continue; + for (let r = range.r0; r <= range.r1; r += 1) { + for (let c = range.c0; c <= range.c1; c += 1) map.set(`${r},${c}`, range); + } + } + return map; +} + +interface CellDisplay { + text: string; + hAlign: HAlign; + title: string; + isError: boolean; +} + +function cellDisplay( + ctx: SpreadsheetContext, + sheet: Sheet, + r: number, + c: number, + hAlign0: HAlign, + code?: string, +): CellDisplay { + const cell = sheet.칸[toA1(r, c)]; + const scalar: Scalar = + cell?.식 !== undefined + ? ctx.engine.value(sheet.id, r, c) + : cell?.값 !== undefined + ? toScalar(cell.값) + : null; + const formatted = formatValue(scalar, code); + 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 }; +} + +export function createGrid(ctx: SpreadsheetContext): GridHandle { + const root = document.createElement("div"); + root.className = "aislo-grid"; + + const corner = document.createElement("div"); + corner.className = "aislo-grid__corner"; + const colHeadFrozen = paneDiv("aislo-grid__colhead-frozen"); + const colHeadScroll = paneDiv("aislo-grid__colhead-scroll"); + const rowHeadFrozen = paneDiv("aislo-grid__rowhead-frozen"); + const rowHeadScroll = paneDiv("aislo-grid__rowhead-scroll"); + const paneTL = paneDiv("aislo-grid__pane aislo-grid__pane--tl"); + const paneTR = paneDiv("aislo-grid__pane aislo-grid__pane--tr"); + const paneBL = paneDiv("aislo-grid__pane aislo-grid__pane--bl"); + const paneBR = paneDiv("aislo-grid__pane aislo-grid__pane--br"); + const overlay = paneDiv("aislo-grid__overlay"); + + const colHeadInnerFrozen = innerDiv("aislo-grid__colhead-inner"); + const colHeadInnerScroll = innerDiv("aislo-grid__colhead-inner"); + const rowHeadInnerFrozen = innerDiv("aislo-grid__rowhead-inner"); + const rowHeadInnerScroll = innerDiv("aislo-grid__rowhead-inner"); + const paneTLInner = innerDiv("aislo-grid__pane-inner"); + const paneTRInner = innerDiv("aislo-grid__pane-inner"); + const paneBLInner = innerDiv("aislo-grid__pane-inner"); + const paneBRInner = innerDiv("aislo-grid__pane-inner"); + const spacer = document.createElement("div"); + spacer.className = "aislo-grid__spacer"; + + colHeadFrozen.append(colHeadInnerFrozen); + colHeadScroll.append(colHeadInnerScroll); + rowHeadFrozen.append(rowHeadInnerFrozen); + rowHeadScroll.append(rowHeadInnerScroll); + paneTL.append(paneTLInner); + paneTR.append(paneTRInner); + paneBL.append(paneBLInner); + paneBR.append(paneBRInner, spacer); + root.append( + corner, + colHeadFrozen, + colHeadScroll, + rowHeadFrozen, + rowHeadScroll, + paneTL, + paneTR, + paneBL, + paneBR, + overlay, + ); + + const pools = { tl: createPool(), tr: createPool(), bl: createPool(), br: createPool() }; + 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 extent = computeExtent(ctx.sheet()); + let merges = buildMergeMap(ctx.sheet()); + let frozenRows = 0; + let frozenCols = 0; + let frozenW = 0; + let frozenH = 0; + + const resize = new ResizeObserver(() => layoutPanes()); + resize.observe(root); + + /** 판 크기 · 자리 다시 재기(창 크기 · 틀고정 수가 바뀔 때). */ + function layoutPanes(): void { + const w = root.clientWidth; + const h = root.clientHeight; + 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`; + colHeadFrozen.style.top = "0"; + colHeadFrozen.style.width = `${frozenW}px`; + colHeadFrozen.style.height = `${HEAD_ROW_H}px`; + colHeadScroll.style.left = `${HEAD_COL_W + 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`; + rowHeadFrozen.style.left = "0"; + rowHeadFrozen.style.top = `${HEAD_ROW_H}px`; + rowHeadFrozen.style.width = `${HEAD_COL_W}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`; + 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.height = `${frozenH}px`; + paneBL.style.left = `${HEAD_COL_W}px`; + paneBL.style.top = `${HEAD_ROW_H + 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`; + 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.right = "0"; + overlay.style.bottom = "0"; + renderVisible(); + } + + /** `br` 스크롤 위치 → 세 판(머리 둘 · tr · bl)에 옮겨 그리고 보이는 칸을 다시. */ + function onScroll(): void { + const left = paneBR.scrollLeft; + const top = paneBR.scrollTop; + colHeadInnerScroll.style.transform = `translateX(${-left}px)`; + paneTRInner.style.transform = `translateX(${-left}px)`; + rowHeadInnerScroll.style.transform = `translateY(${-top}px)`; + paneBLInner.style.transform = `translateY(${-top}px)`; + renderVisible(); + } + paneBR.addEventListener("scroll", onScroll); + + const gridlines = (): boolean => ctx.sheet().보기?.눈금선 !== false; + + function renderRowOfCells( + rowsVisible: number[], + colsVisible: number[], + pool: CellPool, + container: HTMLElement, + baseX: number, + baseY: number, + ): void { + const sheet = ctx.sheet(); + const book = ctx.book; + const keep = new Set(); + for (const r of rowsVisible) { + for (const c of colsVisible) { + const merge = merges.get(`${r},${c}`); + if (merge && (merge.r0 !== r || merge.c0 !== c)) continue; + const r0 = merge ? merge.r0 : r; + const c0 = merge ? merge.c0 : c; + const r1 = merge ? merge.r1 : r; + const c1 = merge ? merge.c1 : c; + const key = `${r0},${c0}`; + if (keep.has(key)) continue; + keep.add(key); + const cellEl = poolGet(pool, key, container, "aislo-cell"); + const x = colGeo.offset(c0) - baseX; + const y = rowGeo.offset(r0) - baseY; + const w = colGeo.offset(c1 + 1) - colGeo.offset(c0); + const h = rowGeo.offset(r1 + 1) - rowGeo.offset(r0); + cellEl.style.left = `${x}px`; + cellEl.style.top = `${y}px`; + cellEl.style.width = `${w}px`; + cellEl.style.height = `${h}px`; + cellEl.dataset.r = String(r0); + cellEl.dataset.c = String(c0); + const style = cellStyleAt(book, sheet, r0, c0); + const borders: ResolvedBorders = resolveBoxBorders( + book, + sheet, + r0, + c0, + r1, + c1, + extent.rows - 1, + extent.cols - 1, + ); + const display = cellDisplay(ctx, sheet, r0, c0, style.가로 ?? "general", style.형식); + applyCellStyle(cellEl, style, borders, display.hAlign); + setCellText(cellEl, display.text, display.title || undefined); + cellEl.classList.toggle("is-error", display.isError); + cellEl.classList.toggle( + "is-selected", + ctx.selection.활성.r === r0 && ctx.selection.활성.c === c0, + ); + } + } + poolSweep(pool, keep); + } + + function layoutColHeaders( + cols: number[], + pool: CellPool, + container: HTMLElement, + base: number, + ): void { + const keep = new Set(); + cols.forEach((c, i) => { + const key = String(c); + keep.add(key); + const prev = i > 0 ? cols[i - 1] : c - 1; + const hiddenBefore = c - prev > 1; + let cellEl = pool.used.get(key); + if (!cellEl) { + const built = makeColHeaderCell(c, 0, 0, hiddenBefore); + cellEl = built.el; + pool.used.set(key, cellEl); + container.append(cellEl); + const handle = cellEl.querySelector(".aislo-grid__resize--col")!; + startDragResize({ + handle, + axis: "col", + currentPx: () => colGeo.size(c), + onPreview: (px) => { + cellEl!.style.width = `${px}px`; + }, + onCommit: (px) => commitColWidth(ctx, c, px), + }); + } + cellEl.style.left = `${colGeo.offset(c) - base}px`; + cellEl.style.width = `${colGeo.size(c)}px`; + cellEl.classList.toggle("aislo-grid__colhead--hidden-before", hiddenBefore); + }); + poolSweep(pool, keep); + } + + function layoutRowHeaders( + rows: number[], + pool: CellPool, + container: HTMLElement, + base: number, + ): void { + const keep = new Set(); + rows.forEach((r, i) => { + const key = String(r); + keep.add(key); + const prev = i > 0 ? rows[i - 1] : r - 1; + const hiddenBefore = r - prev > 1; + let cellEl = pool.used.get(key); + if (!cellEl) { + const built = makeRowHeaderCell(r, 0, 0, hiddenBefore); + cellEl = built.el; + pool.used.set(key, cellEl); + container.append(cellEl); + const handle = cellEl.querySelector(".aislo-grid__resize--row")!; + startDragResize({ + handle, + axis: "row", + currentPx: () => rowGeo.size(r), + onPreview: (px) => { + cellEl!.style.height = `${px}px`; + }, + onCommit: (px) => commitRowHeight(ctx, r, px), + }); + } + cellEl.style.top = `${rowGeo.offset(r) - base}px`; + cellEl.style.height = `${rowGeo.size(r)}px`; + cellEl.classList.toggle("aislo-grid__rowhead--hidden-before", hiddenBefore); + }); + poolSweep(pool, keep); + } + + function renderVisible(): void { + 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 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); + const scrollColsVisible = visibleIndices( + colGeo, + scrollColFrom, + Math.max(scrollColFrom, scrollColTo), + ); + const frozenRowsVisible = visibleIndices(rowGeo, 0, Math.max(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( + rowGeo, + scrollRowFrom, + Math.max(scrollRowFrom, scrollRowTo), + ); + + layoutColHeaders(frozenColsVisible, colHeadPools.frozen, colHeadInnerFrozen, 0); + layoutColHeaders( + scrollColsVisible, + colHeadPools.scroll, + colHeadInnerScroll, + colGeo.offset(frozenCols), + ); + layoutRowHeaders(frozenRowsVisible, rowHeadPools.frozen, rowHeadInnerFrozen, 0); + layoutRowHeaders( + scrollRowsVisible, + rowHeadPools.scroll, + rowHeadInnerScroll, + rowGeo.offset(frozenRows), + ); + + renderRowOfCells(frozenRowsVisible, frozenColsVisible, pools.tl, paneTLInner, 0, 0); + renderRowOfCells( + frozenRowsVisible, + scrollColsVisible, + pools.tr, + paneTRInner, + colGeo.offset(frozenCols), + 0, + ); + renderRowOfCells( + scrollRowsVisible, + frozenColsVisible, + pools.bl, + paneBLInner, + 0, + rowGeo.offset(frozenRows), + ); + renderRowOfCells( + scrollRowsVisible, + scrollColsVisible, + pools.br, + paneBRInner, + colGeo.offset(frozenCols), + rowGeo.offset(frozenRows), + ); + + renderSelection(); + } + + function render(): void { + const sheet = ctx.sheet(); + colGeo = buildColGeometry(ctx.book, sheet); + rowGeo = buildRowGeometry(ctx.book, sheet); + extent = computeExtent(sheet); + merges = buildMergeMap(sheet); + frozenRows = Math.min(sheet.틀고정?.행 ?? 0, extent.rows); + frozenCols = Math.min(sheet.틀고정?.열 ?? 0, extent.cols); + spacer.style.width = "1px"; + spacer.style.height = "1px"; + paneBR.scrollLeft = 0; + paneBR.scrollTop = 0; + layoutPanes(); + } + + /** 값 · 서식이 바뀐 칸만 — 지금 그려진 칸이면 다시(구조는 안 바꿈 · `render()` 는 구조 바뀔 때만). */ + function invalidate(cells: SheetCellAddress[]): void { + const sheet = ctx.sheet(); + const dirty = new Set(cells.filter((c) => c.시트 === sheet.id).map((c) => `${c.r},${c.c}`)); + if (dirty.size === 0) return; + const anyDirty = (pool: CellPool): boolean => { + for (const key of pool.used.keys()) if (dirty.has(key)) return true; + return false; + }; + if (anyDirty(pools.tl) || anyDirty(pools.tr) || anyDirty(pools.bl) || anyDirty(pools.br)) + renderVisible(); + } + + function boxOf(r: number, c: number): CellBox { + const merge = merges.get(`${r},${c}`); + const r0 = merge ? merge.r0 : r; + const c0 = merge ? merge.c0 : c; + const r1 = merge ? merge.r1 : r; + const c1 = merge ? merge.c1 : c; + return { + x: colGeo.offset(c0), + y: rowGeo.offset(r0), + w: colGeo.offset(c1 + 1) - colGeo.offset(c0), + h: rowGeo.offset(r1 + 1) - rowGeo.offset(r0), + }; + } + + function screenBoxOf(r: number, c: number): CellBox { + 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); + return { x, y, w: box.w, h: box.h }; + } + + function renderSelection(): void { + let selLayer = overlay.querySelector(".aislo-grid__sel-layer"); + if (!selLayer) { + selLayer = document.createElement("div"); + selLayer.className = "aislo-grid__sel-layer"; + overlay.append(selLayer); + } + selLayer.replaceChildren(); + const sel = ctx.selection; + if (!sel || sel.시트 !== ctx.sheet().id) return; + for (const [i, range] of sel.범위.entries()) { + const box: CellBox = { + x: colGeo.offset(range.c0), + y: rowGeo.offset(range.r0), + w: colGeo.offset(range.c1 + 1) - colGeo.offset(range.c0), + h: rowGeo.offset(range.r1 + 1) - rowGeo.offset(range.r0), + }; + 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), + w: box.w, + h: box.h, + }; + const node = document.createElement("div"); + node.className = "aislo-grid__ref-highlight"; + node.style.left = `${screen.x}px`; + node.style.top = `${screen.y}px`; + node.style.width = `${screen.w}px`; + node.style.height = `${screen.h}px`; + node.style.borderColor = "#1a73e8"; + node.style.borderWidth = i === 0 ? "2px" : "1px"; + selLayer!.append(node); + } + const active = boxOf(sel.활성.r, sel.활성.c); + const inFrozenCol = sel.활성.c < frozenCols; + 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`; + selLayer!.append(handle); + } + + function setRefHighlights(list: RefHighlight[]): void { + let refLayer = overlay.querySelector(".aislo-grid__ref-layer"); + if (!refLayer) { + refLayer = document.createElement("div"); + refLayer.className = "aislo-grid__ref-layer"; + overlay.append(refLayer); + } + refLayer.replaceChildren(); + for (const ref of list) { + if (ref.시트 !== ctx.sheet().id) continue; + const box = boxOf(ref.범위.r0, ref.범위.c0); + const box2 = boxOf(ref.범위.r1, ref.범위.c1); + const merged: CellBox = { + x: box.x, + y: box.y, + w: box2.x + box2.w - box.x, + h: box2.y + box2.h - box.y, + }; + const inFrozenCol = ref.범위.c0 < frozenCols; + 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.width = `${merged.w}px`; + node.style.height = `${merged.h}px`; + node.style.borderColor = REF_COLORS[ref.색번호 % REF_COLORS.length]; + refLayer!.append(node); + } + } + + function hitTest(clientX: number, clientY: number): HitResult | null { + const bounds = root.getBoundingClientRect(); + 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 }; + const contentX = + x < HEAD_COL_W ? -1 : x - HEAD_COL_W + (x - HEAD_COL_W > 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) { + 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) { + const r = rowGeo.indexAt(contentY); + const edge = Math.abs(rowGeo.offset(r + 1) - contentY) <= RESIZE_HOTZONE; + return { kind: edge ? "rowEdge" : "rowHead", r, c: 0 }; + } + const c = colGeo.indexAt(contentX); + const r = rowGeo.indexAt(contentY); + const sel = ctx.selection; + if (sel && sel.시트 === ctx.sheet().id) { + const active = boxOf(sel.활성.r, sel.활성.c); + const screen = screenBoxOf(sel.활성.r, sel.활성.c); + void active; + if (Math.abs(x - (screen.x + screen.w)) <= 5 && Math.abs(y - (screen.y + screen.h)) <= 5) { + return { kind: "fillHandle", r: sel.활성.r, c: sel.활성.c }; + } + } + return { kind: "cell", r, c }; + } + + function reveal(r: number, c: number): void { + if (r >= frozenRows) { + const y0 = rowGeo.offset(r) - rowGeo.offset(frozenRows); + const y1 = y0 + rowGeo.size(r); + const viewTop = paneBR.scrollTop; + const viewBottom = viewTop + paneBR.clientHeight; + if (y0 < viewTop) paneBR.scrollTop = y0; + else if (y1 > viewBottom) paneBR.scrollTop = y1 - paneBR.clientHeight; + } + if (c >= frozenCols) { + const x0 = colGeo.offset(c) - colGeo.offset(frozenCols); + const x1 = x0 + colGeo.size(c); + const viewLeft = paneBR.scrollLeft; + const viewRight = viewLeft + paneBR.clientWidth; + if (x0 < viewLeft) paneBR.scrollLeft = x0; + else if (x1 > viewRight) paneBR.scrollLeft = x1 - paneBR.clientWidth; + } + } + + 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); + return { + r0: rowGeo.indexAt(paneBR.scrollTop), + c0: colGeo.indexAt(paneBR.scrollLeft), + r1: Math.min(extent.rows - 1, rowGeo.indexAt(paneBR.scrollTop + scrollH)), + c1: Math.min(extent.cols - 1, colGeo.indexAt(paneBR.scrollLeft + scrollW)), + }; + } + + function editorSlot(r: number, c: number): { layer: HTMLElement; box: CellBox } { + return { layer: overlay, box: screenBoxOf(r, c) }; + } + + function destroy(): void { + resize.disconnect(); + paneBR.removeEventListener("scroll", onScroll); + root.remove(); + } + + render(); + + return { + root, + render, + invalidate, + renderSelection, + setRefHighlights, + cellBox: boxOf, + hitTest, + reveal, + visibleRange, + editorSlot, + destroy, + }; +} + +function paneDiv(className: string): HTMLElement { + const node = document.createElement("div"); + node.className = className; + return node; +} + +function innerDiv(className: string): HTMLElement { + const node = document.createElement("div"); + node.className = className; + return node; } diff --git a/A00_Common/spreadsheet/spreadsheet_headers.ts b/A00_Common/spreadsheet/spreadsheet_headers.ts new file mode 100644 index 00000000..ac634b4f --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_headers.ts @@ -0,0 +1,223 @@ +/* ============================================================================= + * spreadsheet_headers.ts (주인 C) + * 열 글자 · 행 번호 머리 · 폭 · 높이 재기(숨김 = 0) · 끌기 · 숨김 표시. + * `LineGeometry` = 자리 하나 재기 — 자리 대부분 기본값이라(성긴 `열`·`행` 표) 겹친 만큼만 + * 어긋난 표로 쥐고 이분 탐색으로 자리 ↔ 픽셀을 오간다(1만 행이어도 가벼움). + * 0 계약 머리 — 몸은 C 가 채움. 계약은 `spreadsheet_types.ts`. + * ========================================================================== */ + +import { colIndex, colName } from "./spreadsheet_address"; +import type { RowInfo, Sheet, Workbook } from "./spreadsheet_types"; +import type { SpreadsheetContext } from "./spreadsheet_view_types"; + +export const DEFAULT_COL_CHARS = 8.43; +export const DEFAULT_ROW_PT = 15; +/** 엑셀 글자 폭 → px 근사(굴림 10pt 기준 · Calibri 공식과 같은 꼴). */ +const CHAR_PX = 7; +const CHAR_PAD_PX = 5; +const PT_PX = 4 / 3; + +export const colCharsToPx = (chars: number): number => Math.round(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; + +export const colLabel = (c: number): string => colName(c); +export const rowLabel = (r: number): string => String(r + 1); + +/** 자리 하나 재기 — 성긴 기본값 어긋남 표(정렬된 색인)로 자리 ↔ 픽셀 변환. */ +export interface LineGeometry { + /** 자리 `i` 폭(px) — 숨겼으면 0. */ + size(i: number): number; + /** 자리 `i` 가 0 부터 시작하는 픽셀 자리(내용 좌표). */ + offset(i: number): number; + /** 자리 `count` 개(0..count-1)를 합친 픽셀 길이. */ + total(count: number): number; + /** 픽셀 자리 `px` 가 든 자리 번호(0 부터). */ + indexAt(px: number): number; +} + +class SparseGeometry implements LineGeometry { + private readonly idx: number[] = []; + private readonly prefix: number[] = []; + + constructor( + private readonly base: number, + overrides: Map, + ) { + const keys = [...overrides.keys()].sort((a, b) => a - b); + let acc = 0; + for (const k of keys) { + acc += overrides.get(k)! - base; + this.idx.push(k); + this.prefix.push(acc); + } + } + + /** 어긋난 표에서 `i` 가 들어갈 자리(첫 idx[k] >= i). */ + private lowerBound(i: number): number { + let lo = 0; + let hi = this.idx.length; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (this.idx[mid] < i) lo = mid + 1; + else hi = mid; + } + return lo; + } + + size(i: number): number { + const at = this.lowerBound(i); + if (this.idx[at] === i) { + const before = at > 0 ? this.prefix[at - 1] : 0; + return this.base + (this.prefix[at] - before); + } + return this.base; + } + + offset(i: number): number { + const at = this.lowerBound(i); + const acc = at > 0 ? this.prefix[at - 1] : 0; + return i * this.base + acc; + } + + total(count: number): number { + return this.offset(count); + } + + indexAt(px: number): number { + if (px <= 0) return 0; + let hi = Math.max(1, Math.ceil(px / Math.max(this.base, 1)) + this.idx.length + 2); + while (this.offset(hi) <= px) hi *= 2; + let lo = 0; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (this.offset(mid) <= px) lo = mid; + else hi = mid - 1; + } + return lo; + } +} + +export function buildColGeometry(book: Workbook, sheet: Sheet): LineGeometry { + const base = colCharsToPx(book.기본?.열폭 ?? DEFAULT_COL_CHARS); + const overrides = new Map(); + for (const [key, info] of Object.entries(sheet.열 ?? {})) { + const c = colIndex(key); + if (c < 0) continue; + if (info.숨김) overrides.set(c, 0); + else if (info.폭 !== undefined) overrides.set(c, colCharsToPx(info.폭)); + } + return new SparseGeometry(base, overrides); +} + +export function buildRowGeometry(book: Workbook, sheet: Sheet): LineGeometry { + const base = ptToPx(book.기본?.행높이 ?? DEFAULT_ROW_PT); + const overrides = new Map(); + for (const [key, info] of Object.entries(sheet.행 ?? {}) as [string, RowInfo][]) { + const r = Number(key) - 1; + if (!Number.isInteger(r) || r < 0) continue; + if (info.숨김) overrides.set(r, 0); + else if (info.높이 !== undefined) overrides.set(r, ptToPx(info.높이)); + } + return new SparseGeometry(base, overrides); +} + +/** `from..to` 안에서 숨기지 않은 자리만. */ +export function visibleIndices(geo: LineGeometry, from: number, to: number): number[] { + const list: number[] = []; + for (let i = from; i <= to; i += 1) if (geo.size(i) > 0) list.push(i); + return list; +} + +export interface HeaderCell { + el: HTMLElement; + index: number; +} + +/** 열 머리 칸 — 글자 · 폭 끌기 손잡이 · 숨김 표시(왼쪽에 건너뛴 열이 있으면). */ +export function makeColHeaderCell( + c: number, + x: number, + w: number, + hiddenBefore: boolean, +): HeaderCell { + const cellEl = document.createElement("div"); + cellEl.className = "aislo-grid__colhead"; + if (hiddenBefore) cellEl.classList.add("aislo-grid__colhead--hidden-before"); + cellEl.style.left = `${x}px`; + cellEl.style.width = `${w}px`; + cellEl.dataset.col = String(c); + const label = document.createElement("span"); + label.className = "aislo-grid__head-label"; + label.textContent = colLabel(c); + cellEl.append(label); + const handle = document.createElement("div"); + handle.className = "aislo-grid__resize aislo-grid__resize--col"; + cellEl.append(handle); + return { el: cellEl, index: c }; +} + +/** 행 머리 칸 — 번호 · 높이 끌기 손잡이 · 숨김 표시(위에 건너뛴 행이 있으면). */ +export function makeRowHeaderCell( + r: number, + y: number, + h: number, + hiddenBefore: boolean, +): HeaderCell { + const cellEl = document.createElement("div"); + cellEl.className = "aislo-grid__rowhead"; + if (hiddenBefore) cellEl.classList.add("aislo-grid__rowhead--hidden-before"); + cellEl.style.top = `${y}px`; + cellEl.style.height = `${h}px`; + cellEl.dataset.row = String(r); + const label = document.createElement("span"); + label.className = "aislo-grid__head-label"; + label.textContent = rowLabel(r); + cellEl.append(label); + const handle = document.createElement("div"); + handle.className = "aislo-grid__resize aislo-grid__resize--row"; + cellEl.append(handle); + return { el: cellEl, index: r }; +} + +export interface ResizeDragOptions { + handle: HTMLElement; + axis: "col" | "row"; + currentPx: () => number; + onPreview: (px: number) => void; + onCommit: (px: number) => void; +} + +/** 폭 · 높이 끌기 — 누르는 순간 시작 픽셀을 쥐고 포인터가 옮긴 만큼 더함. */ +export function startDragResize(opts: ResizeDragOptions): void { + opts.handle.addEventListener("pointerdown", (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + const startClient = opts.axis === "col" ? ev.clientX : ev.clientY; + const startPx = opts.currentPx(); + opts.handle.setPointerCapture(ev.pointerId); + const next = (e: PointerEvent): number => { + const cur = opts.axis === "col" ? e.clientX : e.clientY; + return Math.max(4, startPx + (cur - startClient)); + }; + const move = (e: PointerEvent): void => opts.onPreview(next(e)); + const up = (e: PointerEvent): void => { + opts.handle.removeEventListener("pointermove", move); + opts.handle.removeEventListener("pointerup", up); + opts.onCommit(next(e)); + }; + opts.handle.addEventListener("pointermove", move); + opts.handle.addEventListener("pointerup", up); + }); +} + +/** 폭 끌기 다 됨 → `열폭` 명령(글자 단위로 되돌려 보냄). */ +export function commitColWidth(ctx: SpreadsheetContext, c: number, px: number): void { + ctx.dispatch({ 종류: "열폭", 시트: ctx.sheet().id, 열: [c], 폭: pxToColChars(px) }); +} + +/** 높이 끌기 다 됨 → `행높이` 명령(pt 단위로 되돌려 보냄). */ +export function commitRowHeight(ctx: SpreadsheetContext, r: number, px: number): void { + ctx.dispatch({ 종류: "행높이", 시트: ctx.sheet().id, 행: [r], 높이: pxToPt(px) }); +}