diff --git a/A00_Common/spreadsheet/spreadsheet_commands.ts b/A00_Common/spreadsheet/spreadsheet_commands.ts index e89a56a8..2a98c605 100644 --- a/A00_Common/spreadsheet/spreadsheet_commands.ts +++ b/A00_Common/spreadsheet/spreadsheet_commands.ts @@ -450,7 +450,7 @@ function setAxis( s: Sheet, axis: "행" | "열", idx: number[], - prop: "폭" | "높이" | "숨김", + prop: "폭" | "높이" | "숨김" | "묶음", value: V | null, undoOf: (idx: number[], prev: V | null) => Command, ): Command { @@ -561,6 +561,19 @@ export function applyCommand(book: Workbook, command: Command): CommandEffect { })), ); } + case "묶음설정": { + const s = sheetOf(book, command.시트); + const { 축 } = command; + return plain( + setAxis(s, 축, command.번호, "묶음", command.묶음, (번호, prev) => ({ + 종류: "묶음설정", + 시트: s.id, + 축, + 번호, + 묶음: prev, + })), + ); + } case "틀고정": { const s = sheetOf(book, command.시트); const prev = { 행: s.틀고정?.행 ?? 0, 열: s.틀고정?.열 ?? 0 }; diff --git a/A00_Common/spreadsheet/spreadsheet_grid.css b/A00_Common/spreadsheet/spreadsheet_grid.css index 9cf34e1d..be6845e2 100644 --- a/A00_Common/spreadsheet/spreadsheet_grid.css +++ b/A00_Common/spreadsheet/spreadsheet_grid.css @@ -116,6 +116,19 @@ cursor: col-resize; } +.aislo-grid__outline-btn { + position: absolute; + z-index: 2; + box-sizing: border-box; + padding: 0; + border: 1px solid var(--aislo-grid-head-border); + background: #fff; + color: var(--aislo-grid-head-fg); + font-size: 0.7em; + line-height: 1; + cursor: pointer; +} + .aislo-grid__resize--row { left: 0; bottom: -3px; diff --git a/A00_Common/spreadsheet/spreadsheet_grid.ts b/A00_Common/spreadsheet/spreadsheet_grid.ts index 9aec9cba..6f455663 100644 --- a/A00_Common/spreadsheet/spreadsheet_grid.ts +++ b/A00_Common/spreadsheet/spreadsheet_grid.ts @@ -12,7 +12,7 @@ import "./spreadsheet_grid.css"; import { toFrac } from "@ui/sheet/ui_template_sheet_frac"; -import { colIndex, parseA1, parseRange, toA1 } from "./spreadsheet_address"; +import { colIndex, colName, parseA1, parseRange, toA1 } from "./spreadsheet_address"; import { applyCellStyle, cellStyleAt, @@ -22,14 +22,17 @@ import { } from "./spreadsheet_cellstyle"; import { buildColGeometry, + buildOutlineRuns, buildRowGeometry, commitColWidth, commitRowHeight, + isExplicitHidden, makeColHeaderCell, makeRowHeaderCell, startDragResize, visibleIndices, type LineGeometry, + type OutlineRun, } from "./spreadsheet_headers"; import { formatValue } from "./spreadsheet_numfmt"; import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types"; @@ -51,6 +54,7 @@ import type { 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; @@ -232,8 +236,15 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle { 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); + 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 resize = new ResizeObserver(() => layoutPanes()); resize.observe(root); @@ -381,18 +392,33 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle { 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(); + 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; + const hiddenBefore = c - prev > 1 && colHiddenGap(sheet, prev, c); let cellEl = pool.used.get(key); if (!cellEl) { const built = makeColHeaderCell(c, 0, 0, hiddenBefore); @@ -411,7 +437,9 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle { }); } 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); @@ -423,12 +451,15 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle { container: HTMLElement, base: number, ): void { + const sheet = ctx.sheet(); const keep = new Set(); + 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; + const hiddenBefore = r - prev > 1 && rowHiddenGap(sheet, prev, r); let cellEl = pool.used.get(key); if (!cellEl) { const built = makeRowHeaderCell(r, 0, 0, hiddenBefore); @@ -447,12 +478,79 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle { }); } 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; @@ -490,6 +588,8 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle { rowHeadInnerScroll, rowGeo.offset(frozenRows), ); + renderRowOutline(); + renderColOutline(); renderRowOfCells(frozenRowsVisible, frozenColsVisible, pools.tl, paneTLInner, 0, 0); renderRowOfCells( @@ -528,6 +628,8 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle { 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"; diff --git a/A00_Common/spreadsheet/spreadsheet_headers.ts b/A00_Common/spreadsheet/spreadsheet_headers.ts index 3d8be157..50e4cc0b 100644 --- a/A00_Common/spreadsheet/spreadsheet_headers.ts +++ b/A00_Common/spreadsheet/spreadsheet_headers.ts @@ -105,7 +105,7 @@ export function buildColGeometry(book: Workbook, sheet: Sheet): LineGeometry { for (const [key, info] of Object.entries(sheet.열 ?? {})) { const c = colIndex(key); if (c < 0) continue; - if (info.숨김) overrides.set(c, 0); + if (info.숨김 || info.묶음?.접힘) overrides.set(c, 0); else if (info.폭 !== undefined) overrides.set(c, colCharsToPx(info.폭)); } return new SparseGeometry(base, overrides); @@ -117,12 +117,41 @@ export function buildRowGeometry(book: Workbook, sheet: Sheet): LineGeometry { 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); + 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( + records: Record | 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[] = []; diff --git a/A00_Common/spreadsheet/spreadsheet_types.ts b/A00_Common/spreadsheet/spreadsheet_types.ts index b86ae188..e533464c 100644 --- a/A00_Common/spreadsheet/spreadsheet_types.ts +++ b/A00_Common/spreadsheet/spreadsheet_types.ts @@ -128,11 +128,18 @@ export interface CellStyle { 형식?: string; } +/** 묶음(윤곽) — 층 1 부터(중첩 단계) · 접힘 이면 격자가 숨김처럼 그림(숨김 · 걸러짐과는 화면 표시가 다름 — 윤곽 +/- 단추). */ +export interface Outline { + 층: number; + 접힘?: boolean; +} + export interface ColumnInfo { /** 엑셀 글자 폭 단위(기본 글꼴 숫자 폭 기준) — 화면이 px 로 바꿈. */ 폭?: number; 숨김?: boolean; 서식?: number; + 묶음?: Outline; } export interface RowInfo { @@ -142,6 +149,7 @@ export interface RowInfo { 서식?: number; /** 자동 필터가 숨긴 행 — 사용자 `숨김` 과 따로(필터 풀면 보임) · 격자는 숨김처럼 그림. */ 걸러짐?: boolean; + 묶음?: Outline; } /** 자동 필터 — `범위` 첫 행 = 머리(단추) · `조건` = 열 글자 → 보일 값(보이는 글 · 빈 칸은 ""). 조건 없는 열은 다 보임. */ @@ -394,6 +402,8 @@ export type Command = | { 종류: "정렬"; 시트: string; 범위: CellRange; 차례: number[] } /** 자동 필터 켜기 · 조건 바꾸기 · 끄기(null) — `걸러짐` = 숨길 행(`filterCommand` 가 값으로 셈) */ | { 종류: "필터"; 시트: string; 필터: AutoFilter | null; 걸러짐: number[] } + /** 행 · 열 묶기(윤곽) · 접기 · 펴기 · 풀기(`묶음` null) — 격자 윤곽 +/- 단추가 씀 */ + | { 종류: "묶음설정"; 시트: string; 축: "행" | "열"; 번호: number[]; 묶음: Outline | null } /** 여러 명령 = 되돌리기 한 번 */ | { 종류: "묶음"; 명령: Command[] }; diff --git a/resources/tester/spreadsheet/spreadsheet_grid_test.ts b/resources/tester/spreadsheet/spreadsheet_grid_test.ts index d732dea1..44c80247 100644 --- a/resources/tester/spreadsheet/spreadsheet_grid_test.ts +++ b/resources/tester/spreadsheet/spreadsheet_grid_test.ts @@ -39,8 +39,18 @@ const sheet: Sheet = { }, comments: { H4: "메모 — ROUNDDOWN 끝수 확인" }, 병합: ["A1:H1", "B8:C9"], - 열: { A: { 폭: 14 }, D: { 숨김: true } }, - 행: { "1": { 높이: 22 }, "5": { 숨김: true } }, + 열: { + A: { 폭: 14 }, + D: { 숨김: true }, + E: { 묶음: { 층: 1 } }, + F: { 묶음: { 층: 1 } }, + }, + 행: { + "1": { 높이: 22 }, + "5": { 숨김: true }, + "11": { 묶음: { 층: 1 } }, + "12": { 묶음: { 층: 1 } }, + }, 틀고정: { 행: 3, 열: 1 }, 보기: { 눈금선: true }, }; @@ -129,6 +139,14 @@ const ctx: SpreadsheetContext = { ...sheet.행, [String(r0 + 1)]: { ...sheet.행?.[String(r0 + 1)], 높이: command.높이 ?? undefined }, }; + } else if (command.종류 === "묶음설정") { + const table = command.축 === "열" ? "열" : "행"; + const next = { ...sheet[table] } as Record; + for (const i of command.번호) { + const key = command.축 === "열" ? String.fromCharCode(65 + i) : String(i + 1); + next[key] = { ...next[key], 묶음: command.묶음 ?? undefined }; + } + (sheet as unknown as Record)[table] = next; } grid.render(); },