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
This commit is contained in:
@@ -8,47 +8,31 @@
|
||||
* 4 판 — 오른아래(`br`) 만 실제 스크롤바를 가짐(overflow:auto · 안에 전체 크기 스페이서) ·
|
||||
* 나머지 세 판은 overflow:hidden 이고 `br` 의 scroll 을 그대로 옮겨 받음(틀고정 방향만).
|
||||
* 보이는 칸만 = 판 크기 안에 들어오는 행열만 실제 div 로 그림(칸 재사용 — `CellPool`).
|
||||
* 칸 그리기 · 넘침은 `spreadsheet_grid_cells.ts` · 행열 머리 · 윤곽은 `spreadsheet_grid_heads.ts`.
|
||||
* ========================================================================== */
|
||||
|
||||
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,
|
||||
isAutoInk,
|
||||
resolveBoxBorders,
|
||||
setCellText,
|
||||
type ResolvedBorders,
|
||||
} from "./spreadsheet_cellstyle";
|
||||
import { colIndex, toA1 } from "./spreadsheet_address";
|
||||
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 { spillOf, type SpillAlign, type SpillSlot } from "./spreadsheet_spill";
|
||||
import {
|
||||
buildMergeMap,
|
||||
computeExtent,
|
||||
createPool,
|
||||
renderCells,
|
||||
scaledGeometry,
|
||||
type CellPool,
|
||||
type GridView,
|
||||
} from "./spreadsheet_grid_cells";
|
||||
import { createHeads, HEAD_COL_W_BASE, HEAD_ROW_H_BASE } from "./spreadsheet_grid_heads";
|
||||
import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types";
|
||||
import type {
|
||||
CellRange,
|
||||
CellStyle,
|
||||
ErrorValue,
|
||||
HAlign,
|
||||
Scalar,
|
||||
Sheet,
|
||||
SheetCellAddress,
|
||||
Workbook,
|
||||
} from "./spreadsheet_types";
|
||||
import type { CellRange, SheetCellAddress } from "./spreadsheet_types";
|
||||
import type {
|
||||
CellBox,
|
||||
GridHandle,
|
||||
@@ -57,172 +41,12 @@ import type {
|
||||
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) — 칸 재기용(수가 칸 폭보다 길면 `#####`). */
|
||||
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 = "";
|
||||
}
|
||||
|
||||
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";
|
||||
@@ -296,6 +120,47 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
|
||||
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 view: GridView = {
|
||||
get colGeo() {
|
||||
return colGeo;
|
||||
},
|
||||
get rowGeo() {
|
||||
return rowGeo;
|
||||
},
|
||||
get merges() {
|
||||
return merges;
|
||||
},
|
||||
get extent() {
|
||||
return extent;
|
||||
},
|
||||
get zoom() {
|
||||
return zoom;
|
||||
},
|
||||
get frozenRows() {
|
||||
return frozenRows;
|
||||
},
|
||||
get frozenCols() {
|
||||
return frozenCols;
|
||||
},
|
||||
get rowOutlineRuns() {
|
||||
return rowOutlineRuns;
|
||||
},
|
||||
get colOutlineRuns() {
|
||||
return colOutlineRuns;
|
||||
},
|
||||
rowGutterW,
|
||||
colGutterH,
|
||||
};
|
||||
const { layoutColHeaders, layoutRowHeaders, renderRowOutline, renderColOutline } = createHeads(
|
||||
ctx,
|
||||
view,
|
||||
{
|
||||
colFrozen: colHeadInnerFrozen,
|
||||
colScroll: colHeadInnerScroll,
|
||||
rowFrozen: rowHeadInnerFrozen,
|
||||
rowScroll: rowHeadInnerScroll,
|
||||
},
|
||||
);
|
||||
|
||||
const resize = new ResizeObserver(() => layoutPanes());
|
||||
resize.observe(root);
|
||||
@@ -420,279 +285,6 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
|
||||
|
||||
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>();
|
||||
// 긴 글 넘침 — 줄마다 칸 자리를 모았다가 판을 다 그린 뒤 폄(옆 칸이 빈지 알아야 해서)
|
||||
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);
|
||||
}
|
||||
|
||||
/** 건너뛴 자리(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;
|
||||
@@ -736,8 +328,10 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
|
||||
renderRowOutline();
|
||||
renderColOutline();
|
||||
|
||||
renderRowOfCells(frozenRowsVisible, frozenColsVisible, pools.tl, paneTLInner, 0, 0);
|
||||
renderRowOfCells(
|
||||
renderCells(ctx, view, frozenRowsVisible, frozenColsVisible, pools.tl, paneTLInner, 0, 0);
|
||||
renderCells(
|
||||
ctx,
|
||||
view,
|
||||
frozenRowsVisible,
|
||||
scrollColsVisible,
|
||||
pools.tr,
|
||||
@@ -745,7 +339,9 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
|
||||
colGeo.offset(frozenCols),
|
||||
0,
|
||||
);
|
||||
renderRowOfCells(
|
||||
renderCells(
|
||||
ctx,
|
||||
view,
|
||||
scrollRowsVisible,
|
||||
frozenColsVisible,
|
||||
pools.bl,
|
||||
@@ -753,7 +349,9 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
|
||||
0,
|
||||
rowGeo.offset(frozenRows),
|
||||
);
|
||||
renderRowOfCells(
|
||||
renderCells(
|
||||
ctx,
|
||||
view,
|
||||
scrollRowsVisible,
|
||||
scrollColsVisible,
|
||||
pools.br,
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
/* =============================================================================
|
||||
* 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);
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_grid_heads.ts
|
||||
* 격자 머리 — 행 · 열 머리 칸(보이는 것만 · 폭 · 높이 끌기) · 숨긴 자리 표시 · 묶음 윤곽 +/- 단추.
|
||||
* `spreadsheet_grid.ts` 가 판을 그릴 때 부름 — 머리 칸 모양 · 끌기는 `spreadsheet_headers.ts`.
|
||||
* ========================================================================== */
|
||||
|
||||
import { colName } from "./spreadsheet_address";
|
||||
import {
|
||||
commitColWidth,
|
||||
commitRowHeight,
|
||||
isExplicitHidden,
|
||||
makeColHeaderCell,
|
||||
makeRowHeaderCell,
|
||||
startDragResize,
|
||||
type OutlineRun,
|
||||
} from "./spreadsheet_headers";
|
||||
import { poolSweep, type CellPool, type GridView } from "./spreadsheet_grid_cells";
|
||||
import type { Sheet } from "./spreadsheet_types";
|
||||
import type { SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
|
||||
export const HEAD_ROW_H_BASE = 24;
|
||||
export const HEAD_COL_W_BASE = 46;
|
||||
|
||||
/** 머리 안쪽 층 넷 — 틀고정 쪽 · 스크롤 쪽. */
|
||||
export interface HeadLayers {
|
||||
colFrozen: HTMLElement;
|
||||
colScroll: HTMLElement;
|
||||
rowFrozen: HTMLElement;
|
||||
rowScroll: HTMLElement;
|
||||
}
|
||||
|
||||
export function createHeads(ctx: SpreadsheetContext, view: GridView, layers: HeadLayers) {
|
||||
const {
|
||||
colFrozen: colHeadInnerFrozen,
|
||||
colScroll: colHeadInnerScroll,
|
||||
rowFrozen: rowHeadInnerFrozen,
|
||||
rowScroll: rowHeadInnerScroll,
|
||||
} = layers;
|
||||
|
||||
/** 건너뛴 자리(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 = view.colGutterH();
|
||||
const height = Math.round(HEAD_ROW_H_BASE * view.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: () => view.colGeo.size(c),
|
||||
onPreview: (px) => {
|
||||
cellEl!.style.width = `${px}px`;
|
||||
},
|
||||
onCommit: (px) => commitColWidth(ctx, c, px),
|
||||
});
|
||||
}
|
||||
cellEl.style.left = `${view.colGeo.offset(c) - base}px`;
|
||||
cellEl.style.top = `${top}px`;
|
||||
cellEl.style.width = `${view.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 = view.rowGutterW();
|
||||
const width = Math.round(HEAD_COL_W_BASE * view.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: () => view.rowGeo.size(r),
|
||||
onPreview: (px) => {
|
||||
cellEl!.style.height = `${px}px`;
|
||||
},
|
||||
onCommit: (px) => commitRowHeight(ctx, r, px),
|
||||
});
|
||||
}
|
||||
cellEl.style.top = `${view.rowGeo.offset(r) - base}px`;
|
||||
cellEl.style.left = `${left}px`;
|
||||
cellEl.style.height = `${view.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 (!view.rowOutlineRuns.length) return;
|
||||
const gw = view.rowGutterW();
|
||||
for (const run of view.rowOutlineRuns) {
|
||||
const btnRow = Math.min(run.to + 1, view.extent.rows - 1);
|
||||
const container = btnRow < view.frozenRows ? rowHeadInnerFrozen : rowHeadInnerScroll;
|
||||
const base = btnRow < view.frozenRows ? 0 : view.rowGeo.offset(view.frozenRows);
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "aislo-grid__outline-btn";
|
||||
btn.textContent = run.collapsed ? "+" : "−";
|
||||
btn.style.top = `${view.rowGeo.offset(btnRow) - base}px`;
|
||||
btn.style.left = "0";
|
||||
btn.style.width = `${gw}px`;
|
||||
btn.style.height = `${view.rowGeo.size(btnRow) || Math.round(HEAD_ROW_H_BASE * view.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 (!view.colOutlineRuns.length) return;
|
||||
const gh = view.colGutterH();
|
||||
for (const run of view.colOutlineRuns) {
|
||||
const btnCol = Math.min(run.to + 1, view.extent.cols - 1);
|
||||
const container = btnCol < view.frozenCols ? colHeadInnerFrozen : colHeadInnerScroll;
|
||||
const base = btnCol < view.frozenCols ? 0 : view.colGeo.offset(view.frozenCols);
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "aislo-grid__outline-btn";
|
||||
btn.textContent = run.collapsed ? "+" : "−";
|
||||
btn.style.left = `${view.colGeo.offset(btnCol) - base}px`;
|
||||
btn.style.top = "0";
|
||||
btn.style.width = `${view.colGeo.size(btnCol) || Math.round(HEAD_COL_W_BASE * view.zoom)}px`;
|
||||
btn.style.height = `${gh}px`;
|
||||
btn.addEventListener("click", (ev) => {
|
||||
ev.stopPropagation();
|
||||
outlineToggle("열", run);
|
||||
});
|
||||
container.append(btn);
|
||||
}
|
||||
}
|
||||
|
||||
return { layoutColHeaders, layoutRowHeaders, renderRowOutline, renderColOutline };
|
||||
}
|
||||
@@ -41,7 +41,10 @@
|
||||
* B spreadsheet_functions_misc.ts 논리 · 글 · 찾기 · 세기 ~500
|
||||
* B spreadsheet_numfmt.ts 숫자 형식 코드 → 보이는 글 ~400
|
||||
* B spreadsheet_fill.ts 채우기 핸들 연속값 ~200
|
||||
* C spreadsheet_grid.ts 격자 — 보이는 칸만 · 틀고정 4 판 · 병합 · 고름 그림 ~550
|
||||
* C spreadsheet_grid.ts 격자 — 보이는 칸만 · 틀고정 4 판 · 병합 · 고름 그림 ~650
|
||||
* C spreadsheet_grid_cells.ts 판 하나 칸 그리기 · 값 글 · #### · 넘침 붙이기 ~320
|
||||
* C spreadsheet_grid_heads.ts 행열 머리 칸 · 숨김 표시 · 묶음 윤곽 단추 ~200
|
||||
* C spreadsheet_spill.ts 긴 글 넘침 계산(옆 빈 칸) ~60
|
||||
* C spreadsheet_headers.ts 열 글자 · 행 번호 · 폭 · 높이 끌기 · 숨김 표시 ~350
|
||||
* C spreadsheet_cellstyle.ts 서식 → CSS(hair · 균등 분할 · 겹친 테두리) ~250
|
||||
* C spreadsheet_grid.css ~200
|
||||
|
||||
Reference in New Issue
Block a user