feat(spreadsheet): C 격자 — Ctrl+휠 확대·축소(10~400%, 칸·머리·선 굵기 같이)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVM3xZ1aUPcUTuW2WZSnwg
This commit is contained in:
2026-09-27 21:04:03 +09:00
co-authored by Claude Sonnet 5
parent 6b08058a58
commit 85ada11e9d
3 changed files with 113 additions and 69 deletions
+18 -15
View File
@@ -91,25 +91,26 @@ export function resolveBoxBorders(
};
}
/** 빈 글 = 인라인 테두리를 안 그림(css `aislo-grid--gridlines` 눈금선이 비쳐 보임). */
function borderCss(side?: BorderSide): string {
/** 빈 글 = 인라인 테두리를 안 그림(css `aislo-grid--gridlines` 눈금선이 비쳐 보임). `scale` = 확대 · 축소 배율(선 굵기도 같이). */
function borderCss(side: BorderSide | undefined, scale: number): string {
if (!side) return "";
const color = side.색 ?? "#000000";
const w = (px: number): number => Math.max(1, Math.round(px * scale));
switch (side.선) {
case "hair":
return `1px dotted ${color}`;
return `${w(1)}px dotted ${color}`;
case "thin":
return `1px solid ${color}`;
return `${w(1)}px solid ${color}`;
case "medium":
return `2px solid ${color}`;
return `${w(2)}px solid ${color}`;
case "thick":
return `3px solid ${color}`;
return `${w(3)}px solid ${color}`;
case "double":
return `3px double ${color}`;
return `${w(3)}px double ${color}`;
case "dotted":
return `1px dotted ${color}`;
return `${w(1)}px dotted ${color}`;
case "dashed":
return `1px dashed ${color}`;
return `${w(1)}px dashed ${color}`;
}
}
@@ -129,16 +130,18 @@ const VALIGN_CSS: Record<VAlign, string> = {
bottom: "flex-end",
};
/** 칸 하나에 서식 · 겹쳐 고른 테두리를 입힘. `hAlign` 은 부른 쪽이 정함(일반 정렬은 값 종류로 갈림 — B `formatValue` 의 `정렬`). */
/** 칸 하나에 서식 · 겹쳐 고른 테두리를 입힘. `hAlign` 은 부른 쪽이 정함(일반 정렬은 값 종류로 갈림 — B `formatValue` 의 `정렬`).
* `scale` = 확대 · 축소 배율(칸 글자 크기 · 테두리 굵기가 같이 커짐 · 기본 1). */
export function applyCellStyle(
cell: HTMLElement,
style: CellStyle,
borders: ResolvedBorders,
hAlign: HAlign,
scale = 1,
): void {
const s = cell.style;
s.fontFamily = style.글꼴 ?? "";
s.fontSize = style.크기 ? `${style.크기}pt` : "";
s.fontSize = style.크기 ? `${style.크기 * scale}pt` : "";
s.fontWeight = style.굵게 ? "700" : "400";
s.fontStyle = style.기울임 ? "italic" : "normal";
s.textDecorationLine = [style.밑줄 && "underline", style.취소선 && "line-through"]
@@ -146,10 +149,10 @@ export function applyCellStyle(
.join(" ");
s.color = style.글자색 ?? "";
s.backgroundColor = style.채움 ?? "";
s.borderTop = borderCss(borders.위);
s.borderLeft = borderCss(borders.왼);
s.borderBottom = borderCss(borders.아래);
s.borderRight = borderCss(borders.오른);
s.borderTop = borderCss(borders.위, scale);
s.borderLeft = borderCss(borders.왼, scale);
s.borderBottom = borderCss(borders.아래, scale);
s.borderRight = borderCss(borders.오른, scale);
s.justifyContent = VALIGN_CSS[style.세로 ?? "bottom"];
s.whiteSpace = style.줄바꿈 ? "normal" : "nowrap";
+6 -2
View File
@@ -152,8 +152,12 @@
}
.aislo-grid--gridlines .aislo-cell {
border-right: 1px solid var(--aislo-grid-line);
border-bottom: 1px solid var(--aislo-grid-line);
border-right-style: solid;
border-bottom-style: solid;
border-right-width: var(--aislo-grid-gridline-w, 1px);
border-bottom-width: var(--aislo-grid-gridline-w, 1px);
border-right-color: var(--aislo-grid-line);
border-bottom-color: var(--aislo-grid-line);
}
.aislo-cell__text {
+89 -52
View File
@@ -49,10 +49,12 @@ import type {
SpreadsheetContext,
} from "./spreadsheet_view_types";
const HEAD_ROW_H = 24;
const HEAD_COL_W = 46;
const HEAD_ROW_H_BASE = 24;
const HEAD_COL_W_BASE = 46;
const RESIZE_HOTZONE = 4;
const REF_COLORS = ["#1a73e8", "#e8710a", "#0f9d58", "#a142f4", "#d93025"];
const ZOOM_MIN = 0.1;
const ZOOM_MAX = 4;
/** 칸 div 재사용 — 새 보이는 칸 집합에 없는 건 떼어 다음 판에 다시 씀. */
interface CellPool {
@@ -125,6 +127,17 @@ function buildMergeMap(sheet: Sheet): Map<string, CellRange> {
return map;
}
/** 확대 · 축소 — 밑감(1 배) 자리 재기를 그대로 두고 픽셀만 곱함(수는 그대로 · 화면만 커짐). */
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;
@@ -207,8 +220,10 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
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 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;
@@ -216,6 +231,9 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
let frozenW = 0;
let frozenH = 0;
let lastSheetId = "";
let zoom = 1;
const headRowH = (): number => Math.round(HEAD_ROW_H_BASE * zoom);
const headColW = (): number => Math.round(HEAD_COL_W_BASE * zoom);
const resize = new ResizeObserver(() => layoutPanes());
resize.observe(root);
@@ -224,45 +242,48 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
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 = `${HEAD_COL_W}px`;
corner.style.height = `${HEAD_ROW_H}px`;
colHeadFrozen.style.left = `${HEAD_COL_W}px`;
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 = `${HEAD_ROW_H}px`;
colHeadScroll.style.left = `${HEAD_COL_W + frozenW}px`;
colHeadFrozen.style.height = `${headRowH()}px`;
colHeadScroll.style.left = `${headColW() + 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`;
colHeadScroll.style.width = `${Math.max(0, w - headColW() - frozenW)}px`;
colHeadScroll.style.height = `${headRowH()}px`;
rowHeadFrozen.style.left = "0";
rowHeadFrozen.style.top = `${HEAD_ROW_H}px`;
rowHeadFrozen.style.width = `${HEAD_COL_W}px`;
rowHeadFrozen.style.top = `${headRowH()}px`;
rowHeadFrozen.style.width = `${headColW()}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`;
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 = `${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.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 = `${HEAD_COL_W}px`;
paneBL.style.top = `${HEAD_ROW_H + 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 - 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`;
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`;
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.left = `${headColW()}px`;
overlay.style.top = `${headRowH()}px`;
overlay.style.right = "0";
overlay.style.bottom = "0";
renderVisible();
@@ -280,6 +301,22 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
}
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.addEventListener("wheel", onWheel, { passive: false });
const gridlines = (): boolean => ctx.sheet().보기?.눈금선 !== false;
function renderRowOfCells(
@@ -329,7 +366,7 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
extent.cols - 1,
);
const display = cellDisplay(ctx, sheet, r0, c0, style.가로 ?? "general", style.형식);
applyCellStyle(cellEl, style, borders, display.hAlign);
applyCellStyle(cellEl, style, borders, display.hAlign, zoom);
if (display.color) cellEl.style.color = display.color;
const comment = sheet.comments?.[addr];
setCellText(cellEl, display.text, display.title || comment || undefined);
@@ -420,8 +457,8 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
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 scrollW = Math.max(0, w - headColW() - frozenW);
const scrollH = Math.max(0, h - headRowH() - 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);
@@ -485,10 +522,10 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
function render(): void {
const sheet = ctx.sheet();
root.style.fontFamily = ctx.book.기본?.글꼴 ?? "";
root.style.fontSize = ctx.book.기본?.크기 ? `${ctx.book.기본.크기}pt` : "";
colGeo = buildColGeometry(ctx.book, sheet);
rowGeo = buildRowGeometry(ctx.book, 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);
frozenRows = Math.min(sheet.틀고정?.행 ?? 0, extent.rows);
@@ -534,8 +571,8 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
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);
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 };
}
@@ -559,8 +596,8 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
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),
x: headColW() + (inFrozenCol ? box.x : box.x - paneBR.scrollLeft),
y: headRowH() + (inFrozenRow ? box.y : box.y - paneBR.scrollTop),
w: box.w,
h: box.h,
};
@@ -579,8 +616,8 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
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`;
handle.style.left = `${headColW() + (inFrozenCol ? active.x : active.x - paneBR.scrollLeft) + active.w - 4}px`;
handle.style.top = `${headRowH() + (inFrozenRow ? active.y : active.y - paneBR.scrollTop) + active.h - 4}px`;
selLayer!.append(handle);
}
@@ -606,8 +643,8 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
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.left = `${headColW() + (inFrozenCol ? merged.x : merged.x - paneBR.scrollLeft)}px`;
node.style.top = `${headRowH() + (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];
@@ -620,17 +657,17 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
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 };
if (x < headColW() && y < headRowH()) 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);
x < headColW() ? -1 : x - headColW() + (x - headColW() > 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) {
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 < HEAD_COL_W) {
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 };
@@ -669,8 +706,8 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
}
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);
const scrollW = Math.max(0, root.clientWidth - headColW() - frozenW);
const scrollH = Math.max(0, root.clientHeight - headRowH() - frozenH);
return {
r0: rowGeo.indexAt(paneBR.scrollTop),
c0: colGeo.indexAt(paneBR.scrollLeft),