Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VVM3xZ1aUPcUTuW2WZSnwg
224 lines
8.1 KiB
TypeScript
224 lines
8.1 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 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(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.숨김) 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.숨김) 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) });
|
|
}
|