/* ============================================================================= * spreadsheet_keys.ts (주인 D) * 엑셀 단축키 지도 — 숨은 textarea · 수식 입력줄 keydown 하나로 받음. * 편집 중: Enter/Tab 확정 이동 · Alt+Enter 줄바꿈 · Ctrl+Enter 고름 전부 · Esc · F2 방식 바꿈 · * enter 방식 방향키 = 확정 이동(식 참조 자리면 가리키기). * 고름 중: 방향(Shift 늘림 · Ctrl 끝) · Home · Ctrl+Home/End · PgUp/PgDn · Enter/Tab(고름 안 돌기) · * F2 · Delete · Backspace · Ctrl+A · Shift/Ctrl+Space · Ctrl+Z/Y · Ctrl+D/R · 그 밖은 hooks.more. * 식 편집 중 F4 = 참조 $ 돌리기(A1 → $A$1 → A$1 → $A1). * 글자 키는 막지 않음 — textarea 의 input · compositionstart 가 편집을 엶(한글 첫 글자). * 복사 · 붙여넣기(Ctrl+C/X/V)는 막지 않음 — E 클립보드가 copy · paste 사건으로 받음. * ========================================================================== */ import { cycleAbs } from "./spreadsheet_editor"; import { fillCells } from "./spreadsheet_fill"; import { cycle, LAST_R, move, selectAll, selectCell, selectCols, selectRows, selectSpan, usedEnd, } from "./spreadsheet_selection"; import type { Editor } from "./spreadsheet_editor"; import type { Selection, SpreadsheetContext } from "./spreadsheet_view_types"; const ARROWS: Record = { ArrowUp: [-1, 0], ArrowDown: [1, 0], ArrowLeft: [0, -1], ArrowRight: [0, 1], }; /** 2단계 잇기 자리 — 부른 쪽(spreadsheet.ts)이 채움 */ export interface KeyHooks { /** Ctrl+F · Shift+F5 찾기 · Ctrl+H 바꾸기 */ find?(replace: boolean): void; /** Ctrl+Shift+V — 곧 올 paste 사건을 골라 붙여넣기로 */ pasteSpecial?(): void; /** Shift+F2 메모 편집 */ comment?(): void; /** 고름 중 Esc(서식 붓 끄기 등) */ escape?(): void; /** Ctrl+1 셀 서식 창 */ formatDialog?(): void; /** Alt+= 자동 합계 */ autosum?(): void; /** Ctrl+Shift+L 필터 켜기 · 끄기 */ filter?(): void; /** Alt+↓ — 필터 머리 칸이면 값 목록을 열고 true */ dropdown?(): boolean; /** 고름 중 그 밖 단축키(`spreadsheet_actions.ts`) — 받았으면 true */ more?(e: KeyboardEvent): boolean; } export function attachKeys(ctx: SpreadsheetContext, ed: Editor, hooks: KeyHooks = {}): () => void { const go = (sel: Selection): void => ctx.select(sel, true); /** 확정 뒤 한 칸 — 고름이 여러 칸이면 그 안에서 돎 */ function next(dr: number, dc: number): void { const sheet = ctx.sheet(); go(cycle(sheet, ctx.selection, dr, dc) ?? move(sheet, ctx.selection, dr, dc)); } function editingKey(e: KeyboardEvent, inBar: boolean): boolean { const ctrl = e.ctrlKey || e.metaKey; switch (e.key) { case "Enter": { const node = e.target as HTMLTextAreaElement; if (e.altKey) { node.setRangeText("\n", node.selectionStart, node.selectionEnd, "end"); node.dispatchEvent(new Event("input")); return true; } if (ctrl) return (ed.commitAll(), true); ed.commit(); if (ed.editing()) return true; // 유효성에 막힘 next(e.shiftKey ? -1 : 1, 0); return true; } case "Tab": if (!e.shiftKey && ed.accept()) return true; // 함수 자동 완성 ed.commit(); if (ed.editing()) return true; next(0, e.shiftKey ? -1 : 1); return true; case "Escape": ed.cancel(); return true; case "F2": ed.toggleMode(); return true; case "F4": { const node = e.target as HTMLTextAreaElement; const got = cycleAbs(node.value, node.selectionStart); if (!got) return false; node.value = got.text; node.setSelectionRange(got.caret, got.caret); node.dispatchEvent(new Event("input")); return true; } } const arrow = ARROWS[e.key]; if (arrow && !arrow[1] && !e.shiftKey && ed.suggestMove(arrow[0])) return true; if (!arrow || inBar || ed.mode() === "edit") return false; if (ed.pointable()) { ed.pointMove(arrow[0], arrow[1], e.shiftKey, ctrl); return true; } if (e.shiftKey || ctrl) return false; // 글자 고르기 · 낱말 건너기는 textarea 에 맡김 ed.commit(); if (!ed.editing()) go(move(ctx.sheet(), ctx.selection, arrow[0], arrow[1])); return true; } function navKey(e: KeyboardEvent): boolean { const ctrl = e.ctrlKey || e.metaKey; const sheet = ctx.sheet(); const sel = ctx.selection; const arrow = ARROWS[e.key]; if (e.altKey && e.key === "ArrowDown" && hooks.dropdown?.()) return true; if (hooks.more?.(e)) return true; if (e.altKey && e.key === "=" && hooks.autosum && !ctx.readOnly) return (hooks.autosum(), true); if (arrow && !e.altKey) { go(move(sheet, sel, arrow[0], arrow[1], { extend: e.shiftKey, jump: ctrl })); return true; } const k = e.key.length === 1 ? e.key.toLowerCase() : e.key; if (ctrl && !e.altKey) { switch (k) { case "z": e.shiftKey ? ctx.redo() : ctx.undo(); return true; case "y": ctx.redo(); return true; case "a": go(selectAll(sheet, sel.활성)); return true; case "Home": go(e.shiftKey ? selectSpan(sheet, sel.기준, { r: 0, c: 0 }) : selectCell(sheet, 0, 0)); return true; case "End": { const end = usedEnd(sheet); go(e.shiftKey ? selectSpan(sheet, sel.기준, end) : selectCell(sheet, end.r, end.c)); return true; } case " ": { const g = sel.범위[0]; go(selectCols(sheet, g.c0, g.c1, sel.활성.c)); return true; } case "d": case "r": fill(k === "d"); return true; case "f": case "h": if (!hooks.find) return false; hooks.find(k === "h"); return true; case "1": if (!hooks.formatDialog) return false; hooks.formatDialog(); return true; case "l": if (!e.shiftKey || !hooks.filter) return false; hooks.filter(); return true; case "v": if (e.shiftKey) hooks.pasteSpecial?.(); return false; // paste 사건은 그대로 — E 클립보드 · 골라 붙여넣기가 받음 } return false; } switch (e.key) { case "Enter": next(e.shiftKey ? -1 : 1, 0); return true; case "Tab": next(0, e.shiftKey ? -1 : 1); return true; case "F5": // Shift+F5 = 찾기(엑셀 · Ctrl+F 를 브라우저가 가로챌 때) if (!e.shiftKey || !hooks.find) return false; hooks.find(false); return true; case "F2": if (e.shiftKey) hooks.comment?.(); else ed.begin(); return true; case "Escape": hooks.escape?.(); return !!hooks.escape; case "Delete": if (!ctx.readOnly) ctx.dispatch({ 종류: "내용지움", 시트: sheet.id, 범위: sel.범위 }); return true; case "Backspace": ed.begin(""); return true; case "Home": go( e.shiftKey ? selectSpan(sheet, sel.기준, { r: sel.기준.r, c: 0 }) : selectCell(sheet, sel.활성.r, 0), ); return true; case "PageDown": case "PageUp": { const v = ctx.grid.visibleRange(); const n = Math.max(1, v.r1 - v.r0) * (e.key === "PageDown" ? 1 : -1); const r = Math.min(LAST_R, Math.max(0, sel.활성.r + n)); go(selectCell(sheet, r, sel.활성.c)); return true; } case " ": if (!e.shiftKey) return false; go(selectRows(sheet, sel.범위[0].r0, sel.범위[0].r1, sel.활성.r)); return true; } return false; } /** Ctrl+D 아래로 · Ctrl+R 오른쪽으로 — 고름 첫 행(열)을 나머지에 */ function fill(down: boolean): void { if (ctx.readOnly) return; const sheet = ctx.sheet(); const g = ctx.selection.범위[0]; const src = down ? { ...g, r1: g.r0 } : { ...g, c1: g.c0 }; if ((down ? g.r1 : g.c1) === (down ? src.r1 : src.c1)) return; ctx.dispatch({ 종류: "칸", 시트: sheet.id, 칸: fillCells(ctx.book, sheet.id, src, g) }); } function onKey(e: KeyboardEvent): void { if (e.isComposing || e.keyCode === 229) return; // 한글 조합 중 — 브라우저 몫 const inBar = e.target === ed.formula; const done = ed.editing() ? editingKey(e, inBar) : !inBar && navKey(e); if (done) { e.preventDefault(); e.stopPropagation(); } } ed.ta.addEventListener("keydown", onKey); ed.formula.addEventListener("keydown", onKey); return () => { ed.ta.removeEventListener("keydown", onKey); ed.formula.removeEventListener("keydown", onKey); }; }