① 스크롤 영역이 빈 시트 기본 200행에서 안 늘어나 이름 상자 · Ctrl+방향키로 먼 칸 가도 화면이 안 따라가던 것 — reveal() 이 그 칸 밖이면 스페이서를 늘림(ensureExtent) · 스크롤 끝 가까이 닿아도 미리 늘림(growOnScrollEdge, 엑셀처럼). ② 열 폭 자동 맞춤 상한 없어 아주 긴 글로 약 4만 px 까지 늘던 것 — colCharsToPx 에 엑셀 상한(255 자) 캡을 걸어 렌더 쪽에서 막음. ③ 칸 폭보다 긴 수는 줄임표 대신 `#####`(엑셀과 같음) — 캔버스로 재 폭 넘으면 바꿔치기 · 글은 그대로 줄임표. ④ 틀 고정을 풀어도 왼위 · 위 · 왼 판에 칸이 하나 남던 것(A1 이 DOM 에 네 벌) — `Math.max(0, frozenCols-1)` 이 0 일 때도 0 을 줘 범위가 안 비던 계산 버그 고침. ORCA 수치 — ① 스크롤 영역 201,901px→1,001,901px 로 늘고 scrollTop 목표 자리로 이동 확인 · ② 5,702 자 요청에도 1,790px(255 자) 로 막힘 확인 · ③ 「123456789012345」→「#######」 확인 · ④ 해제 뒤 A1 DOM 칸 수 1 로 확인. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VVM3xZ1aUPcUTuW2WZSnwg
256 lines
9.5 KiB
TypeScript
256 lines
9.5 KiB
TypeScript
/* =============================================================================
|
|
* 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 MAX_COL_CHARS = 255;
|
|
|
|
export const colCharsToPx = (chars: number): number =>
|
|
Math.round(Math.min(chars, MAX_COL_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(sheet.기본?.열폭 ?? 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.숨김 || 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(sheet.기본?.행높이 ?? 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.숨김 || info.걸러짐 || info.묶음?.접힘) overrides.set(r, 0);
|
|
else if (info.높이 !== undefined) overrides.set(r, ptToPx(info.높이));
|
|
}
|
|
return new SparseGeometry(base, overrides);
|
|
}
|
|
|
|
/** 접힌 윤곽 묶음(연속 자리) — 묶음 있는 자리를 모아 붙은 것끼리 한 덩이로(같은 접힘 값끼리만). */
|
|
export interface OutlineRun {
|
|
from: number;
|
|
to: number;
|
|
collapsed: boolean;
|
|
}
|
|
|
|
export function buildOutlineRuns<T extends { 묶음?: { 층: number; 접힘?: boolean } }>(
|
|
records: Record<string, T> | undefined,
|
|
indexOf: (key: string) => number,
|
|
): OutlineRun[] {
|
|
const items = Object.entries(records ?? {})
|
|
.map(([key, info]) => ({ i: indexOf(key), collapsed: !!info.묶음?.접힘, has: !!info.묶음 }))
|
|
.filter((x) => x.has && x.i >= 0)
|
|
.sort((a, b) => a.i - b.i);
|
|
const runs: OutlineRun[] = [];
|
|
for (const it of items) {
|
|
const last = runs[runs.length - 1];
|
|
if (last && last.to === it.i - 1 && last.collapsed === it.collapsed) last.to = it.i;
|
|
else runs.push({ from: it.i, to: it.i, collapsed: it.collapsed });
|
|
}
|
|
return runs;
|
|
}
|
|
|
|
/** 그 자리가 숨김 · 걸러짐(사용자 · 필터) 인가 — 묶음 접힘은 빼고(화면 표시가 다름). */
|
|
export function isExplicitHidden(info: { 숨김?: boolean; 걸러짐?: boolean } | undefined): boolean {
|
|
return !!info?.숨김 || !!info?.걸러짐;
|
|
}
|
|
|
|
/** `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) });
|
|
}
|