- spreadsheet.ts — 가짜 부품 자리(parts) 걷고 A · B · C · E 직접 잇기 · 2단계는 spreadsheet_extras.ts 로 - spreadsheet_extras.ts(새) — 메모 풍선(마우스 올림) · Shift+F2 메모 편집 · 서식 붓 단추 · Ctrl+Shift+V 값만 · 서식만 · 수식만 · 도구 모음 뒤 초점 되돌림 - spreadsheet_find_panel.ts(새) — Ctrl+F · Shift+F5 찾기 · Ctrl+H 바꾸기 패널 - spreadsheet_editor.ts — C editorSlot(격자 뿌리 기준) → 층 안 좌표 맞춤(slotOf) · 함수 자동 완성(Tab) · 인자 도움말 - spreadsheet_keys.ts · spreadsheet_mouse.ts — 2단계 잇기 자리 · 열 경계 두 번 누르면 폭 자동 맞춤 - 시험 — 함수 도움말 자리 판정 추가(8 묶음) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L6KXAabDTenEU7hrDQmCKY
259 lines
9.3 KiB
TypeScript
259 lines
9.3 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.범위[0];
|
|
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();
|
|
}
|
|
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": {
|
|
const first = e.shiftKey
|
|
? selectSpan(sheet, ctx.selection.기준, hit)
|
|
: selectCell(sheet, hit.r, hit.c);
|
|
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}`;
|
|
ctx.select(selectSpan(sheet, anchor, p));
|
|
},
|
|
() => 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);
|
|
};
|
|
}
|