/* ============================================================================= * 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`). * ========================================================================== */ 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 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 { 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; } /** 확대 · 축소 — 밑감(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; title: string; isError: boolean; color?: string; } 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, color: formatted.색 }; } 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; 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); /** 판 크기 · 자리 다시 재기(창 크기 · 틀고정 수가 바뀔 때). */ 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`; spacer.style.transform = `translate(${colGeo.total(extent.cols) - frozenW}px, ${rowGeo.total(extent.rows) - frozenH}px)`; overlay.style.left = `${headColW()}px`; overlay.style.top = `${headRowH()}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); /** 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( 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 addr = toA1(r0, c0); cellEl.dataset.addr = addr; 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, zoom); if (display.color) cellEl.style.color = display.color; const comment = sheet.comments?.[addr]; setCellText(cellEl, display.text, display.title || comment || undefined); cellEl.classList.toggle("is-error", display.isError); cellEl.classList.toggle("has-comment", !!comment); 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 - 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); 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(); 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); 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 }; } 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: headColW() + (inFrozenCol ? box.x : box.x - paneBR.scrollLeft), y: headRowH() + (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 = `${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); } 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 = `${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]; 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; 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 - headColW() - frozenW); const scrollH = Math.max(0, root.clientHeight - headRowH() - 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)), }; } /** 칸이 든 판(틀고정 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 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; }