- 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
651 lines
24 KiB
TypeScript
651 lines
24 KiB
TypeScript
/* =============================================================================
|
|
* spreadsheet_grid.ts (주인 C)
|
|
* 격자 — DOM(칸 = 절대 위치 div) · 틀고정 4 판(왼위 · 위 · 왼 · 본문) · 판마다 보이는 행열만 그림 ·
|
|
* 병합 칸 = div 하나 · 숨긴 행열 건너뜀 · 눈금선 · 고름 · 참조 색 테두리 · 채우기 손잡이.
|
|
* 칸 글은 엔진 값 → B `formatValue` → 서식 CSS(`spreadsheet_cellstyle.ts`). 머리 · 폭 끌기는 `spreadsheet_headers.ts`.
|
|
* 폭 · 높이 끌기는 `ctx.dispatch({종류:"열폭"|"행높이"})`.
|
|
*
|
|
* 4 판 — 오른아래(`br`) 만 실제 스크롤바를 가짐(overflow:auto · 안에 전체 크기 스페이서) ·
|
|
* 나머지 세 판은 overflow:hidden 이고 `br` 의 scroll 을 그대로 옮겨 받음(틀고정 방향만).
|
|
* 보이는 칸만 = 판 크기 안에 들어오는 행열만 실제 div 로 그림(칸 재사용 — `CellPool`).
|
|
* 칸 그리기 · 넘침은 `spreadsheet_grid_cells.ts` · 행열 머리 · 윤곽은 `spreadsheet_grid_heads.ts`.
|
|
* ========================================================================== */
|
|
|
|
import "./spreadsheet_grid.css";
|
|
import { colIndex, toA1 } from "./spreadsheet_address";
|
|
import {
|
|
buildColGeometry,
|
|
buildOutlineRuns,
|
|
buildRowGeometry,
|
|
visibleIndices,
|
|
type LineGeometry,
|
|
type OutlineRun,
|
|
} from "./spreadsheet_headers";
|
|
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, SheetCellAddress } from "./spreadsheet_types";
|
|
import type {
|
|
CellBox,
|
|
GridHandle,
|
|
HitResult,
|
|
RefHighlight,
|
|
SpreadsheetContext,
|
|
} from "./spreadsheet_view_types";
|
|
|
|
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;
|
|
|
|
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 baseColGeo: LineGeometry = buildColGeometry(ctx.book, ctx.sheet());
|
|
let baseRowGeo: LineGeometry = buildRowGeometry(ctx.book, ctx.sheet());
|
|
let colGeo: LineGeometry = baseColGeo;
|
|
let rowGeo: LineGeometry = baseRowGeo;
|
|
let extent = computeExtent(ctx.sheet());
|
|
let merges = buildMergeMap(ctx.sheet());
|
|
let frozenRows = 0;
|
|
let frozenCols = 0;
|
|
let frozenW = 0;
|
|
let frozenH = 0;
|
|
let lastSheetId = "";
|
|
let zoom = 1;
|
|
let rowOutlineRuns: OutlineRun[] = [];
|
|
let colOutlineRuns: OutlineRun[] = [];
|
|
/** 행 묶음 윤곽 띠 — 행 머리 왼쪽(있을 때만) · 열 묶음 띠는 열 머리 위. */
|
|
const rowGutterW = (): number =>
|
|
rowOutlineRuns.length ? Math.round(OUTLINE_GUTTER_BASE * zoom) : 0;
|
|
const colGutterH = (): number =>
|
|
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);
|
|
|
|
/** 판 크기 · 자리 다시 재기(창 크기 · 틀고정 수가 바뀔 때). */
|
|
function layoutPanes(): void {
|
|
const w = root.clientWidth;
|
|
const h = root.clientHeight;
|
|
root.style.fontFamily = ctx.book.기본?.글꼴 ?? "";
|
|
root.style.fontSize = ctx.book.기본?.크기 ? `${ctx.book.기본.크기 * zoom}pt` : `${10 * zoom}pt`;
|
|
root.style.setProperty("--aislo-grid-gridline-w", `${Math.max(1, Math.round(zoom))}px`);
|
|
frozenW = colGeo.total(frozenCols);
|
|
frozenH = rowGeo.total(frozenRows);
|
|
corner.style.width = `${headColW()}px`;
|
|
corner.style.height = `${headRowH()}px`;
|
|
colHeadFrozen.style.left = `${headColW()}px`;
|
|
colHeadFrozen.style.top = "0";
|
|
colHeadFrozen.style.width = `${frozenW}px`;
|
|
colHeadFrozen.style.height = `${headRowH()}px`;
|
|
colHeadScroll.style.left = `${headColW() + frozenW}px`;
|
|
colHeadScroll.style.top = "0";
|
|
colHeadScroll.style.width = `${Math.max(0, w - headColW() - frozenW)}px`;
|
|
colHeadScroll.style.height = `${headRowH()}px`;
|
|
rowHeadFrozen.style.left = "0";
|
|
rowHeadFrozen.style.top = `${headRowH()}px`;
|
|
rowHeadFrozen.style.width = `${headColW()}px`;
|
|
rowHeadFrozen.style.height = `${frozenH}px`;
|
|
rowHeadScroll.style.left = "0";
|
|
rowHeadScroll.style.top = `${headRowH() + frozenH}px`;
|
|
rowHeadScroll.style.width = `${headColW()}px`;
|
|
rowHeadScroll.style.height = `${Math.max(0, h - headRowH() - frozenH)}px`;
|
|
paneTL.style.left = `${headColW()}px`;
|
|
paneTL.style.top = `${headRowH()}px`;
|
|
paneTL.style.width = `${frozenW}px`;
|
|
paneTL.style.height = `${frozenH}px`;
|
|
paneTR.style.left = `${headColW() + frozenW}px`;
|
|
paneTR.style.top = `${headRowH()}px`;
|
|
paneTR.style.width = `${Math.max(0, w - headColW() - frozenW)}px`;
|
|
paneTR.style.height = `${frozenH}px`;
|
|
paneBL.style.left = `${headColW()}px`;
|
|
paneBL.style.top = `${headRowH() + frozenH}px`;
|
|
paneBL.style.width = `${frozenW}px`;
|
|
paneBL.style.height = `${Math.max(0, h - headRowH() - frozenH)}px`;
|
|
paneBR.style.left = `${headColW() + frozenW}px`;
|
|
paneBR.style.top = `${headRowH() + frozenH}px`;
|
|
paneBR.style.width = `${Math.max(0, w - headColW() - frozenW)}px`;
|
|
paneBR.style.height = `${Math.max(0, h - headRowH() - frozenH)}px`;
|
|
resizeSpacer();
|
|
overlay.style.left = `${headColW()}px`;
|
|
overlay.style.top = `${headRowH()}px`;
|
|
overlay.style.right = "0";
|
|
overlay.style.bottom = "0";
|
|
renderVisible();
|
|
}
|
|
|
|
/** `br` 스크롤 위치 → 세 판(머리 둘 · tr · bl)에 옮겨 그리고 보이는 칸을 다시. */
|
|
function resizeSpacer(): void {
|
|
spacer.style.transform = `translate(${colGeo.total(extent.cols) - frozenW}px, ${rowGeo.total(extent.rows) - frozenH}px)`;
|
|
}
|
|
|
|
/** 이름 상자 · Ctrl+방향키 등으로 간 칸이 지금 스크롤 영역 밖이면 그만큼 늘림(엑셀처럼 · 빈 시트도 안 막힘). */
|
|
function ensureExtent(r: number, c: number): void {
|
|
let grown = false;
|
|
if (r >= extent.rows - 1) {
|
|
extent.rows = Math.min(MAX_ROWS, r + 100);
|
|
grown = true;
|
|
}
|
|
if (c >= extent.cols - 1) {
|
|
extent.cols = Math.min(MAX_COLS, c + 20);
|
|
grown = true;
|
|
}
|
|
if (grown) resizeSpacer();
|
|
}
|
|
|
|
/** 스크롤이 끝 가까이 닿으면 미리 늘림(휠 · 스크롤바로 끝까지 밀 때도 안 막힘). */
|
|
function growOnScrollEdge(): void {
|
|
const edge = 100;
|
|
if (
|
|
extent.rows < MAX_ROWS &&
|
|
paneBR.scrollTop + paneBR.clientHeight >= paneBR.scrollHeight - edge
|
|
) {
|
|
extent.rows = Math.min(MAX_ROWS, extent.rows + 100);
|
|
resizeSpacer();
|
|
}
|
|
if (
|
|
extent.cols < MAX_COLS &&
|
|
paneBR.scrollLeft + paneBR.clientWidth >= paneBR.scrollWidth - edge
|
|
) {
|
|
extent.cols = Math.min(MAX_COLS, extent.cols + 20);
|
|
resizeSpacer();
|
|
}
|
|
}
|
|
|
|
function onScroll(): void {
|
|
growOnScrollEdge();
|
|
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);
|
|
|
|
/** Ctrl+휠 확대 · 축소(10~400%) — 칸 · 머리 · 선 굵기가 같이 커짐(`headRowH`·`headColW`·`applyCellStyle` scale). */
|
|
function onWheel(ev: WheelEvent): void {
|
|
if (!ev.ctrlKey) return;
|
|
ev.preventDefault();
|
|
const before = zoom;
|
|
const next = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoom * Math.exp(-ev.deltaY * 0.0015)));
|
|
if (next === before) return;
|
|
zoom = next;
|
|
colGeo = scaledGeometry(baseColGeo, zoom);
|
|
rowGeo = scaledGeometry(baseRowGeo, zoom);
|
|
paneBR.scrollLeft *= zoom / before;
|
|
paneBR.scrollTop *= zoom / before;
|
|
layoutPanes();
|
|
root.dispatchEvent(new CustomEvent("aislo-grid-zoom", { detail: { zoom }, bubbles: true }));
|
|
}
|
|
root.addEventListener("wheel", onWheel, { passive: false });
|
|
|
|
const gridlines = (): boolean => ctx.sheet().보기?.눈금선 !== false;
|
|
|
|
function renderVisible(): void {
|
|
root.classList.toggle("aislo-grid--gridlines", gridlines());
|
|
const w = root.clientWidth;
|
|
const h = root.clientHeight;
|
|
const scrollW = Math.max(0, w - headColW() - frozenW);
|
|
const scrollH = Math.max(0, h - headRowH() - frozenH);
|
|
const frozenColsVisible = visibleIndices(colGeo, 0, frozenCols - 1);
|
|
// 본문 판 스크롤 0 = 틀고정 바로 다음 칸(`offset(frozen)`) — 그만큼 더해 재야 오른 · 아래 끝이 안 빔.
|
|
const left = paneBR.scrollLeft + frozenW;
|
|
const top = paneBR.scrollTop + frozenH;
|
|
const scrollColFrom = Math.max(frozenCols, colGeo.indexAt(left));
|
|
const scrollColTo = Math.min(extent.cols - 1, colGeo.indexAt(left + scrollW) + 1);
|
|
const scrollColsVisible = visibleIndices(
|
|
colGeo,
|
|
scrollColFrom,
|
|
Math.max(scrollColFrom, scrollColTo),
|
|
);
|
|
const frozenRowsVisible = visibleIndices(rowGeo, 0, frozenRows - 1);
|
|
const scrollRowFrom = Math.max(frozenRows, rowGeo.indexAt(top));
|
|
const scrollRowTo = Math.min(extent.rows - 1, rowGeo.indexAt(top + 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),
|
|
);
|
|
renderRowOutline();
|
|
renderColOutline();
|
|
|
|
renderCells(ctx, view, frozenRowsVisible, frozenColsVisible, pools.tl, paneTLInner, 0, 0);
|
|
renderCells(
|
|
ctx,
|
|
view,
|
|
frozenRowsVisible,
|
|
scrollColsVisible,
|
|
pools.tr,
|
|
paneTRInner,
|
|
colGeo.offset(frozenCols),
|
|
0,
|
|
);
|
|
renderCells(
|
|
ctx,
|
|
view,
|
|
scrollRowsVisible,
|
|
frozenColsVisible,
|
|
pools.bl,
|
|
paneBLInner,
|
|
0,
|
|
rowGeo.offset(frozenRows),
|
|
);
|
|
renderCells(
|
|
ctx,
|
|
view,
|
|
scrollRowsVisible,
|
|
scrollColsVisible,
|
|
pools.br,
|
|
paneBRInner,
|
|
colGeo.offset(frozenCols),
|
|
rowGeo.offset(frozenRows),
|
|
);
|
|
|
|
renderSelection();
|
|
}
|
|
|
|
function render(): void {
|
|
const sheet = ctx.sheet();
|
|
baseColGeo = buildColGeometry(ctx.book, sheet);
|
|
baseRowGeo = buildRowGeometry(ctx.book, sheet);
|
|
colGeo = scaledGeometry(baseColGeo, zoom);
|
|
rowGeo = scaledGeometry(baseRowGeo, zoom);
|
|
extent = computeExtent(sheet);
|
|
merges = buildMergeMap(sheet);
|
|
rowOutlineRuns = buildOutlineRuns(sheet.행, (k) => Number(k) - 1);
|
|
colOutlineRuns = buildOutlineRuns(sheet.열, colIndex);
|
|
frozenRows = Math.min(sheet.틀고정?.행 ?? 0, extent.rows);
|
|
frozenCols = Math.min(sheet.틀고정?.열 ?? 0, extent.cols);
|
|
spacer.style.width = "1px";
|
|
spacer.style.height = "1px";
|
|
if (sheet.id !== lastSheetId) {
|
|
lastSheetId = sheet.id;
|
|
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 = headColW() + (inFrozenCol ? box.x : box.x - paneBR.scrollLeft);
|
|
const y = headRowH() + (inFrozenRow ? box.y : box.y - paneBR.scrollTop);
|
|
return { x, y, w: box.w, h: box.h };
|
|
}
|
|
|
|
/** `overlay` 안 자리 — `overlay` 자신이 이미 머리 칸(headColW·headRowH)만큼 밀려 있어
|
|
* `screenBoxOf`(격자 뿌리 기준)를 그대로 쓰면 머리 칸만큼 두 번 밀림(찾은 버그 · 2026-09-27). */
|
|
function overlayBoxOf(r: number, c: number): CellBox {
|
|
const box = boxOf(r, c);
|
|
const inFrozenCol = c < frozenCols;
|
|
const inFrozenRow = r < frozenRows;
|
|
return {
|
|
x: inFrozenCol ? box.x : box.x - paneBR.scrollLeft,
|
|
y: inFrozenRow ? box.y : box.y - paneBR.scrollTop,
|
|
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: inFrozenCol ? box.x : box.x - paneBR.scrollLeft,
|
|
y: 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 = "var(--aislo-grid-selected)";
|
|
node.style.borderWidth = i === 0 ? "2px" : "1px";
|
|
selLayer!.append(node);
|
|
}
|
|
// 활성 칸 테두리 — 칸 다시 그리기(`renderRowOfCells`) 없이 고름만 바뀌어도 옛 칸에 남지 않게.
|
|
const activeKey = `${sel.활성.r},${sel.활성.c}`;
|
|
const anchor = merges.get(activeKey);
|
|
const activeAddr = toA1(anchor?.r0 ?? sel.활성.r, anchor?.c0 ?? sel.활성.c);
|
|
for (const el of root.querySelectorAll<HTMLElement>(".aislo-cell.is-selected"))
|
|
if (el.dataset.addr !== activeAddr) el.classList.remove("is-selected");
|
|
for (const el of root.querySelectorAll<HTMLElement>(`.aislo-cell[data-addr="${activeAddr}"]`))
|
|
el.classList.add("is-selected");
|
|
const handleAt = fillHandleCell();
|
|
if (!handleAt) return;
|
|
const corner = overlayBoxOf(handleAt.r, handleAt.c);
|
|
const handle = document.createElement("div");
|
|
handle.className = "aislo-grid__fill-handle";
|
|
handle.style.left = `${corner.x + corner.w - 4}px`;
|
|
handle.style.top = `${corner.y + corner.h - 4}px`;
|
|
selLayer!.append(handle);
|
|
}
|
|
|
|
/** 채우기 손잡이 자리 — 고른 범위 오른아래 칸(엑셀) · 범위가 여럿이거나 읽기 전용이면 없음. */
|
|
function fillHandleCell(): { r: number; c: number } | null {
|
|
const sel = ctx.selection;
|
|
if (ctx.readOnly || !sel || sel.범위.length !== 1) return null;
|
|
const g = sel.범위[0];
|
|
return { r: g.r1, c: g.c1 };
|
|
}
|
|
|
|
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 = `${inFrozenCol ? merged.x : merged.x - paneBR.scrollLeft}px`;
|
|
node.style.top = `${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 < headColW() && y < headRowH()) return { kind: "corner", r: 0, c: 0 };
|
|
const contentX =
|
|
x < headColW() ? -1 : x - headColW() + (x - headColW() > frozenW ? paneBR.scrollLeft : 0);
|
|
const contentY =
|
|
y < headRowH() ? -1 : y - headRowH() + (y - headRowH() > frozenH ? paneBR.scrollTop : 0);
|
|
if (y < headRowH()) {
|
|
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 < headColW()) {
|
|
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;
|
|
const handleAt = sel && sel.시트 === ctx.sheet().id ? fillHandleCell() : null;
|
|
if (handleAt) {
|
|
const screen = screenBoxOf(handleAt.r, handleAt.c);
|
|
if (Math.abs(x - (screen.x + screen.w)) <= 5 && Math.abs(y - (screen.y + screen.h)) <= 5) {
|
|
return { kind: "fillHandle", r: handleAt.r, c: handleAt.c };
|
|
}
|
|
}
|
|
return { kind: "cell", r, c };
|
|
}
|
|
|
|
function reveal(r: number, c: number): void {
|
|
ensureExtent(r, c);
|
|
const [top0, left0] = [paneBR.scrollTop, paneBR.scrollLeft];
|
|
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;
|
|
}
|
|
// scroll 이벤트는 다음 그림 때 옴 — 먼 칸으로 뛸 때 한 틀 비어 보이지 않게 바로 그림
|
|
if (paneBR.scrollTop !== top0 || paneBR.scrollLeft !== left0) onScroll();
|
|
}
|
|
|
|
function visibleRange(): CellRange {
|
|
const scrollW = Math.max(0, root.clientWidth - headColW() - frozenW);
|
|
const scrollH = Math.max(0, root.clientHeight - headRowH() - frozenH);
|
|
const left = paneBR.scrollLeft + frozenW;
|
|
const top = paneBR.scrollTop + frozenH;
|
|
return {
|
|
r0: rowGeo.indexAt(top),
|
|
c0: colGeo.indexAt(left),
|
|
r1: Math.min(extent.rows - 1, rowGeo.indexAt(top + scrollH)),
|
|
c1: Math.min(extent.cols - 1, colGeo.indexAt(left + scrollW)),
|
|
};
|
|
}
|
|
|
|
/** 칸이 든 판(틀고정 4 판 중 하나)의 안쪽 층 — 어느 판인지는 이 반환 요소로 앎. */
|
|
function paneLayerOf(r: number, c: number): HTMLElement {
|
|
const inFrozenRow = r < frozenRows;
|
|
const inFrozenCol = c < frozenCols;
|
|
if (inFrozenRow) return inFrozenCol ? paneTLInner : paneTRInner;
|
|
return inFrozenCol ? paneBLInner : paneBRInner;
|
|
}
|
|
|
|
/** 편집기 자리 — `layer` = 칸이 든 판의 안쪽 층 · `box` = 격자 뿌리 기준 화면 좌표(D 가 층 차이를 빼서 맞춤). */
|
|
function editorSlot(r: number, c: number): { layer: HTMLElement; box: CellBox } {
|
|
return { layer: paneLayerOf(r, c), box: screenBoxOf(r, c) };
|
|
}
|
|
|
|
function getZoom(): number {
|
|
return zoom;
|
|
}
|
|
|
|
function destroy(): void {
|
|
resize.disconnect();
|
|
paneBR.removeEventListener("scroll", onScroll);
|
|
root.remove();
|
|
}
|
|
|
|
render();
|
|
|
|
return {
|
|
root,
|
|
render,
|
|
invalidate,
|
|
renderSelection,
|
|
setRefHighlights,
|
|
cellBox: boxOf,
|
|
hitTest,
|
|
reveal,
|
|
visibleRange,
|
|
editorSlot,
|
|
zoom: getZoom,
|
|
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;
|
|
}
|