.aislo-grid 안 색 변수(--aislo-grid-bg · -fg · -line · -head-bg · -head-fg · -head-border · -selected) 를 전부 앱 전역 토큰(ui_template_theme.css 의 --color-surface-raised · -text-body · -border · -surface · -text-secondary · -accent)에 연결 — 새 값 안 적음, 앱이 data-theme 를 바꾸면 그대로 따라감(다른 스프레드시트 파일(spreadsheet_toolbar.css)과 같은 방식). 칸 · 머리 배경 · 눈금선 · 선택 테두리 · 채우기 손잡이 테두리의 하드코딩 흰색 · 회색 · 파랑을 이 변수로 바꿈. 테두리 기본 색(사용자가 색 안 지정)은 #000000 대신 currentColor 로 — 칸 글자색과 같이 테마를 따라감. 사용자가 지정한 채움색 · 글자색(조건부 서식 포함)은 그대로 둠 (엑셀과 같음 — 이미 빈 문자열 인라인 스타일로 CSS 기본값에 떨어지는 구조라 따로 안 고침). 시험 틀(html)에 ui_template_theme.css 를 넣어 data-theme 전환을 실제로 시험. ORCA 수치(시험 틀 · data-theme 전환) — 밝은: 격자 배경 rgb(255,255,255) · 글자 rgb(51,51,51) · 선택 #3e0079 · 눈금선 #e6e2e3. 어두운: 격자 배경 rgb(37,31,56) · 글자 rgb(228,224,240) · 선택 #b99aff · 눈금선 #322a45 — 모두 전환됨. 사용자 지정 채움(A3 #F2F2F2)·조건부 서식 글자색(H4 #c00000)은 두 테마에서 값 그대로(안 바뀜) 확인. A3 무색 테두리는 currentColor 로 밝은 rgb(51,51,51)/어두운 rgb(228,224,240) 로 글자색과 같이 바뀜 확인. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VVM3xZ1aUPcUTuW2WZSnwg
978 lines
36 KiB
TypeScript
978 lines
36 KiB
TypeScript
/* =============================================================================
|
||
* 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, colName, parseA1, parseRange, toA1 } from "./spreadsheet_address";
|
||
import {
|
||
applyCellStyle,
|
||
cellStyleAt,
|
||
resolveBoxBorders,
|
||
setCellText,
|
||
type ResolvedBorders,
|
||
} from "./spreadsheet_cellstyle";
|
||
import {
|
||
buildColGeometry,
|
||
buildOutlineRuns,
|
||
buildRowGeometry,
|
||
commitColWidth,
|
||
commitRowHeight,
|
||
isExplicitHidden,
|
||
makeColHeaderCell,
|
||
makeRowHeaderCell,
|
||
startDragResize,
|
||
visibleIndices,
|
||
type LineGeometry,
|
||
type OutlineRun,
|
||
} from "./spreadsheet_headers";
|
||
import { resolveCondFmtStyle, type CondFmtValue } from "./spreadsheet_condfmt";
|
||
import { formatValue } from "./spreadsheet_numfmt";
|
||
import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types";
|
||
import type {
|
||
CellRange,
|
||
CellStyle,
|
||
ErrorValue,
|
||
HAlign,
|
||
Scalar,
|
||
Sheet,
|
||
SheetCellAddress,
|
||
Workbook,
|
||
} from "./spreadsheet_types";
|
||
import type {
|
||
CellBox,
|
||
GridHandle,
|
||
HitResult,
|
||
RefHighlight,
|
||
SpreadsheetContext,
|
||
} from "./spreadsheet_view_types";
|
||
|
||
const HEAD_ROW_H_BASE = 24;
|
||
const HEAD_COL_W_BASE = 46;
|
||
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;
|
||
|
||
/** 칸 div 재사용 — 새 보이는 칸 집합에 없는 건 떼어 다음 판에 다시 씀. */
|
||
interface CellPool {
|
||
used: Map<string, HTMLElement>;
|
||
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<string>): 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<string, CellRange> {
|
||
const map = new Map<string, CellRange>();
|
||
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 배) 자리 재기를 그대로 두고 픽셀만 곱함(수는 그대로 · 화면만 커짐). */
|
||
let measureRuler: CanvasRenderingContext2D | null = null;
|
||
|
||
/** 글 너비(px) — 칸 재기용(수가 칸 폭보다 길면 `#####`). */
|
||
function measureText(text: string, font: string): number {
|
||
measureRuler ??= document.createElement("canvas").getContext("2d");
|
||
if (!measureRuler) return text.length * 7;
|
||
measureRuler.font = font;
|
||
return measureRuler.measureText(text).width;
|
||
}
|
||
|
||
/** 칸 글꼴 CSS 글(캔버스 재기용) — `applyCellStyle` 이 칸에 입히는 글꼴과 같은 차례로 고름. */
|
||
function cellFont(style: CellStyle, book: Workbook, scale: number): string {
|
||
const family = style.글꼴 || book.기본?.글꼴 || "굴림";
|
||
const size = (style.크기 ?? book.기본?.크기 ?? 10) * scale;
|
||
const weight = style.굵게 ? "700" : "400";
|
||
const italic = style.기울임 ? "italic " : "";
|
||
return `${italic}${weight} ${size}pt ${family}`;
|
||
}
|
||
|
||
function scaledGeometry(base: LineGeometry, factor: number): LineGeometry {
|
||
if (factor === 1) return base;
|
||
return {
|
||
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;
|
||
isNumeric: boolean;
|
||
color?: string;
|
||
}
|
||
|
||
/** 칸 하나 값 — 식이면 엔진 값 · 아니면 입력 값(수는 Frac로) · 둘 다 없으면 빈 칸. */
|
||
function scalarAt(ctx: SpreadsheetContext, sheet: Sheet, r: number, c: number): Scalar {
|
||
const cell = sheet.칸[toA1(r, c)];
|
||
return cell?.식 !== undefined
|
||
? ctx.engine.value(sheet.id, r, c)
|
||
: cell?.값 !== undefined
|
||
? toScalar(cell.값)
|
||
: null;
|
||
}
|
||
|
||
/** 조건부 서식(sub4) 이 값을 보는 형 — Frac 는 근사 수로(조건 비교용 · 오류는 조건 대상 아님). */
|
||
function toCondFmtValue(scalar: Scalar): CondFmtValue {
|
||
if (scalar === null || typeof scalar === "boolean" || typeof scalar === "string") return scalar;
|
||
if ("error" in scalar) return null;
|
||
return Number(scalar.n) / Number(scalar.d);
|
||
}
|
||
|
||
function cellDisplay(
|
||
ctx: SpreadsheetContext,
|
||
sheet: Sheet,
|
||
r: number,
|
||
c: number,
|
||
hAlign0: HAlign,
|
||
code?: string,
|
||
): CellDisplay {
|
||
const scalar = scalarAt(ctx, sheet, r, c);
|
||
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.글) : "";
|
||
const isNumeric =
|
||
!isError && scalar !== null && typeof scalar !== "string" && typeof scalar !== "boolean";
|
||
return { text: formatted.글, hAlign, title, isError, isNumeric, color: formatted.색 };
|
||
}
|
||
|
||
export function createGrid(ctx: SpreadsheetContext): GridHandle {
|
||
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 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 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<string>();
|
||
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 baseStyle = cellStyleAt(book, sheet, r0, c0);
|
||
const condStyle = sheet.조건부서식?.length
|
||
? resolveCondFmtStyle(sheet, r0, c0, {
|
||
value: (rr, cc) => toCondFmtValue(scalarAt(ctx, sheet, rr, cc)),
|
||
})
|
||
: null;
|
||
const style = condStyle ? { ...baseStyle, ...condStyle } : baseStyle;
|
||
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);
|
||
// 조건부 서식 글자색이 이기고(엑셀과 같음) · 없으면 숫자 형식([Red] 등)이 정한 색.
|
||
if (display.color && !condStyle?.글자색) cellEl.style.color = display.color;
|
||
const comment = sheet.comments?.[addr];
|
||
// 수는 칸 폭보다 길면 `#####`(엑셀과 같음) · 글은 지금처럼 줄임표.
|
||
let text = display.text;
|
||
if (display.isNumeric && !style.줄바꿈) {
|
||
const font = cellFont(style, book, zoom);
|
||
const avail = w - 4;
|
||
if (measureText(text, font) > avail) {
|
||
const hashW = measureText("#", font) || 1;
|
||
text = "#".repeat(Math.max(1, Math.floor(avail / hashW)));
|
||
}
|
||
}
|
||
setCellText(cellEl, text, display.title || comment || undefined);
|
||
cellEl.classList.toggle("is-error", display.isError);
|
||
cellEl.classList.toggle("has-comment", !!comment);
|
||
cellEl.classList.toggle(
|
||
"is-selected",
|
||
ctx.selection.활성.r === r0 && ctx.selection.활성.c === c0,
|
||
);
|
||
}
|
||
}
|
||
poolSweep(pool, keep);
|
||
}
|
||
|
||
/** 건너뛴 자리(prev+1..cur-1) 안에 사용자 숨김 · 필터 걸러짐이 있나(묶음 접힘은 빼고 — 화면 표시가 다름). */
|
||
function colHiddenGap(sheet: Sheet, prev: number, cur: number): boolean {
|
||
for (let i = prev + 1; i < cur; i += 1)
|
||
if (isExplicitHidden(sheet.열?.[colName(i)])) return true;
|
||
return false;
|
||
}
|
||
function rowHiddenGap(sheet: Sheet, prev: number, cur: number): boolean {
|
||
for (let i = prev + 1; i < cur; i += 1)
|
||
if (isExplicitHidden(sheet.행?.[String(i + 1)])) return true;
|
||
return false;
|
||
}
|
||
|
||
function layoutColHeaders(
|
||
cols: number[],
|
||
pool: CellPool,
|
||
container: HTMLElement,
|
||
base: number,
|
||
): void {
|
||
const sheet = ctx.sheet();
|
||
const keep = new Set<string>();
|
||
const top = colGutterH();
|
||
const height = Math.round(HEAD_ROW_H_BASE * zoom);
|
||
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 && colHiddenGap(sheet, prev, c);
|
||
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<HTMLElement>(".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.top = `${top}px`;
|
||
cellEl.style.width = `${colGeo.size(c)}px`;
|
||
cellEl.style.height = `${height}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 sheet = ctx.sheet();
|
||
const keep = new Set<string>();
|
||
const left = rowGutterW();
|
||
const width = Math.round(HEAD_COL_W_BASE * zoom);
|
||
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 && rowHiddenGap(sheet, prev, r);
|
||
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<HTMLElement>(".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.left = `${left}px`;
|
||
cellEl.style.height = `${rowGeo.size(r)}px`;
|
||
cellEl.style.width = `${width}px`;
|
||
cellEl.classList.toggle("aislo-grid__rowhead--hidden-before", hiddenBefore);
|
||
});
|
||
poolSweep(pool, keep);
|
||
}
|
||
|
||
/** 묶음 접힘 뒤집기(같은 묶음 자리 모두) → `묶음설정` dispatch. */
|
||
function outlineToggle(axis: "행" | "열", run: OutlineRun): void {
|
||
const idx: number[] = [];
|
||
for (let i = run.from; i <= run.to; i += 1) idx.push(i);
|
||
ctx.dispatch({
|
||
종류: "묶음설정",
|
||
시트: ctx.sheet().id,
|
||
축: axis,
|
||
번호: idx,
|
||
묶음: { 층: 1, 접힘: !run.collapsed },
|
||
});
|
||
}
|
||
|
||
/** 윤곽 +/- 단추 — 행 묶음 마지막 다음 자리(머리 왼쪽 띠) · 열 묶음도 같은 이치(머리 위 띠).
|
||
* 여러 층(중첩)은 1단계 다음(지금은 늘 층 1). */
|
||
function renderRowOutline(): void {
|
||
rowHeadInnerFrozen.querySelectorAll(".aislo-grid__outline-btn").forEach((n) => n.remove());
|
||
rowHeadInnerScroll.querySelectorAll(".aislo-grid__outline-btn").forEach((n) => n.remove());
|
||
if (!rowOutlineRuns.length) return;
|
||
const gw = rowGutterW();
|
||
for (const run of rowOutlineRuns) {
|
||
const btnRow = Math.min(run.to + 1, extent.rows - 1);
|
||
const container = btnRow < frozenRows ? rowHeadInnerFrozen : rowHeadInnerScroll;
|
||
const base = btnRow < frozenRows ? 0 : rowGeo.offset(frozenRows);
|
||
const btn = document.createElement("button");
|
||
btn.type = "button";
|
||
btn.className = "aislo-grid__outline-btn";
|
||
btn.textContent = run.collapsed ? "+" : "−";
|
||
btn.style.top = `${rowGeo.offset(btnRow) - base}px`;
|
||
btn.style.left = "0";
|
||
btn.style.width = `${gw}px`;
|
||
btn.style.height = `${rowGeo.size(btnRow) || Math.round(HEAD_ROW_H_BASE * zoom)}px`;
|
||
btn.addEventListener("click", (ev) => {
|
||
ev.stopPropagation();
|
||
outlineToggle("행", run);
|
||
});
|
||
container.append(btn);
|
||
}
|
||
}
|
||
|
||
function renderColOutline(): void {
|
||
colHeadInnerFrozen.querySelectorAll(".aislo-grid__outline-btn").forEach((n) => n.remove());
|
||
colHeadInnerScroll.querySelectorAll(".aislo-grid__outline-btn").forEach((n) => n.remove());
|
||
if (!colOutlineRuns.length) return;
|
||
const gh = colGutterH();
|
||
for (const run of colOutlineRuns) {
|
||
const btnCol = Math.min(run.to + 1, extent.cols - 1);
|
||
const container = btnCol < frozenCols ? colHeadInnerFrozen : colHeadInnerScroll;
|
||
const base = btnCol < frozenCols ? 0 : colGeo.offset(frozenCols);
|
||
const btn = document.createElement("button");
|
||
btn.type = "button";
|
||
btn.className = "aislo-grid__outline-btn";
|
||
btn.textContent = run.collapsed ? "+" : "−";
|
||
btn.style.left = `${colGeo.offset(btnCol) - base}px`;
|
||
btn.style.top = "0";
|
||
btn.style.width = `${colGeo.size(btnCol) || Math.round(HEAD_COL_W_BASE * zoom)}px`;
|
||
btn.style.height = `${gh}px`;
|
||
btn.addEventListener("click", (ev) => {
|
||
ev.stopPropagation();
|
||
outlineToggle("열", run);
|
||
});
|
||
container.append(btn);
|
||
}
|
||
}
|
||
|
||
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);
|
||
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, 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),
|
||
);
|
||
renderRowOutline();
|
||
renderColOutline();
|
||
|
||
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);
|
||
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<HTMLElement>(".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);
|
||
}
|
||
const active = overlayBoxOf(sel.활성.r, sel.활성.c);
|
||
const handle = document.createElement("div");
|
||
handle.className = "aislo-grid__fill-handle";
|
||
handle.style.left = `${active.x + active.w - 4}px`;
|
||
handle.style.top = `${active.y + active.h - 4}px`;
|
||
selLayer!.append(handle);
|
||
}
|
||
|
||
function setRefHighlights(list: RefHighlight[]): void {
|
||
let refLayer = overlay.querySelector<HTMLElement>(".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;
|
||
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 {
|
||
ensureExtent(r, c);
|
||
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 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;
|
||
}
|