/* ============================================================================= * spreadsheet_grid.ts (주인 C) * 격자 — DOM(칸 = 절대 위치 div) · 틀고정 4 판(왼위 · 위 · 왼 · 본문) · 판마다 보이는 행열만 그림 · * 병합 칸 = div 하나 · 숨긴 행열 건너뜀 · 눈금선 · 고름 · 참조 색 테두리 · 채우기 손잡이. * 칸 글은 엔진 값 → B `formatValue` → 서식 CSS(`spreadsheet_cellstyle.ts`). 머리 · 폭 끌기는 `spreadsheet_headers.ts`. * 폭 · 높이 끌기는 `ctx.dispatch({종류:"열폭"|"행높이"})`. * * 4 판 — 오른아래(`br`) 만 실제 스크롤바를 가짐(overflow:auto · 안에 전체 크기 스페이서) · * 나머지 세 판은 overflow:hidden 이고 `br` 의 scroll 을 그대로 옮겨 받음(틀고정 방향만). * 보이는 칸만 = 판 크기 안에 들어오는 행열만 실제 div 로 그림(칸 재사용 — `CellPool`). * 칸 그리기 · 넘침은 `spreadsheet_grid_cells.ts` · 행열 머리 · 윤곽은 `spreadsheet_grid_heads.ts`. * ========================================================================== */ import "./spreadsheet_grid.css"; import { colIndex, toA1 } from "./spreadsheet_address"; import { buildColGeometry, buildOutlineRuns, buildRowGeometry, visibleIndices, type LineGeometry, type OutlineRun, } from "./spreadsheet_headers"; import { buildMergeMap, computeExtent, createPool, renderCells, scaledGeometry, type CellPool, type GridView, } from "./spreadsheet_grid_cells"; import { createHeads, HEAD_COL_W_BASE, HEAD_ROW_H_BASE } from "./spreadsheet_grid_heads"; import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types"; import type { CellRange, SheetCellAddress } from "./spreadsheet_types"; import type { CellBox, GridHandle, HitResult, RefHighlight, SpreadsheetContext, } from "./spreadsheet_view_types"; const OUTLINE_GUTTER_BASE = 14; const RESIZE_HOTZONE = 4; const REF_COLORS = ["#1a73e8", "#e8710a", "#0f9d58", "#a142f4", "#d93025"]; const ZOOM_MIN = 0.1; const ZOOM_MAX = 4; 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 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; let frozenCols = 0; let frozenW = 0; let frozenH = 0; let lastSheetId = ""; let zoom = 1; let rowOutlineRuns: OutlineRun[] = []; let colOutlineRuns: OutlineRun[] = []; /** 행 묶음 윤곽 띠 — 행 머리 왼쪽(있을 때만) · 열 묶음 띠는 열 머리 위. */ const rowGutterW = (): number => rowOutlineRuns.length ? Math.round(OUTLINE_GUTTER_BASE * zoom) : 0; const colGutterH = (): number => colOutlineRuns.length ? Math.round(OUTLINE_GUTTER_BASE * zoom) : 0; const headRowH = (): number => Math.round(HEAD_ROW_H_BASE * zoom) + colGutterH(); const headColW = (): number => Math.round(HEAD_COL_W_BASE * zoom) + rowGutterW(); const view: GridView = { get colGeo() { return colGeo; }, get rowGeo() { return rowGeo; }, get merges() { return merges; }, get extent() { return extent; }, get zoom() { return zoom; }, get frozenRows() { return frozenRows; }, get frozenCols() { return frozenCols; }, get rowOutlineRuns() { return rowOutlineRuns; }, get colOutlineRuns() { return colOutlineRuns; }, rowGutterW, colGutterH, }; const { layoutColHeaders, layoutRowHeaders, renderRowOutline, renderColOutline } = createHeads( ctx, view, { colFrozen: colHeadInnerFrozen, colScroll: colHeadInnerScroll, rowFrozen: rowHeadInnerFrozen, rowScroll: rowHeadInnerScroll, }, ); const resize = new ResizeObserver(() => layoutPanes()); resize.observe(root); /** 판 크기 · 자리 다시 재기(창 크기 · 틀고정 수가 바뀔 때). */ 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 = `${headColW()}px`; corner.style.height = `${headRowH()}px`; colHeadFrozen.style.left = `${headColW()}px`; colHeadFrozen.style.top = "0"; colHeadFrozen.style.width = `${frozenW}px`; colHeadFrozen.style.height = `${headRowH()}px`; colHeadScroll.style.left = `${headColW() + frozenW}px`; colHeadScroll.style.top = "0"; colHeadScroll.style.width = `${Math.max(0, w - headColW() - frozenW)}px`; colHeadScroll.style.height = `${headRowH()}px`; rowHeadFrozen.style.left = "0"; rowHeadFrozen.style.top = `${headRowH()}px`; rowHeadFrozen.style.width = `${headColW()}px`; rowHeadFrozen.style.height = `${frozenH}px`; rowHeadScroll.style.left = "0"; 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 = `${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 = `${headColW()}px`; paneBL.style.top = `${headRowH() + frozenH}px`; paneBL.style.width = `${frozenW}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`; resizeSpacer(); overlay.style.left = `${headColW()}px`; overlay.style.top = `${headRowH()}px`; overlay.style.right = "0"; overlay.style.bottom = "0"; renderVisible(); } /** `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)`; paneTRInner.style.transform = `translateX(${-left}px)`; rowHeadInnerScroll.style.transform = `translateY(${-top}px)`; paneBLInner.style.transform = `translateY(${-top}px)`; renderVisible(); } 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.dispatchEvent(new CustomEvent("aislo-grid-zoom", { detail: { zoom }, bubbles: true })); } root.addEventListener("wheel", onWheel, { passive: false }); const gridlines = (): boolean => ctx.sheet().보기?.눈금선 !== false; function renderVisible(): void { root.classList.toggle("aislo-grid--gridlines", gridlines()); const w = root.clientWidth; 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, frozenCols - 1); // 본문 판 스크롤 0 = 틀고정 바로 다음 칸(`offset(frozen)`) — 그만큼 더해 재야 오른 · 아래 끝이 안 빔. const left = paneBR.scrollLeft + frozenW; const top = paneBR.scrollTop + frozenH; const scrollColFrom = Math.max(frozenCols, colGeo.indexAt(left)); const scrollColTo = Math.min(extent.cols - 1, colGeo.indexAt(left + scrollW) + 1); const scrollColsVisible = visibleIndices( colGeo, scrollColFrom, Math.max(scrollColFrom, scrollColTo), ); const frozenRowsVisible = visibleIndices(rowGeo, 0, frozenRows - 1); const scrollRowFrom = Math.max(frozenRows, rowGeo.indexAt(top)); const scrollRowTo = Math.min(extent.rows - 1, rowGeo.indexAt(top + 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), ); renderRowOutline(); renderColOutline(); renderCells(ctx, view, frozenRowsVisible, frozenColsVisible, pools.tl, paneTLInner, 0, 0); renderCells( ctx, view, frozenRowsVisible, scrollColsVisible, pools.tr, paneTRInner, colGeo.offset(frozenCols), 0, ); renderCells( ctx, view, scrollRowsVisible, frozenColsVisible, pools.bl, paneBLInner, 0, rowGeo.offset(frozenRows), ); renderCells( ctx, view, scrollRowsVisible, scrollColsVisible, pools.br, paneBRInner, colGeo.offset(frozenCols), rowGeo.offset(frozenRows), ); renderSelection(); } function render(): void { const sheet = ctx.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); rowOutlineRuns = buildOutlineRuns(sheet.행, (k) => Number(k) - 1); colOutlineRuns = buildOutlineRuns(sheet.열, colIndex); frozenRows = Math.min(sheet.틀고정?.행 ?? 0, extent.rows); frozenCols = Math.min(sheet.틀고정?.열 ?? 0, extent.cols); spacer.style.width = "1px"; spacer.style.height = "1px"; if (sheet.id !== lastSheetId) { lastSheetId = sheet.id; 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 = 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 }; } /** `overlay` 안 자리 — `overlay` 자신이 이미 머리 칸(headColW·headRowH)만큼 밀려 있어 * `screenBoxOf`(격자 뿌리 기준)를 그대로 쓰면 머리 칸만큼 두 번 밀림(찾은 버그 · 2026-09-27). */ function overlayBoxOf(r: number, c: number): CellBox { const box = boxOf(r, c); const inFrozenCol = c < frozenCols; const inFrozenRow = r < frozenRows; return { x: inFrozenCol ? box.x : box.x - paneBR.scrollLeft, y: inFrozenRow ? box.y : box.y - paneBR.scrollTop, 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: inFrozenCol ? box.x : box.x - paneBR.scrollLeft, y: 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 = "var(--aislo-grid-selected)"; node.style.borderWidth = i === 0 ? "2px" : "1px"; selLayer!.append(node); } // 활성 칸 테두리 — 칸 다시 그리기(`renderRowOfCells`) 없이 고름만 바뀌어도 옛 칸에 남지 않게. const activeKey = `${sel.활성.r},${sel.활성.c}`; const anchor = merges.get(activeKey); const activeAddr = toA1(anchor?.r0 ?? sel.활성.r, anchor?.c0 ?? sel.활성.c); for (const el of root.querySelectorAll(".aislo-cell.is-selected")) if (el.dataset.addr !== activeAddr) el.classList.remove("is-selected"); for (const el of root.querySelectorAll(`.aislo-cell[data-addr="${activeAddr}"]`)) el.classList.add("is-selected"); const handleAt = fillHandleCell(); if (!handleAt) return; const corner = overlayBoxOf(handleAt.r, handleAt.c); const handle = document.createElement("div"); handle.className = "aislo-grid__fill-handle"; handle.style.left = `${corner.x + corner.w - 4}px`; handle.style.top = `${corner.y + corner.h - 4}px`; selLayer!.append(handle); } /** 채우기 손잡이 자리 — 고른 범위 오른아래 칸(엑셀) · 범위가 여럿이거나 읽기 전용이면 없음. */ function fillHandleCell(): { r: number; c: number } | null { const sel = ctx.selection; if (ctx.readOnly || !sel || sel.범위.length !== 1) return null; const g = sel.범위[0]; return { r: g.r1, c: g.c1 }; } 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 = `${inFrozenCol ? merged.x : merged.x - paneBR.scrollLeft}px`; node.style.top = `${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 < headColW() && y < headRowH()) return { kind: "corner", r: 0, c: 0 }; const contentX = x < headColW() ? -1 : x - headColW() + (x - headColW() > frozenW ? paneBR.scrollLeft : 0); const contentY = 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 < 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 }; } const c = colGeo.indexAt(contentX); const r = rowGeo.indexAt(contentY); const sel = ctx.selection; const handleAt = sel && sel.시트 === ctx.sheet().id ? fillHandleCell() : null; if (handleAt) { const screen = screenBoxOf(handleAt.r, handleAt.c); if (Math.abs(x - (screen.x + screen.w)) <= 5 && Math.abs(y - (screen.y + screen.h)) <= 5) { return { kind: "fillHandle", r: handleAt.r, c: handleAt.c }; } } return { kind: "cell", r, c }; } function reveal(r: number, c: number): void { ensureExtent(r, c); const [top0, left0] = [paneBR.scrollTop, paneBR.scrollLeft]; 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; } // scroll 이벤트는 다음 그림 때 옴 — 먼 칸으로 뛸 때 한 틀 비어 보이지 않게 바로 그림 if (paneBR.scrollTop !== top0 || paneBR.scrollLeft !== left0) onScroll(); } function visibleRange(): CellRange { const scrollW = Math.max(0, root.clientWidth - headColW() - frozenW); const scrollH = Math.max(0, root.clientHeight - headRowH() - frozenH); const left = paneBR.scrollLeft + frozenW; const top = paneBR.scrollTop + frozenH; return { r0: rowGeo.indexAt(top), c0: colGeo.indexAt(left), r1: Math.min(extent.rows - 1, rowGeo.indexAt(top + scrollH)), c1: Math.min(extent.cols - 1, colGeo.indexAt(left + scrollW)), }; } /** 칸이 든 판(틀고정 4 판 중 하나)의 안쪽 층 — 어느 판인지는 이 반환 요소로 앎. */ function paneLayerOf(r: number, c: number): HTMLElement { const inFrozenRow = r < frozenRows; const inFrozenCol = c < frozenCols; if (inFrozenRow) return inFrozenCol ? paneTLInner : paneTRInner; return inFrozenCol ? paneBLInner : paneBRInner; } /** 편집기 자리 — `layer` = 칸이 든 판의 안쪽 층 · `box` = 격자 뿌리 기준 화면 좌표(D 가 층 차이를 빼서 맞춤). */ function editorSlot(r: number, c: number): { layer: HTMLElement; box: CellBox } { return { layer: paneLayerOf(r, c), box: screenBoxOf(r, c) }; } function getZoom(): number { return zoom; } function destroy(): void { resize.disconnect(); paneBR.removeEventListener("scroll", onScroll); root.remove(); } render(); return { root, render, invalidate, renderSelection, setRefHighlights, cellBox: boxOf, hitTest, reveal, visibleRange, editorSlot, zoom: getZoom, 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; }