- 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
289 lines
10 KiB
TypeScript
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;
|
|
}
|