Files
Aislo/A00_Common/spreadsheet/spreadsheet_mouse.ts
T

321 lines
12 KiB
TypeScript

/* =============================================================================
* spreadsheet_mouse.ts (주인 D)
* 마우스 — 칸 · 범위 끌기 · Shift 늘림 · 행열 머리 · 모서리(전체) · 식 편집 중 참조 가리키기 ·
* 채우기 핸들 끌기(늘림 · 안으로 끌면 지움) · 두 번 누르기(칸 편집 · 핸들이면 옆 열 끝까지 아래로) ·
* 우클릭 자리(고름 밖이면 그 칸을 고름 — 메뉴는 E).
* 머리 끝(폭 · 높이 끌기)은 C 몫 — 여기서 건드리지 않음.
* ========================================================================== */
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);
void import("./spreadsheet_autofit").then((m) => {
for (let c = whole ? g.c0 : hit.c; c <= (whole ? g.c1 : hit.c); c++)
m.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);
// 손가락(기기대응 4-2) — 톡 = 칸 고름(브라우저 호환 마우스 그대로) · 고른 칸 · 행열 머리 · 채우기 손잡이에서
// 끌기 = 범위 · 채우기(마우스 끌기와 같은 길) · 안 고른 칸에서 끌기 = 화면 밀기 ·
// 두 번 톡 = 고침(손 동작 안에서 바로 열어 화면 자판이 뜸)
let touchDrag = false;
let lastTap = 0;
const asMouse = (t: Touch, type: string): MouseEvent =>
new MouseEvent(type, { clientX: t.clientX, clientY: t.clientY, button: 0, bubbles: true });
const onTouchStart = (e: TouchEvent): void => {
if (e.touches.length !== 1 || e.target === ed.ta) return;
const t = e.touches[0];
const hit = ctx.grid.hitTest(t.clientX, t.clientY);
const grab =
hit &&
(hit.kind === "fillHandle" ||
hit.kind === "rowHead" ||
hit.kind === "colHead" ||
(hit.kind === "cell" && inSelection(hit.r, hit.c)));
if (!grab) return;
e.preventDefault(); // 화면 밀기 대신 범위 · 호환 마우스 이벤트도 안 옴
touchDrag = true;
onDown(asMouse(t, "mousedown"));
};
const onTouchMove = (e: TouchEvent): void => {
if (!touchDrag) return;
e.preventDefault();
root.dispatchEvent(asMouse(e.touches[0], "mousemove")); // 올라가 window 끌기 듣개에 닿음
};
const onTouchEnd = (e: TouchEvent): void => {
if (touchDrag) {
touchDrag = false;
root.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, button: 0 })); // M02 칸 고르기도 받음
}
if (e.touches.length || e.changedTouches.length !== 1) return;
const t = e.changedTouches[0];
const hit = ctx.grid.hitTest(t.clientX, t.clientY);
const now = Date.now();
if (now - lastTap < 350 && hit?.kind === "cell" && !ed.editing() && !ctx.readOnly) {
lastTap = 0;
ed.begin();
return;
}
lastTap = now;
};
root.addEventListener("mousedown", onDown);
root.addEventListener("dblclick", onDbl);
root.addEventListener("mousemove", onHover);
root.addEventListener("mouseleave", onLeave);
root.addEventListener("touchstart", onTouchStart, { passive: false });
root.addEventListener("touchmove", onTouchMove, { passive: false });
root.addEventListener("touchend", onTouchEnd);
return () => {
root.removeEventListener("touchstart", onTouchStart);
root.removeEventListener("touchmove", onTouchMove);
root.removeEventListener("touchend", onTouchEnd);
root.removeEventListener("mousemove", onHover);
root.removeEventListener("mouseleave", onLeave);
stopDrag?.();
preview.remove();
root.removeEventListener("mousedown", onDown);
root.removeEventListener("dblclick", onDbl);
};
}