Files
Aislo/A00_Common/spreadsheet/spreadsheet_selection.ts
T
eomsangdonandClaude Opus 5.5 500424d7ae feat(spreadsheet): D 잇기 — 정렬 · 자동 필터(sub3)
- spreadsheet_extras.ts — 수식 입력줄 앞 데이터 단추(↑ ↓ 정렬… 필터) · attachFilter 부품 붙임 · Alt+↓ 머리 칸 값 목록
- spreadsheet_keys.ts — Ctrl+Shift+L 필터 · Alt+↓ 자리
- spreadsheet_selection.ts — 걸러진 행도 방향키 · Enter 가 건너뜀
- spreadsheet.ts — recalc 뒤 부품 refresh

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L6KXAabDTenEU7hrDQmCKY
2026-09-27 21:05:39 +09:00

289 lines
10 KiB
TypeScript

/* =============================================================================
* 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?.걸러짐);
}
export function colHidden(sheet: Sheet, c: number): boolean {
const cols = sheet.열;
if (!cols) return false;
return !!cols[colName(c)]?.숨김;
}
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.범위[0] ?? 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,
범위: [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;
}