Files
Aislo/A00_Common/spreadsheet/spreadsheet_grid.ts
T
eomsangdonandClaude Sonnet 5 e48870765e feat(spreadsheet): C 격자 — 조건부 서식(sub4) 얹음 · 오류 칸 초록 세모(왼 위)
resolveCondFmtStyle 결과를 칸 서식 위에 덮어씀(채움·글자색·굵게) · 글자색은 조건부 서식이
숫자 형식([Red] 등)보다 이김(엑셀과 같음). 오류 칸은 기존 title 풍선에 더해 왼 위 초록
세모(엑셀 오류 검사 표시)도 그림. 필터로 걸러진 행 숨김은 이전 커밋(ebdee04f)에서 이미
buildRowGeometry 에 같이 넣어 둠(걸러짐 · 숨김 · 묶음 접힘 모두 처리).
ORCA 시험 틀 — 조건 맞는 칸 배경·글자색·굵기 수치로 확인 · 오류 칸 title·세모 확인.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVM3xZ1aUPcUTuW2WZSnwg
2026-09-27 21:34:58 +09:00

899 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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,
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 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 배) 자리 재기를 그대로 두고 픽셀만 곱함(수는 그대로 · 화면만 커짐). */
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;
}
/** 칸 하나 값 — 식이면 엔진 값 · 아니면 입력 값(수는 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.글) : "";
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;
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`;
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<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];
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);
}
/** 건너뛴 자리(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, 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),
);
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 = "#1a73e8";
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 {
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;
}