Files
Aislo/A00_Common/spreadsheet/spreadsheet_keys.ts
T
eomsangdonandClaude Opus 5.5 a2011c8035 feat(spreadsheet): D 입력 — 고름 · 칸 편집기 · 한글 조합 · 수식 입력줄 · 참조 가리키기 · 단축키 · 채우기 끌기 · 잇기
- spreadsheet.ts — createSpreadsheet 잇기(문맥 · 명령 → 되돌림 → 엔진 → 격자 · onChange · onSelect · 시트마다 고름 기억) · parts 한 곳(시험 틀이 빈 몸 부품을 바꿔 끼움)
- spreadsheet_selection.ts — 병합으로 늘림 · Shift/Ctrl 방향 · 숨김 건너뜀 · 행열 머리 · 고름 안 Enter/Tab 돌기
- spreadsheet_editor.ts — 숨은 textarea 늘 초점 · compositionstart/input 로 편집 시작 · enter/edit 방식 · 수식 입력줄 · 주소 상자 · 참조 넣기 · 색 테두리 · 입력 글 규칙(' · % · 천 단위)
- spreadsheet_keys.ts · spreadsheet_mouse.ts — 엑셀 단축키 · 끌기 고르기 · 채우기 핸들(늘림 · 줄여 지움 · 두 번 누르면 아래로) · 우클릭 자리
- 시험 resources/tester/spreadsheet — 규칙 7 묶음 · 가짜 부품 번들 시험 틀 html(ORCA)

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

188 lines
6.3 KiB
TypeScript

/* =============================================================================
* 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.
* 글자 키는 막지 않음 — textarea 의 input · compositionstart 가 편집을 엶(한글 첫 글자).
* 복사 · 붙여넣기(Ctrl+C/X/V)는 막지 않음 — E 클립보드가 copy · paste 사건으로 받음.
* ========================================================================== */
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<string, [number, number]> = {
ArrowUp: [-1, 0],
ArrowDown: [1, 0],
ArrowLeft: [0, -1],
ArrowRight: [0, 1],
};
export function attachKeys(ctx: SpreadsheetContext, ed: Editor): () => 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();
next(e.shiftKey ? -1 : 1, 0);
return true;
}
case "Tab":
ed.commit();
next(0, e.shiftKey ? -1 : 1);
return true;
case "Escape":
ed.cancel();
return true;
case "F2":
ed.toggleMode();
return true;
}
const arrow = ARROWS[e.key];
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();
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 (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;
}
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 "F2":
ed.begin();
return true;
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);
};
}