Files
Aislo/A00_Common/spreadsheet/spreadsheet_mouse.ts
T
eomsangdonandClaude Opus 5.5 61d9c0de58 feat(spreadsheet): D 잇기 — 데이터 메뉴(조건부 서식 · 유효성 · 이름 · 눈금선) · 유효성 검사 · 이름 상자 · 여러 범위
- spreadsheet_data_menu.ts(새) — [↑] [↓] [필터] [데이터 ▾] · 패널 창(sub4 조건부 서식 · 유효성 · 이름 관리) · 눈금선 · 유효성 목록 ▼ · Alt+↓
- spreadsheet_editor.ts — 확정 때 유효성 검사(막음 = 편집 유지 · 경고 = 알림) · 주소 상자에 이름 = 그 범위로 · 새 이름 = 지금 고름에 정의
- spreadsheet_mouse.ts · spreadsheet_selection.ts — Ctrl+누르기 여러 범위 · Shift 는 마지막 범위
- spreadsheet_keys.ts · spreadsheet.ts — 유효성에 막히면 이동 · 시트 바꿈 멈춤
- spreadsheet_extras.ts — 정렬 · 필터를 데이터 메뉴로 옮김

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

269 lines
9.7 KiB
TypeScript

/* =============================================================================
* spreadsheet_mouse.ts (주인 D)
* 마우스 — 칸 · 범위 끌기 · Shift 늘림 · 행열 머리 · 모서리(전체) · 식 편집 중 참조 가리키기 ·
* 채우기 핸들 끌기(늘림 · 안으로 끌면 지움) · 두 번 누르기(칸 편집 · 핸들이면 옆 열 끝까지 아래로) ·
* 우클릭 자리(고름 밖이면 그 칸을 고름 — 메뉴는 E).
* 머리 끝(폭 · 높이 끌기)은 C 몫 — 여기서 건드리지 않음.
* ========================================================================== */
import { autofitColumn } from "./spreadsheet_autofit";
import { fillCells } from "./spreadsheet_fill";
import {
hasValue,
inRange,
LAST_C,
LAST_R,
selectAll,
selectCell,
selectCols,
selectRows,
selectSpan,
} from "./spreadsheet_selection";
import { boxStyle, slotOf, type Editor } from "./spreadsheet_editor";
import type { CellAddress, CellRange } from "./spreadsheet_types";
import type { HitResult, Selection, SpreadsheetContext } from "./spreadsheet_view_types";
/** 채우기 끌기 목표 — 늘림(fill) · 안으로 줄임(clear = 지울 부분) */
export function fillTarget(
src: CellRange,
p: CellAddress,
): { target: CellRange; clear: CellRange | null } {
if (inRange(src, p.r, p.c)) {
const up = src.r1 - p.r;
const left = src.c1 - p.c;
if (up === 0 && left === 0) return { target: src, clear: null };
if (up >= left) return { target: { ...src, r1: p.r }, clear: { ...src, r0: p.r + 1 } };
return { target: { ...src, c1: p.c }, clear: { ...src, c0: p.c + 1 } };
}
const down = p.r - src.r1;
const upOut = src.r0 - p.r;
const right = p.c - src.c1;
const leftOut = src.c0 - p.c;
const best = Math.max(down, upOut, right, leftOut);
if (best === down) return { target: { ...src, r1: p.r }, clear: null };
if (best === upOut) return { target: { ...src, r0: p.r }, clear: null };
if (best === right) return { target: { ...src, c1: p.c }, clear: null };
return { target: { ...src, c0: p.c }, clear: null };
}
/** 2단계 잇기 자리 — 부른 쪽(spreadsheet.ts)이 채움 */
export interface MouseHooks {
/** 칸 끌어 고르기가 끝남(서식 붓 바르기) */
selected?(): void;
/** 누르지 않고 움직일 때 가리킨 것(메모 풍선) — 격자 밖이면 null */
hover?(hit: HitResult | null): void;
}
export function attachMouse(
ctx: SpreadsheetContext,
ed: Editor,
hooks: MouseHooks = {},
): () => void {
const root = ctx.grid.root;
const preview = document.createElement("div");
preview.className = "ss-fill-preview";
let stopDrag: (() => void) | null = null;
/** 끌기 — 누른 채 움직이면 `onMove(칸)` · 떼면 `onUp` · 격자 밖이면 가장자리 칸(스크롤 따라감) */
function drag(onMove: (p: HitResult) => void, onUp?: () => void): void {
const move = (e: MouseEvent): void => {
const box = root.getBoundingClientRect();
const x = Math.min(box.right - 2, Math.max(box.left + 2, e.clientX));
const y = Math.min(box.bottom - 2, Math.max(box.top + 2, e.clientY));
const hit = ctx.grid.hitTest(x, y);
if (!hit || hit.r < 0 || hit.c < 0) return;
onMove(hit);
if (x !== e.clientX || y !== e.clientY) ctx.grid.reveal(hit.r, hit.c);
};
const up = (): void => {
stopDrag?.();
onUp?.();
};
stopDrag = () => {
window.removeEventListener("mousemove", move);
window.removeEventListener("mouseup", up);
stopDrag = null;
};
window.addEventListener("mousemove", move);
window.addEventListener("mouseup", up);
}
const inSelection = (r: number, c: number): boolean =>
ctx.selection.범위.some((g) => inRange(g, r, c));
function showPreview(g: CellRange | null): void {
if (!g) return preview.remove();
const a = slotOf(ctx, g.r0, g.c0);
const b = slotOf(ctx, Math.min(g.r1, LAST_R), Math.min(g.c1, LAST_C));
if (preview.parentElement !== a.layer) a.layer.append(preview);
boxStyle(preview, {
x: a.box.x,
y: a.box.y,
w: b.box.x + b.box.w - a.box.x,
h: b.box.y + b.box.h - a.box.y,
});
}
function fillDrag(): void {
const sheet = ctx.sheet();
const src = ctx.selection.범위[ctx.selection.범위.length - 1];
let result = { target: src, clear: null as CellRange | null };
drag(
(p) => {
result = fillTarget(src, p);
showPreview(result.clear ?? result.target);
},
() => {
showPreview(null);
applyFill(sheet.id, src, result.target, result.clear);
},
);
}
function applyFill(
sheet: string,
src: CellRange,
target: CellRange,
clear: CellRange | null,
): void {
const at = ctx.selection.활성;
const sel = (g: CellRange): Selection => ({
시트: sheet,
범위: [g],
활성: at,
기준: { ...at },
});
if (clear) {
ctx.dispatch({ 종류: "내용지움", 시트: sheet, 범위: [clear] });
ctx.select(sel(target));
return;
}
if (
target.r0 === src.r0 &&
target.r1 === src.r1 &&
target.c0 === src.c0 &&
target.c1 === src.c1
)
return;
ctx.dispatch({ 종류: "칸", 시트: sheet, 칸: fillCells(ctx.book, sheet, src, target) });
ctx.select(sel(target));
}
/** 핸들 두 번 누르기 — 왼쪽(없으면 오른쪽) 열 값이 이어진 끝까지 아래로 */
function fillDown(): void {
const sheet = ctx.sheet();
const src = ctx.selection.범위[0];
const side = [src.c0 - 1, src.c1 + 1].find(
(c) => c >= 0 && c <= LAST_C && hasValue(sheet, src.r1 + 1, c),
);
if (side === undefined) return;
let r = src.r1 + 1;
while (r < LAST_R && hasValue(sheet, r + 1, side)) r++;
applyFill(sheet.id, src, { ...src, r1: r }, null);
}
function onDown(e: MouseEvent): void {
if (e.target === ed.ta) return; // 편집 칸 안 — 글자 자리 옮김
const hit = ctx.grid.hitTest(e.clientX, e.clientY);
if (!hit || hit.kind === "colEdge" || hit.kind === "rowEdge") return;
const sheet = ctx.sheet();
if (e.button === 2) {
if (ed.editing() || (hit.kind === "cell" && inSelection(hit.r, hit.c))) return;
if (hit.kind === "cell") ctx.select(selectCell(sheet, hit.r, hit.c));
else if (hit.kind === "rowHead" && !inSelection(hit.r, ctx.selection.활성.c))
ctx.select(selectRows(sheet, hit.r, hit.r));
else if (hit.kind === "colHead" && !inSelection(ctx.selection.활성.r, hit.c))
ctx.select(selectCols(sheet, hit.c, hit.c));
return;
}
if (e.button !== 0) return;
e.preventDefault(); // 초점은 편집기 · 수식 입력줄에 둠
if (ed.editing()) {
if (hit.kind === "cell" && ed.pointable()) {
const at = { r: hit.r, c: hit.c };
ed.point(selectCell(sheet, hit.r, hit.c));
drag((p) => ed.point(selectSpan(sheet, at, { r: p.r, c: p.c })));
return;
}
ed.commit();
if (ed.editing()) return; // 유효성에 막힘
}
ed.focus();
if (ctx.readOnly && hit.kind === "fillHandle") hit.kind = "cell";
switch (hit.kind) {
case "fillHandle":
return fillDrag();
case "corner":
return ctx.select(selectAll(sheet));
case "rowHead": {
const a = e.shiftKey ? ctx.selection.기준.r : hit.r;
ctx.select(selectRows(sheet, a, hit.r, a));
return drag((p) => ctx.select(selectRows(sheet, a, p.r, a)));
}
case "colHead": {
const a = e.shiftKey ? ctx.selection.기준.c : hit.c;
ctx.select(selectCols(sheet, a, hit.c, a));
return drag((p) => ctx.select(selectCols(sheet, a, p.c, a)));
}
case "cell": {
// Ctrl = 범위 더함(여러 범위) · Shift = 마지막 범위를 늘림
const keep =
e.ctrlKey || e.metaKey
? ctx.selection.범위
: e.shiftKey
? ctx.selection.범위.slice(0, -1)
: [];
const one = e.shiftKey
? selectSpan(sheet, ctx.selection.기준, hit)
: selectCell(sheet, hit.r, hit.c);
const first = { ...one, 범위: [...keep, ...one.범위] };
ctx.select(first);
const anchor = first.기준;
let last = `${hit.r},${hit.c}`;
return drag(
(p) => {
if (`${p.r},${p.c}` === last) return;
last = `${p.r},${p.c}`;
const span = selectSpan(sheet, anchor, p);
ctx.select({ ...span, 범위: [...keep, ...span.범위] });
},
() => hooks.selected?.(),
);
}
}
}
function onDbl(e: MouseEvent): void {
if (e.target === ed.ta || ctx.readOnly) return;
const hit = ctx.grid.hitTest(e.clientX, e.clientY);
if (!hit) return;
if (hit.kind === "fillHandle") return fillDown();
if (hit.kind === "colEdge") {
// 고른 열 전체의 경계면 고른 열 모두(엑셀)
const g = ctx.selection.범위[0];
const whole = g.r0 === 0 && g.r1 === LAST_R && inRange(g, 0, hit.c);
for (let c = whole ? g.c0 : hit.c; c <= (whole ? g.c1 : hit.c); c++) autofitColumn(ctx, c);
return;
}
if (hit.kind === "cell" && !ed.editing()) ed.begin();
}
const onHover = (e: MouseEvent): void => {
if (!stopDrag && !e.buttons) hooks.hover?.(ctx.grid.hitTest(e.clientX, e.clientY));
};
const onLeave = (): void => hooks.hover?.(null);
root.addEventListener("mousedown", onDown);
root.addEventListener("dblclick", onDbl);
root.addEventListener("mousemove", onHover);
root.addEventListener("mouseleave", onLeave);
return () => {
root.removeEventListener("mousemove", onHover);
root.removeEventListener("mouseleave", onLeave);
stopDrag?.();
preview.remove();
root.removeEventListener("mousedown", onDown);
root.removeEventListener("dblclick", onDbl);
};
}