Files
Aislo/A00_Common/spreadsheet/spreadsheet_grid_cells.ts
T
eomsangdonandClaude Opus 5.5 5c0f12a59a refactor(spreadsheet): 격자 1052 줄을 셋으로 나눔 — 칸 그리기 · 머리 · 판(순수 옮기기)
- spreadsheet_grid_cells.ts: 판 하나 칸 그리기 · 값 글 · #### · 긴 글 넘침 붙이기 · 칸 재사용 ·
  자리 재기 도우미
- spreadsheet_grid_heads.ts: 행열 머리 칸 · 숨김 표시 · 묶음 윤곽 단추 · 폭 · 높이 끌기 잇기
- spreadsheet_grid.ts: 틀고정 4 판 · 스크롤 · 확대 · 고름 · 참조 테두리 · 채우기 손잡이 · 누른 자리
  (649 줄) — 자리 재기 · 병합 · 확대는 읽기 전용 `GridView` 로 건넴(끌기 중에도 지금 값)
- 동작 · 겉모양 그대로 · 화면 조각 gzip 33.4 → 33.8 KB(한도 72)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYN9gMie8e6pXzSxJNb6Xt
2026-09-28 23:29:24 +09:00

323 lines
12 KiB
TypeScript

/* =============================================================================
* spreadsheet_grid_cells.ts
* 격자 칸 그리기 — 판 하나(틀고정 4 판 중)의 보이는 칸을 div 로 그림(칸 재사용 `CellPool`) ·
* 값 → 글(`formatValue`) · 서식 CSS · 수 `#####` · 긴 글 넘침(`spreadsheet_spill.ts`) 붙이기.
* `spreadsheet_grid.ts` 가 판마다 부름 — 자리 재기 · 병합 · 확대는 `GridView` 로 받음.
* ========================================================================== */
import { toFrac } from "@ui/sheet/ui_template_sheet_frac";
import { colIndex, parseA1, parseRange, toA1 } from "./spreadsheet_address";
import {
applyCellStyle,
cellStyleAt,
isAutoInk,
resolveBoxBorders,
setCellText,
type ResolvedBorders,
} from "./spreadsheet_cellstyle";
import type { LineGeometry, OutlineRun } from "./spreadsheet_headers";
import { resolveCondFmtStyle, type CondFmtValue } from "./spreadsheet_condfmt";
import { formatValue } from "./spreadsheet_numfmt";
import { spillOf, type SpillAlign, type SpillSlot } from "./spreadsheet_spill";
import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types";
import type {
CellRange,
CellStyle,
ErrorValue,
HAlign,
Scalar,
Sheet,
Workbook,
} from "./spreadsheet_types";
import type { SpreadsheetContext } from "./spreadsheet_view_types";
/** 격자가 지금 쓰는 자리 재기 · 병합 · 틀고정 — `createGrid` 의 값을 읽기만(바뀌면 새 값이 보임). */
export interface GridView {
readonly colGeo: LineGeometry;
readonly rowGeo: LineGeometry;
readonly merges: Map<string, CellRange>;
readonly extent: { rows: number; cols: number };
readonly zoom: number;
readonly frozenRows: number;
readonly frozenCols: number;
readonly rowOutlineRuns: OutlineRun[];
readonly colOutlineRuns: OutlineRun[];
rowGutterW(): number;
colGutterH(): number;
}
/** 칸 div 재사용 — 새 보이는 칸 집합에 없는 건 떼어 다음 판에 다시 씀. */
export interface CellPool {
used: Map<string, HTMLElement>;
free: HTMLElement[];
}
export 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;
}
export 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;
}
/** 시트에 실제 쓰인 자리 + 여유(엑셀 최대 자리까지 다 쓰지 않음 — 스크롤판 스페이서를 적당히 둠). */
export 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)),
};
}
export 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;
}
const SPILL_ALIGN: Partial<Record<HAlign, SpillAlign>> = {
general: "left",
left: "left",
right: "right",
center: "center",
};
function resetSpill(cell: HTMLElement): void {
cell.classList.remove("is-spill", "is-spill-r");
const t = cell.firstElementChild as HTMLElement | null;
if (t) t.style.width = t.style.marginLeft = t.style.clipPath = "";
}
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}`;
}
/** 확대 · 축소 — 밑감(1 배) 자리 재기를 그대로 두고 픽셀만 곱함(수는 그대로 · 화면만 커짐). */
export 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.색 };
}
/** 판 하나의 보이는 칸 — 병합 칸은 div 하나 · 안 보이게 된 칸은 떼어 둠(다음에 다시 씀). */
export function renderCells(
ctx: SpreadsheetContext,
view: GridView,
rowsVisible: number[],
colsVisible: number[],
pool: CellPool,
container: HTMLElement,
baseX: number,
baseY: number,
): void {
const { colGeo, rowGeo, merges, extent, zoom } = view;
const sheet = ctx.sheet();
const book = ctx.book;
const keep = new Set<string>();
// 긴 글 넘침 — 줄마다 칸 자리를 모았다가 판을 다 그린 뒤 폄(옆 칸이 빈지 알아야 해서)
const slots = new Map<number, Map<number, SpillSlot>>();
const longs: {
r: number;
c: number;
el: HTMLElement;
own: number;
need: number;
align: SpillAlign;
}[] = [];
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?.글자색 && !isAutoInk(display.color))
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);
resetSpill(cellEl);
if (!merge) {
(slots.get(r0) ?? slots.set(r0, new Map()).get(r0)!).set(c0, { w, empty: text === "" });
const align = SPILL_ALIGN[display.hAlign];
if (align && text && !display.isNumeric && !display.isError && !style.줄바꿈) {
// 캔버스 폭은 거르기만(글꼴 대체로 조금 어긋남) — 정확한 폭은 아래서 글 요소 scrollWidth 로
const need = measureText(text, cellFont(style, book, zoom)) + 4;
if (need > w * 0.9) longs.push({ r: r0, c: c0, el: cellEl, own: w, need, align });
}
}
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,
);
}
}
// 글 폭 + 칸 안 여백(양쪽 2px) · 오른 선 1px — 읽기를 먼저 몰아 한 번만 배치 계산
for (const l of longs) l.need = (l.el.firstElementChild as HTMLElement).scrollWidth + 5;
for (const l of longs) {
const sp = spillOf(slots.get(l.r)!, l.c, l.own, l.need, l.align);
if (!sp) continue;
l.el.classList.add("is-spill");
const t = l.el.firstElementChild as HTMLElement;
t.style.width = `calc(100% + ${sp.left + sp.right}px)`;
t.style.marginLeft = `${-sp.left}px`;
if (sp.clipLeft || sp.clipRight)
t.style.clipPath = `inset(0 ${sp.clipRight}px 0 ${sp.clipLeft}px)`;
for (let k = sp.from; k < sp.to; k++) pool.used.get(`${l.r},${k}`)?.classList.add("is-spill-r");
}
poolSweep(pool, keep);
}