feat(spreadsheet): C 격자 그리기 — DOM 4 판 · 병합 · 틀고정 · 숨김 · 폭 끌기 · 서식 CSS
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VVM3xZ1aUPcUTuW2WZSnwg
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_cellstyle.ts (주인 C)
|
||||
* 서식 → CSS — 글꼴 · 정렬(균등 분할 · 선택 영역 가운데 · 세로 · 줄 바꿈) · 채움색 ·
|
||||
* 테두리(hair · thin · medium · thick · double · dotted · dashed · 색) · 겹친 테두리 고르기.
|
||||
* 칸 하나가 위 · 왼 이웃과 테두리를 나눠 가짐(표 `border-collapse` 방식) — 겹쳐 그리지 않게
|
||||
* 위 · 왼만 두 이웃 중 굵은 쪽으로 그리고 오른 · 아래는 시트 끝 칸만 그림(`resolveEdgeBorders`).
|
||||
* 0 계약 머리 — 몸은 C 가 채움. 계약은 `spreadsheet_types.ts`.
|
||||
* ========================================================================== */
|
||||
|
||||
import { colName, toA1 } from "./spreadsheet_address";
|
||||
import type {
|
||||
BorderLine,
|
||||
BorderSide,
|
||||
CellStyle,
|
||||
HAlign,
|
||||
Sheet,
|
||||
VAlign,
|
||||
Workbook,
|
||||
} from "./spreadsheet_types";
|
||||
|
||||
/** 선 굵기 차례 — 겹치면 굵은 쪽이 이김(엑셀과 완전히 같지 않음 · 근사). */
|
||||
const BORDER_WEIGHT: Record<BorderLine, number> = {
|
||||
hair: 0,
|
||||
dotted: 1,
|
||||
dashed: 2,
|
||||
thin: 3,
|
||||
double: 4,
|
||||
medium: 5,
|
||||
thick: 6,
|
||||
};
|
||||
|
||||
/** 두 이웃 칸이 한 자리에 그리려는 테두리 중 굵은 쪽. 없으면 있는 쪽 · 둘 다 없으면 undefined. */
|
||||
export function strongerBorder(a?: BorderSide, b?: BorderSide): BorderSide | undefined {
|
||||
if (!a) return b;
|
||||
if (!b) return a;
|
||||
return BORDER_WEIGHT[b.선] > BORDER_WEIGHT[a.선] ? b : a;
|
||||
}
|
||||
|
||||
/** 칸 하나가 실제로 그릴 네 변 — 위 · 왼은 이웃과 겹쳐 고름 · 오른 · 아래는 시트 끝(그 다음 칸이 없음)일 때만. */
|
||||
export interface ResolvedBorders {
|
||||
위?: BorderSide;
|
||||
아래?: BorderSide;
|
||||
왼?: BorderSide;
|
||||
오른?: BorderSide;
|
||||
}
|
||||
|
||||
/** `서식` 표 번호 — 칸 → 행 → 열 → 0(기본) 차례(칸 안의 계약 주석과 같음). */
|
||||
export function styleIndexAt(sheet: Sheet, r: number, c: number, key: string): number {
|
||||
const cell = sheet.칸[key];
|
||||
if (cell?.서식 !== undefined) return cell.서식;
|
||||
const rowFmt = sheet.행?.[String(r + 1)]?.서식;
|
||||
if (rowFmt !== undefined) return rowFmt;
|
||||
const colFmt = sheet.열?.[colName(c)]?.서식;
|
||||
if (colFmt !== undefined) return colFmt;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** (r, c) 칸이 쓸 서식 — 서식 표 밖 번호는 기본(빈 `{}`)으로. */
|
||||
export function cellStyleAt(book: Workbook, sheet: Sheet, r: number, c: number): CellStyle {
|
||||
const key = toA1(r, c);
|
||||
const idx = styleIndexAt(sheet, r, c, key);
|
||||
return book.서식[idx] ?? {};
|
||||
}
|
||||
|
||||
/** 칸 하나 · 병합 칸 한 덩이(box)의 테두리 — 네 변마다 안쪽 이웃과 겹쳐 굵은 쪽으로 고름.
|
||||
* 병합이면 왼위(r0,c0) · 오른아래(r1,c1) 이 다른 칸이라 네 변을 따로 봄(테두리는 칸마다 있음). */
|
||||
export function resolveBoxBorders(
|
||||
book: Workbook,
|
||||
sheet: Sheet,
|
||||
r0: number,
|
||||
c0: number,
|
||||
r1: number,
|
||||
c1: number,
|
||||
lastRow: number,
|
||||
lastCol: number,
|
||||
): ResolvedBorders {
|
||||
const top = cellStyleAt(book, sheet, r0, c0).테두리?.위;
|
||||
const aboveOf = r0 > 0 ? cellStyleAt(book, sheet, r0 - 1, c0).테두리?.아래 : undefined;
|
||||
const left = cellStyleAt(book, sheet, r0, c0).테두리?.왼;
|
||||
const leftOf = c0 > 0 ? cellStyleAt(book, sheet, r0, c0 - 1).테두리?.오른 : undefined;
|
||||
const bottomOwn = cellStyleAt(book, sheet, r1, c0).테두리?.아래;
|
||||
const belowOf = r1 < lastRow ? cellStyleAt(book, sheet, r1 + 1, c0).테두리?.위 : undefined;
|
||||
const rightOwn = cellStyleAt(book, sheet, r0, c1).테두리?.오른;
|
||||
const rightOf = c1 < lastCol ? cellStyleAt(book, sheet, r0, c1 + 1).테두리?.왼 : undefined;
|
||||
return {
|
||||
위: strongerBorder(top, aboveOf),
|
||||
왼: strongerBorder(left, leftOf),
|
||||
아래: r1 >= lastRow ? bottomOwn : strongerBorder(bottomOwn, belowOf),
|
||||
오른: c1 >= lastCol ? rightOwn : strongerBorder(rightOwn, rightOf),
|
||||
};
|
||||
}
|
||||
|
||||
/** 빈 글 = 인라인 테두리를 안 그림(css `aislo-grid--gridlines` 눈금선이 비쳐 보임). */
|
||||
function borderCss(side?: BorderSide): string {
|
||||
if (!side) return "";
|
||||
const color = side.색 ?? "#000000";
|
||||
switch (side.선) {
|
||||
case "hair":
|
||||
return `1px dotted ${color}`;
|
||||
case "thin":
|
||||
return `1px solid ${color}`;
|
||||
case "medium":
|
||||
return `2px solid ${color}`;
|
||||
case "thick":
|
||||
return `3px solid ${color}`;
|
||||
case "double":
|
||||
return `3px double ${color}`;
|
||||
case "dotted":
|
||||
return `1px dotted ${color}`;
|
||||
case "dashed":
|
||||
return `1px dashed ${color}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** 가로 정렬 → `text-align`(균등 분할 · 선택 영역 가운데는 근사: `justify` · `center`). */
|
||||
const HALIGN_CSS: Record<HAlign, string> = {
|
||||
general: "left",
|
||||
left: "left",
|
||||
right: "right",
|
||||
center: "center",
|
||||
distributed: "justify",
|
||||
centerContinuous: "center",
|
||||
};
|
||||
|
||||
const VALIGN_CSS: Record<VAlign, string> = {
|
||||
top: "flex-start",
|
||||
center: "center",
|
||||
bottom: "flex-end",
|
||||
};
|
||||
|
||||
/** 칸 하나에 서식 · 겹쳐 고른 테두리를 입힘. `hAlign` 은 부른 쪽이 정함(일반 정렬은 값 종류로 갈림 — B `formatValue` 의 `정렬`). */
|
||||
export function applyCellStyle(
|
||||
cell: HTMLElement,
|
||||
style: CellStyle,
|
||||
borders: ResolvedBorders,
|
||||
hAlign: HAlign,
|
||||
): void {
|
||||
const s = cell.style;
|
||||
s.fontFamily = style.글꼴 ?? "";
|
||||
s.fontSize = style.크기 ? `${style.크기}pt` : "";
|
||||
s.fontWeight = style.굵게 ? "700" : "400";
|
||||
s.fontStyle = style.기울임 ? "italic" : "normal";
|
||||
s.textDecorationLine = [style.밑줄 && "underline", style.취소선 && "line-through"]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
s.color = style.글자색 ?? "";
|
||||
s.backgroundColor = style.채움 ?? "";
|
||||
s.borderTop = borderCss(borders.위);
|
||||
s.borderLeft = borderCss(borders.왼);
|
||||
s.borderBottom = borderCss(borders.아래);
|
||||
s.borderRight = borderCss(borders.오른);
|
||||
s.justifyContent = VALIGN_CSS[style.세로 ?? "bottom"];
|
||||
s.whiteSpace = style.줄바꿈 ? "normal" : "nowrap";
|
||||
|
||||
let text = cell.firstElementChild as HTMLElement | null;
|
||||
if (!text || !text.classList.contains("aislo-cell__text")) {
|
||||
text = document.createElement("div");
|
||||
text.className = "aislo-cell__text";
|
||||
cell.replaceChildren(text);
|
||||
}
|
||||
text.style.textAlign = HALIGN_CSS[hAlign];
|
||||
text.style.textAlignLast = hAlign === "distributed" ? "justify" : "";
|
||||
}
|
||||
|
||||
/** 칸 글 — 서식 CSS 와 따로 두어 `invalidate()` 가 값만 바꿀 때 서식을 다시 잴 필요가 없게. */
|
||||
export function setCellText(cell: HTMLElement, text: string, title?: string): void {
|
||||
const node = cell.firstElementChild as HTMLElement | null;
|
||||
if (node && node.classList.contains("aislo-cell__text")) {
|
||||
node.textContent = text;
|
||||
} else {
|
||||
cell.textContent = text;
|
||||
}
|
||||
if (title) cell.title = title;
|
||||
else cell.removeAttribute("title");
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_grid.css (주인 C)
|
||||
* 격자 4 판 · 머리 · 칸 기본 모양. 색 · 간격은 여기 변수만(다른 파일은 이 클래스만 씀).
|
||||
* ========================================================================== */
|
||||
|
||||
.aislo-grid {
|
||||
--aislo-grid-line: #d9d9d9;
|
||||
--aislo-grid-head-bg: #f3f3f3;
|
||||
--aislo-grid-head-fg: #444;
|
||||
--aislo-grid-head-border: #cfcfcf;
|
||||
--aislo-grid-freeze-shadow: rgba(0, 0, 0, 0.12);
|
||||
--aislo-grid-selected: #1a73e8;
|
||||
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font:
|
||||
10pt "굴림",
|
||||
sans-serif;
|
||||
background: #fff;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.aislo-grid__corner,
|
||||
.aislo-grid__colhead-frozen,
|
||||
.aislo-grid__colhead-scroll,
|
||||
.aislo-grid__rowhead-frozen,
|
||||
.aislo-grid__rowhead-scroll,
|
||||
.aislo-grid__pane {
|
||||
position: absolute;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.aislo-grid__pane--br {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.aislo-grid__corner {
|
||||
z-index: 5;
|
||||
background: var(--aislo-grid-head-bg);
|
||||
border-right: 1px solid var(--aislo-grid-head-border);
|
||||
border-bottom: 1px solid var(--aislo-grid-head-border);
|
||||
}
|
||||
|
||||
.aislo-grid__colhead-frozen,
|
||||
.aislo-grid__colhead-scroll {
|
||||
z-index: 4;
|
||||
background: var(--aislo-grid-head-bg);
|
||||
border-bottom: 1px solid var(--aislo-grid-head-border);
|
||||
}
|
||||
|
||||
.aislo-grid__rowhead-frozen,
|
||||
.aislo-grid__rowhead-scroll {
|
||||
z-index: 4;
|
||||
background: var(--aislo-grid-head-bg);
|
||||
border-right: 1px solid var(--aislo-grid-head-border);
|
||||
}
|
||||
|
||||
.aislo-grid__colhead-frozen,
|
||||
.aislo-grid__rowhead-frozen {
|
||||
z-index: 6;
|
||||
box-shadow: 2px 2px 4px var(--aislo-grid-freeze-shadow);
|
||||
}
|
||||
|
||||
.aislo-grid__colhead-inner,
|
||||
.aislo-grid__rowhead-inner {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.aislo-grid__colhead,
|
||||
.aislo-grid__rowhead {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.82em;
|
||||
font-weight: 600;
|
||||
color: var(--aislo-grid-head-fg);
|
||||
border-right: 1px solid var(--aislo-grid-head-border);
|
||||
border-bottom: 1px solid var(--aislo-grid-head-border);
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.aislo-grid__rowhead {
|
||||
left: 0;
|
||||
top: auto;
|
||||
}
|
||||
|
||||
.aislo-grid__colhead--hidden-before,
|
||||
.aislo-grid__rowhead--hidden-before {
|
||||
border-left: 3px double #888;
|
||||
}
|
||||
|
||||
.aislo-grid__head-label {
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.aislo-grid__resize {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.aislo-grid__resize--col {
|
||||
top: 0;
|
||||
right: -3px;
|
||||
width: 6px;
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
.aislo-grid__resize--row {
|
||||
left: 0;
|
||||
bottom: -3px;
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
cursor: row-resize;
|
||||
}
|
||||
|
||||
.aislo-grid__pane-inner {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.aislo-grid__pane--br .aislo-grid__spacer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.aislo-cell {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 0 2px;
|
||||
background: #fff;
|
||||
cursor: cell;
|
||||
}
|
||||
|
||||
.aislo-grid--gridlines .aislo-cell {
|
||||
border-right: 1px solid var(--aislo-grid-line);
|
||||
border-bottom: 1px solid var(--aislo-grid-line);
|
||||
}
|
||||
|
||||
.aislo-cell__text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: inherit;
|
||||
}
|
||||
|
||||
.aislo-cell.is-error {
|
||||
color: #c00;
|
||||
}
|
||||
|
||||
.aislo-cell.is-selected {
|
||||
outline: 2px solid var(--aislo-grid-selected);
|
||||
outline-offset: -1px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.aislo-grid__ref-highlight {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
pointer-events: none;
|
||||
border: 1.5px solid;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.aislo-grid__fill-handle {
|
||||
position: absolute;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: var(--aislo-grid-selected);
|
||||
border: 1px solid #fff;
|
||||
cursor: crosshair;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.aislo-grid__overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 7;
|
||||
}
|
||||
|
||||
.aislo-grid__overlay > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
@@ -3,16 +3,705 @@
|
||||
* 격자 — DOM(칸 = 절대 위치 div) · 틀고정 4 판(왼위 · 위 · 왼 · 본문) · 판마다 보이는 행열만 그림 ·
|
||||
* 병합 칸 = div 하나 · 숨긴 행열 건너뜀 · 눈금선 · 고름 · 참조 색 테두리 · 채우기 손잡이.
|
||||
* 칸 글은 엔진 값 → B `formatValue` → 서식 CSS(`spreadsheet_cellstyle.ts`). 머리 · 폭 끌기는 `spreadsheet_headers.ts`.
|
||||
* 폭 · 높이 끌기는 `ctx.dispatch({종류:"열폭"|"행높이"})`. 0 계약 머리 — 몸은 C 가 채움.
|
||||
* 폭 · 높이 끌기는 `ctx.dispatch({종류:"열폭"|"행높이"})`.
|
||||
*
|
||||
* 4 판 — 오른아래(`br`) 만 실제 스크롤바를 가짐(overflow:auto · 안에 전체 크기 스페이서) ·
|
||||
* 나머지 세 판은 overflow:hidden 이고 `br` 의 scroll 을 그대로 옮겨 받음(틀고정 방향만).
|
||||
* 보이는 칸만 = 판 크기 안에 들어오는 행열만 실제 div 로 그림(칸 재사용 — `CellPool`).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { GridHandle, SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
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 todo = (): never => {
|
||||
throw new Error("spreadsheet_grid: 아직 없음(C)");
|
||||
};
|
||||
const HEAD_ROW_H = 24;
|
||||
const HEAD_COL_W = 46;
|
||||
const RESIZE_HOTZONE = 4;
|
||||
const REF_COLORS = ["#1a73e8", "#e8710a", "#0f9d58", "#a142f4", "#d93025"];
|
||||
|
||||
/** `ctx.grid` 는 D 가 이 결과로 채움 — 만들 때 `ctx.grid` · `ctx.editor` 를 부르지 말 것 */
|
||||
export function createGrid(_ctx: SpreadsheetContext): GridHandle {
|
||||
return todo();
|
||||
/** 칸 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;
|
||||
}
|
||||
|
||||
interface CellDisplay {
|
||||
text: string;
|
||||
hAlign: HAlign;
|
||||
title: string;
|
||||
isError: boolean;
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
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 colGeo: LineGeometry = buildColGeometry(ctx.book, ctx.sheet());
|
||||
let rowGeo: LineGeometry = buildRowGeometry(ctx.book, ctx.sheet());
|
||||
let extent = computeExtent(ctx.sheet());
|
||||
let merges = buildMergeMap(ctx.sheet());
|
||||
let frozenRows = 0;
|
||||
let frozenCols = 0;
|
||||
let frozenW = 0;
|
||||
let frozenH = 0;
|
||||
|
||||
const resize = new ResizeObserver(() => layoutPanes());
|
||||
resize.observe(root);
|
||||
|
||||
/** 판 크기 · 자리 다시 재기(창 크기 · 틀고정 수가 바뀔 때). */
|
||||
function layoutPanes(): void {
|
||||
const w = root.clientWidth;
|
||||
const h = root.clientHeight;
|
||||
frozenW = colGeo.total(frozenCols);
|
||||
frozenH = rowGeo.total(frozenRows);
|
||||
corner.style.width = `${HEAD_COL_W}px`;
|
||||
corner.style.height = `${HEAD_ROW_H}px`;
|
||||
colHeadFrozen.style.left = `${HEAD_COL_W}px`;
|
||||
colHeadFrozen.style.top = "0";
|
||||
colHeadFrozen.style.width = `${frozenW}px`;
|
||||
colHeadFrozen.style.height = `${HEAD_ROW_H}px`;
|
||||
colHeadScroll.style.left = `${HEAD_COL_W + frozenW}px`;
|
||||
colHeadScroll.style.top = "0";
|
||||
colHeadScroll.style.width = `${Math.max(0, w - HEAD_COL_W - frozenW)}px`;
|
||||
colHeadScroll.style.height = `${HEAD_ROW_H}px`;
|
||||
rowHeadFrozen.style.left = "0";
|
||||
rowHeadFrozen.style.top = `${HEAD_ROW_H}px`;
|
||||
rowHeadFrozen.style.width = `${HEAD_COL_W}px`;
|
||||
rowHeadFrozen.style.height = `${frozenH}px`;
|
||||
rowHeadScroll.style.left = "0";
|
||||
rowHeadScroll.style.top = `${HEAD_ROW_H + frozenH}px`;
|
||||
rowHeadScroll.style.width = `${HEAD_COL_W}px`;
|
||||
rowHeadScroll.style.height = `${Math.max(0, h - HEAD_ROW_H - frozenH)}px`;
|
||||
paneTL.style.left = `${HEAD_COL_W}px`;
|
||||
paneTL.style.top = `${HEAD_ROW_H}px`;
|
||||
paneTL.style.width = `${frozenW}px`;
|
||||
paneTL.style.height = `${frozenH}px`;
|
||||
paneTR.style.left = `${HEAD_COL_W + frozenW}px`;
|
||||
paneTR.style.top = `${HEAD_ROW_H}px`;
|
||||
paneTR.style.width = `${Math.max(0, w - HEAD_COL_W - frozenW)}px`;
|
||||
paneTR.style.height = `${frozenH}px`;
|
||||
paneBL.style.left = `${HEAD_COL_W}px`;
|
||||
paneBL.style.top = `${HEAD_ROW_H + frozenH}px`;
|
||||
paneBL.style.width = `${frozenW}px`;
|
||||
paneBL.style.height = `${Math.max(0, h - HEAD_ROW_H - frozenH)}px`;
|
||||
paneBR.style.left = `${HEAD_COL_W + frozenW}px`;
|
||||
paneBR.style.top = `${HEAD_ROW_H + frozenH}px`;
|
||||
paneBR.style.width = `${Math.max(0, w - HEAD_COL_W - frozenW)}px`;
|
||||
paneBR.style.height = `${Math.max(0, h - HEAD_ROW_H - frozenH)}px`;
|
||||
spacer.style.transform = `translate(${colGeo.total(extent.cols) - frozenW}px, ${rowGeo.total(extent.rows) - frozenH}px)`;
|
||||
overlay.style.left = `${HEAD_COL_W}px`;
|
||||
overlay.style.top = `${HEAD_ROW_H}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);
|
||||
|
||||
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 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);
|
||||
setCellText(cellEl, display.text, display.title || undefined);
|
||||
cellEl.classList.toggle("is-error", display.isError);
|
||||
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<string>();
|
||||
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<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.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<string>();
|
||||
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<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.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 - HEAD_COL_W - frozenW);
|
||||
const scrollH = Math.max(0, h - HEAD_ROW_H - 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();
|
||||
colGeo = buildColGeometry(ctx.book, sheet);
|
||||
rowGeo = buildRowGeometry(ctx.book, sheet);
|
||||
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";
|
||||
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 = HEAD_COL_W + (inFrozenCol ? box.x : box.x - paneBR.scrollLeft);
|
||||
const y = HEAD_ROW_H + (inFrozenRow ? box.y : box.y - paneBR.scrollTop);
|
||||
return { x, y, 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: HEAD_COL_W + (inFrozenCol ? box.x : box.x - paneBR.scrollLeft),
|
||||
y: HEAD_ROW_H + (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 = `${HEAD_COL_W + (inFrozenCol ? active.x : active.x - paneBR.scrollLeft) + active.w - 4}px`;
|
||||
handle.style.top = `${HEAD_ROW_H + (inFrozenRow ? active.y : active.y - paneBR.scrollTop) + 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 = `${HEAD_COL_W + (inFrozenCol ? merged.x : merged.x - paneBR.scrollLeft)}px`;
|
||||
node.style.top = `${HEAD_ROW_H + (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 < HEAD_COL_W && y < HEAD_ROW_H) return { kind: "corner", r: 0, c: 0 };
|
||||
const contentX =
|
||||
x < HEAD_COL_W ? -1 : x - HEAD_COL_W + (x - HEAD_COL_W > frozenW ? paneBR.scrollLeft : 0);
|
||||
const contentY =
|
||||
y < HEAD_ROW_H ? -1 : y - HEAD_ROW_H + (y - HEAD_ROW_H > frozenH ? paneBR.scrollTop : 0);
|
||||
if (y < HEAD_ROW_H) {
|
||||
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 < HEAD_COL_W) {
|
||||
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 - HEAD_COL_W - frozenW);
|
||||
const scrollH = Math.max(0, root.clientHeight - HEAD_ROW_H - 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)),
|
||||
};
|
||||
}
|
||||
|
||||
function editorSlot(r: number, c: number): { layer: HTMLElement; box: CellBox } {
|
||||
return { layer: overlay, 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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_headers.ts (주인 C)
|
||||
* 열 글자 · 행 번호 머리 · 폭 · 높이 재기(숨김 = 0) · 끌기 · 숨김 표시.
|
||||
* `LineGeometry` = 자리 하나 재기 — 자리 대부분 기본값이라(성긴 `열`·`행` 표) 겹친 만큼만
|
||||
* 어긋난 표로 쥐고 이분 탐색으로 자리 ↔ 픽셀을 오간다(1만 행이어도 가벼움).
|
||||
* 0 계약 머리 — 몸은 C 가 채움. 계약은 `spreadsheet_types.ts`.
|
||||
* ========================================================================== */
|
||||
|
||||
import { colIndex, colName } from "./spreadsheet_address";
|
||||
import type { RowInfo, Sheet, Workbook } from "./spreadsheet_types";
|
||||
import type { SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
|
||||
export const DEFAULT_COL_CHARS = 8.43;
|
||||
export const DEFAULT_ROW_PT = 15;
|
||||
/** 엑셀 글자 폭 → px 근사(굴림 10pt 기준 · Calibri 공식과 같은 꼴). */
|
||||
const CHAR_PX = 7;
|
||||
const CHAR_PAD_PX = 5;
|
||||
const PT_PX = 4 / 3;
|
||||
|
||||
export const colCharsToPx = (chars: number): number => Math.round(chars * CHAR_PX + CHAR_PAD_PX);
|
||||
export const pxToColChars = (px: number): number => Math.max(0, (px - CHAR_PAD_PX) / CHAR_PX);
|
||||
export const ptToPx = (pt: number): number => Math.round(pt * PT_PX);
|
||||
export const pxToPt = (px: number): number => px / PT_PX;
|
||||
|
||||
export const colLabel = (c: number): string => colName(c);
|
||||
export const rowLabel = (r: number): string => String(r + 1);
|
||||
|
||||
/** 자리 하나 재기 — 성긴 기본값 어긋남 표(정렬된 색인)로 자리 ↔ 픽셀 변환. */
|
||||
export interface LineGeometry {
|
||||
/** 자리 `i` 폭(px) — 숨겼으면 0. */
|
||||
size(i: number): number;
|
||||
/** 자리 `i` 가 0 부터 시작하는 픽셀 자리(내용 좌표). */
|
||||
offset(i: number): number;
|
||||
/** 자리 `count` 개(0..count-1)를 합친 픽셀 길이. */
|
||||
total(count: number): number;
|
||||
/** 픽셀 자리 `px` 가 든 자리 번호(0 부터). */
|
||||
indexAt(px: number): number;
|
||||
}
|
||||
|
||||
class SparseGeometry implements LineGeometry {
|
||||
private readonly idx: number[] = [];
|
||||
private readonly prefix: number[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly base: number,
|
||||
overrides: Map<number, number>,
|
||||
) {
|
||||
const keys = [...overrides.keys()].sort((a, b) => a - b);
|
||||
let acc = 0;
|
||||
for (const k of keys) {
|
||||
acc += overrides.get(k)! - base;
|
||||
this.idx.push(k);
|
||||
this.prefix.push(acc);
|
||||
}
|
||||
}
|
||||
|
||||
/** 어긋난 표에서 `i` 가 들어갈 자리(첫 idx[k] >= i). */
|
||||
private lowerBound(i: number): number {
|
||||
let lo = 0;
|
||||
let hi = this.idx.length;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (this.idx[mid] < i) lo = mid + 1;
|
||||
else hi = mid;
|
||||
}
|
||||
return lo;
|
||||
}
|
||||
|
||||
size(i: number): number {
|
||||
const at = this.lowerBound(i);
|
||||
if (this.idx[at] === i) {
|
||||
const before = at > 0 ? this.prefix[at - 1] : 0;
|
||||
return this.base + (this.prefix[at] - before);
|
||||
}
|
||||
return this.base;
|
||||
}
|
||||
|
||||
offset(i: number): number {
|
||||
const at = this.lowerBound(i);
|
||||
const acc = at > 0 ? this.prefix[at - 1] : 0;
|
||||
return i * this.base + acc;
|
||||
}
|
||||
|
||||
total(count: number): number {
|
||||
return this.offset(count);
|
||||
}
|
||||
|
||||
indexAt(px: number): number {
|
||||
if (px <= 0) return 0;
|
||||
let hi = Math.max(1, Math.ceil(px / Math.max(this.base, 1)) + this.idx.length + 2);
|
||||
while (this.offset(hi) <= px) hi *= 2;
|
||||
let lo = 0;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi + 1) >> 1;
|
||||
if (this.offset(mid) <= px) lo = mid;
|
||||
else hi = mid - 1;
|
||||
}
|
||||
return lo;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildColGeometry(book: Workbook, sheet: Sheet): LineGeometry {
|
||||
const base = colCharsToPx(book.기본?.열폭 ?? DEFAULT_COL_CHARS);
|
||||
const overrides = new Map<number, number>();
|
||||
for (const [key, info] of Object.entries(sheet.열 ?? {})) {
|
||||
const c = colIndex(key);
|
||||
if (c < 0) continue;
|
||||
if (info.숨김) overrides.set(c, 0);
|
||||
else if (info.폭 !== undefined) overrides.set(c, colCharsToPx(info.폭));
|
||||
}
|
||||
return new SparseGeometry(base, overrides);
|
||||
}
|
||||
|
||||
export function buildRowGeometry(book: Workbook, sheet: Sheet): LineGeometry {
|
||||
const base = ptToPx(book.기본?.행높이 ?? DEFAULT_ROW_PT);
|
||||
const overrides = new Map<number, number>();
|
||||
for (const [key, info] of Object.entries(sheet.행 ?? {}) as [string, RowInfo][]) {
|
||||
const r = Number(key) - 1;
|
||||
if (!Number.isInteger(r) || r < 0) continue;
|
||||
if (info.숨김) overrides.set(r, 0);
|
||||
else if (info.높이 !== undefined) overrides.set(r, ptToPx(info.높이));
|
||||
}
|
||||
return new SparseGeometry(base, overrides);
|
||||
}
|
||||
|
||||
/** `from..to` 안에서 숨기지 않은 자리만. */
|
||||
export function visibleIndices(geo: LineGeometry, from: number, to: number): number[] {
|
||||
const list: number[] = [];
|
||||
for (let i = from; i <= to; i += 1) if (geo.size(i) > 0) list.push(i);
|
||||
return list;
|
||||
}
|
||||
|
||||
export interface HeaderCell {
|
||||
el: HTMLElement;
|
||||
index: number;
|
||||
}
|
||||
|
||||
/** 열 머리 칸 — 글자 · 폭 끌기 손잡이 · 숨김 표시(왼쪽에 건너뛴 열이 있으면). */
|
||||
export function makeColHeaderCell(
|
||||
c: number,
|
||||
x: number,
|
||||
w: number,
|
||||
hiddenBefore: boolean,
|
||||
): HeaderCell {
|
||||
const cellEl = document.createElement("div");
|
||||
cellEl.className = "aislo-grid__colhead";
|
||||
if (hiddenBefore) cellEl.classList.add("aislo-grid__colhead--hidden-before");
|
||||
cellEl.style.left = `${x}px`;
|
||||
cellEl.style.width = `${w}px`;
|
||||
cellEl.dataset.col = String(c);
|
||||
const label = document.createElement("span");
|
||||
label.className = "aislo-grid__head-label";
|
||||
label.textContent = colLabel(c);
|
||||
cellEl.append(label);
|
||||
const handle = document.createElement("div");
|
||||
handle.className = "aislo-grid__resize aislo-grid__resize--col";
|
||||
cellEl.append(handle);
|
||||
return { el: cellEl, index: c };
|
||||
}
|
||||
|
||||
/** 행 머리 칸 — 번호 · 높이 끌기 손잡이 · 숨김 표시(위에 건너뛴 행이 있으면). */
|
||||
export function makeRowHeaderCell(
|
||||
r: number,
|
||||
y: number,
|
||||
h: number,
|
||||
hiddenBefore: boolean,
|
||||
): HeaderCell {
|
||||
const cellEl = document.createElement("div");
|
||||
cellEl.className = "aislo-grid__rowhead";
|
||||
if (hiddenBefore) cellEl.classList.add("aislo-grid__rowhead--hidden-before");
|
||||
cellEl.style.top = `${y}px`;
|
||||
cellEl.style.height = `${h}px`;
|
||||
cellEl.dataset.row = String(r);
|
||||
const label = document.createElement("span");
|
||||
label.className = "aislo-grid__head-label";
|
||||
label.textContent = rowLabel(r);
|
||||
cellEl.append(label);
|
||||
const handle = document.createElement("div");
|
||||
handle.className = "aislo-grid__resize aislo-grid__resize--row";
|
||||
cellEl.append(handle);
|
||||
return { el: cellEl, index: r };
|
||||
}
|
||||
|
||||
export interface ResizeDragOptions {
|
||||
handle: HTMLElement;
|
||||
axis: "col" | "row";
|
||||
currentPx: () => number;
|
||||
onPreview: (px: number) => void;
|
||||
onCommit: (px: number) => void;
|
||||
}
|
||||
|
||||
/** 폭 · 높이 끌기 — 누르는 순간 시작 픽셀을 쥐고 포인터가 옮긴 만큼 더함. */
|
||||
export function startDragResize(opts: ResizeDragOptions): void {
|
||||
opts.handle.addEventListener("pointerdown", (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
const startClient = opts.axis === "col" ? ev.clientX : ev.clientY;
|
||||
const startPx = opts.currentPx();
|
||||
opts.handle.setPointerCapture(ev.pointerId);
|
||||
const next = (e: PointerEvent): number => {
|
||||
const cur = opts.axis === "col" ? e.clientX : e.clientY;
|
||||
return Math.max(4, startPx + (cur - startClient));
|
||||
};
|
||||
const move = (e: PointerEvent): void => opts.onPreview(next(e));
|
||||
const up = (e: PointerEvent): void => {
|
||||
opts.handle.removeEventListener("pointermove", move);
|
||||
opts.handle.removeEventListener("pointerup", up);
|
||||
opts.onCommit(next(e));
|
||||
};
|
||||
opts.handle.addEventListener("pointermove", move);
|
||||
opts.handle.addEventListener("pointerup", up);
|
||||
});
|
||||
}
|
||||
|
||||
/** 폭 끌기 다 됨 → `열폭` 명령(글자 단위로 되돌려 보냄). */
|
||||
export function commitColWidth(ctx: SpreadsheetContext, c: number, px: number): void {
|
||||
ctx.dispatch({ 종류: "열폭", 시트: ctx.sheet().id, 열: [c], 폭: pxToColChars(px) });
|
||||
}
|
||||
|
||||
/** 높이 끌기 다 됨 → `행높이` 명령(pt 단위로 되돌려 보냄). */
|
||||
export function commitRowHeight(ctx: SpreadsheetContext, r: number, px: number): void {
|
||||
ctx.dispatch({ 종류: "행높이", 시트: ctx.sheet().id, 행: [r], 높이: pxToPt(px) });
|
||||
}
|
||||
Reference in New Issue
Block a user