/* ============================================================================= * spreadsheet_selection.ts (주인 D) * 고름 모델 — 한 칸 · 범위 · 행열 전체 · 전체 · 방향키(Shift 늘림 · Ctrl 끝으로) · 병합으로 늘림 · * 고름 안 Enter/Tab 돌기. DOM 모름(시험이 그대로 부름). 엑셀 동작이 기준. * ========================================================================== */ import { colName, parseA1, parseRange, toA1 } from "./spreadsheet_address"; import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types"; import type { CellAddress, CellRange, Sheet } from "./spreadsheet_types"; import type { Selection } from "./spreadsheet_view_types"; export const LAST_R = MAX_ROWS - 1; export const LAST_C = MAX_COLS - 1; export function merges(sheet: Sheet): CellRange[] { const out: CellRange[] = []; for (const t of sheet.병합 ?? []) { const m = parseRange(t); if (m) out.push(m); } return out; } const overlaps = (a: CellRange, b: CellRange): boolean => a.r0 <= b.r1 && b.r0 <= a.r1 && a.c0 <= b.c1 && b.c0 <= a.c1; export const inRange = (g: CellRange, r: number, c: number): boolean => r >= g.r0 && r <= g.r1 && c >= g.c0 && c <= g.c1; /** 병합 일부를 걸친 범위 → 병합 전체를 품게 늘림(늘린 뒤 새로 걸친 것도) */ export function expand(range: CellRange, list: CellRange[]): CellRange { const g = { ...range }; for (let grew = true; grew;) { grew = false; for (const m of list) { if (!overlaps(g, m)) continue; if (m.r0 < g.r0 || m.r1 > g.r1 || m.c0 < g.c0 || m.c1 > g.c1) { g.r0 = Math.min(g.r0, m.r0); g.r1 = Math.max(g.r1, m.r1); g.c0 = Math.min(g.c0, m.c0); g.c1 = Math.max(g.c1, m.c1); grew = true; } } } return g; } /** 칸이 든 병합(없으면 칸 하나) */ export function mergeAt(sheet: Sheet, r: number, c: number): CellRange { return merges(sheet).find((m) => inRange(m, r, c)) ?? { r0: r, c0: c, r1: r, c1: c }; } /** 숨긴 행 · 필터로 걸러진 행 · 접힌 묶음(방향키 · Enter 가 건너뜀 — 엑셀) */ export function rowHidden(sheet: Sheet, r: number): boolean { const row = sheet.행?.[String(r + 1)]; return !!(row?.숨김 || row?.걸러짐 || row?.묶음?.접힘); } export function colHidden(sheet: Sheet, c: number): boolean { const cols = sheet.열; if (!cols) return false; const col = cols[colName(c)]; return !!(col?.숨김 || col?.묶음?.접힘); } export function hasValue(sheet: Sheet, r: number, c: number): boolean { const cell = sheet.칸[toA1(r, c)]; return !!cell && (cell.식 !== undefined || (cell.값 !== undefined && cell.값 !== "")); } export function span(a: CellAddress, b: CellAddress): CellRange { return { r0: Math.min(a.r, b.r), c0: Math.min(a.c, b.c), r1: Math.max(a.r, b.r), c1: Math.max(a.c, b.c), }; } /** 칸 하나 고름 — 병합 안이면 병합 전체 · 활성은 병합 왼위 */ export function selectCell(sheet: Sheet, r: number, c: number): Selection { const m = mergeAt(sheet, r, c); const at = { r: m.r0, c: m.c0 }; return { 시트: sheet.id, 범위: [m], 활성: at, 기준: { ...at } }; } /** 닻(기준)에서 `to` 까지 — 활성은 닻 그대로(엑셀 Shift) */ export function selectSpan(sheet: Sheet, anchor: CellAddress, to: CellAddress): Selection { const g = expand(span(anchor, to), merges(sheet)); return { 시트: sheet.id, 범위: [g], 활성: { ...anchor }, 기준: { ...anchor } }; } /** 행 전체(r0~r1) · 열 전체(c0~c1) · 전체 — 활성은 첫 보이는 칸 · 걸친 병합만큼 늘림(엑셀) */ export function selectRows(sheet: Sheet, a: number, b: number, active?: number): Selection { const r0 = Math.min(a, b); const at = { r: active ?? r0, c: firstVisible(sheet, "c") }; return { 시트: sheet.id, 범위: [expand({ r0, r1: Math.max(a, b), c0: 0, c1: LAST_C }, merges(sheet))], 활성: at, 기준: { ...at }, }; } export function selectCols(sheet: Sheet, a: number, b: number, active?: number): Selection { const c0 = Math.min(a, b); const at = { r: firstVisible(sheet, "r"), c: active ?? c0 }; return { 시트: sheet.id, 범위: [expand({ r0: 0, r1: LAST_R, c0, c1: Math.max(a, b) }, merges(sheet))], 활성: at, 기준: { ...at }, }; } export function selectAll(sheet: Sheet, active?: CellAddress): Selection { const at = active ?? { r: firstVisible(sheet, "r"), c: firstVisible(sheet, "c") }; return { 시트: sheet.id, 범위: [{ r0: 0, c0: 0, r1: LAST_R, c1: LAST_C }], 활성: at, 기준: { ...at }, }; } function firstVisible(sheet: Sheet, axis: "r" | "c"): number { let i = 0; while (i < 200 && (axis === "r" ? rowHidden(sheet, i) : colHidden(sheet, i))) i++; return i; } /** 한 칸 옮김 — 숨김 건너뜀 · 끝에서 멈춤 */ function step(sheet: Sheet, at: CellAddress, dr: number, dc: number): CellAddress { let { r, c } = at; do { r += dr; c += dc; } while ( r >= 0 && r <= LAST_R && c >= 0 && c <= LAST_C && ((dr && rowHidden(sheet, r)) || (dc && colHidden(sheet, c))) ); if (r < 0 || r > LAST_R || c < 0 || c > LAST_C) return at; return { r, c }; } /** 한 줄(행 또는 열)에서 값 든 자리 — 오름차순 */ function occupied(sheet: Sheet, at: CellAddress, vertical: boolean): number[] { const out: number[] = []; for (const key of Object.keys(sheet.칸)) { const a = parseA1(key); if (!a || !hasValue(sheet, a.r, a.c)) continue; if (vertical ? a.c === at.c : a.r === at.r) out.push(vertical ? a.r : a.c); } return out.sort((a, b) => a - b); } /** Ctrl+방향 — 값 사이를 뛰어 끝으로(엑셀: 값 칸 뭉치의 끝 · 빈 칸이면 다음 값 · 없으면 시트 끝) */ function jump(sheet: Sheet, at: CellAddress, dr: number, dc: number): CellAddress { const vertical = dr !== 0; const d = vertical ? dr : dc; const pos = vertical ? at.r : at.c; const edge = d > 0 ? (vertical ? LAST_R : LAST_C) : 0; const set = new Set(occupied(sheet, at, vertical)); const has = (i: number): boolean => set.has(i); const put = (i: number): CellAddress => (vertical ? { r: i, c: at.c } : { r: at.r, c: i }); if (pos === edge) return at; if (has(pos) && has(pos + d)) { let i = pos; while (i !== edge && has(i + d)) i += d; return put(i); } const sorted = [...set].sort((a, b) => a - b); const next = d > 0 ? sorted.find((i) => i > pos) : sorted.reverse().find((i) => i < pos); return put(next ?? edge); } export interface MoveOptions { /** Shift — 닻은 두고 움직이는 끝만 */ extend?: boolean; /** Ctrl — 값 끝으로 */ jump?: boolean; } /** 방향키 · Home 류 공통 — 새 고름 */ export function move( sheet: Sheet, sel: Selection, dr: number, dc: number, opts: MoveOptions = {}, ): Selection { const list = merges(sheet); if (!opts.extend) { const from = sel.활성; const m = mergeAt(sheet, from.r, from.c); // 병합에서 나갈 때는 병합 끝에서 한 칸 const edge = { r: dr > 0 ? m.r1 : m.r0, c: dc > 0 ? m.c1 : m.c0 }; const to = opts.jump ? jump(sheet, edge, dr, dc) : step(sheet, edge, dr, dc); if (to.r === edge.r && to.c === edge.c) return selectCell(sheet, from.r, from.c); return selectCell(sheet, to.r, to.c); } const g = sel.범위[sel.범위.length - 1] ?? span(sel.활성, sel.활성); // 여러 범위면 마지막(활성) 범위 const anchor = sel.기준; // 움직이는 끝 = 닻에서 먼 쪽 const far = { r: Math.abs(g.r1 - anchor.r) >= Math.abs(anchor.r - g.r0) ? g.r1 : g.r0, c: Math.abs(g.c1 - anchor.c) >= Math.abs(anchor.c - g.c0) ? g.c1 : g.c0, }; let to = opts.jump ? jump(sheet, far, dr, dc) : step(sheet, far, dr, dc); // 줄이는 쪽이면 병합 너머까지 한 번에 for (let guard = 0; guard < 50; guard++) { const next = expand(span(anchor, to), list); if (!sameRange(next, g) || to === far) break; to = step(sheet, to, dr, dc); } return { 시트: sheet.id, 범위: [...sel.범위.slice(0, -1), expand(span(anchor, to), list)], 활성: { ...sel.활성 }, 기준: { ...anchor }, }; } export const sameRange = (a: CellRange, b: CellRange): boolean => a.r0 === b.r0 && a.c0 === b.c0 && a.r1 === b.r1 && a.c1 === b.c1; /** 쓰인 칸 끝(Ctrl+End) */ export function usedEnd(sheet: Sheet): CellAddress { let r = 0; let c = 0; for (const key of Object.keys(sheet.칸)) { const a = parseA1(key); if (!a) continue; r = Math.max(r, a.r); c = Math.max(c, a.c); } return { r, c }; } /** 고름 안 Enter/Tab 돌기 — 범위가 한 칸(또는 병합 하나)이면 null(밖으로 옮김) */ export function cycle(sheet: Sheet, sel: Selection, dr: number, dc: number): Selection | null { const g = sel.범위[0]; if (!g) return null; const list = merges(sheet); if (list.some((m) => sameRange(m, g)) || (g.r0 === g.r1 && g.c0 === g.c1)) return null; // 행 전체 · 열 전체는 쓰인 칸 끝까지만 돎 const end = usedEnd(sheet); const box = { ...g, r1: g.r1 === LAST_R ? Math.max(end.r, g.r0) : g.r1, c1: g.c1 === LAST_C ? Math.max(end.c, g.c0) : g.c1, }; let { r, c } = sel.활성; const cur = mergeAt(sheet, r, c); const rows = box.r1 - box.r0 + 1; const cols = box.c1 - box.c0 + 1; for (let n = 0; n < rows * cols; n++) { if (dr) { r += dr; if (r > box.r1) ((r = box.r0), (c = c + 1 > box.c1 ? box.c0 : c + 1)); if (r < box.r0) ((r = box.r1), (c = c - 1 < box.c0 ? box.c1 : c - 1)); } else { c += dc; if (c > box.c1) ((c = box.c0), (r = r + 1 > box.r1 ? box.r0 : r + 1)); if (c < box.c0) ((c = box.c1), (r = r - 1 < box.r0 ? box.r1 : r - 1)); } if (rowHidden(sheet, r) || colHidden(sheet, c)) continue; const m = mergeAt(sheet, r, c); if (m.r0 !== r || m.c0 !== c || sameRange(m, cur)) continue; return { ...sel, 범위: sel.범위.map((x) => ({ ...x })), 활성: { r, c } }; } return null; } /** 고름 안 칸 전부(행열 전체는 쓰인 끝까지) — Ctrl+Enter · 지우기 대상 */ export function cellsOf(sheet: Sheet, sel: Selection): CellAddress[] { const end = usedEnd(sheet); const out: CellAddress[] = []; for (const g of sel.범위) { const r1 = g.r1 === LAST_R ? Math.max(end.r, g.r0) : g.r1; const c1 = g.c1 === LAST_C ? Math.max(end.c, g.c0) : g.c1; for (let r = g.r0; r <= r1; r++) for (let c = g.c0; c <= c1; c++) out.push({ r, c }); } return out; }