/* ============================================================================= * 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); }; }