@aiquants/spreadsheet 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +1103 -0
  2. package/LICENSE.md +155 -0
  3. package/README.md +817 -0
  4. package/dist/index.cjs +21 -0
  5. package/dist/index.d.cts +1960 -0
  6. package/dist/index.d.ts +1960 -0
  7. package/dist/index.js +7711 -0
  8. package/dist/styles/spreadsheet.css +2 -0
  9. package/dist/styles/spreadsheet.standalone.css +3 -0
  10. package/package.json +94 -0
  11. package/src/Spreadsheet.tsx +2313 -0
  12. package/src/clipboard/clipPayload.ts +1242 -0
  13. package/src/clipboard/fixtures/excel.fixture.ts +301 -0
  14. package/src/clipboard/fixtures/html.fixture.ts +90 -0
  15. package/src/clipboard/fixtures/sheets.fixture.ts +83 -0
  16. package/src/clipboard/htmlInterop.ts +1694 -0
  17. package/src/clipboard/numberFormatCodec.ts +870 -0
  18. package/src/clipboard/pasteSpecial.ts +608 -0
  19. package/src/clipboard/tiling.ts +370 -0
  20. package/src/clipboard/tsv.ts +186 -0
  21. package/src/clipboard/useClipboard.ts +1154 -0
  22. package/src/commands/selfCalc.ts +279 -0
  23. package/src/commands/useCommands.ts +946 -0
  24. package/src/editor/useCellEditor.ts +708 -0
  25. package/src/grid/CellView.tsx +51 -0
  26. package/src/grid/GridBody.tsx +489 -0
  27. package/src/header/ColumnHeader.tsx +167 -0
  28. package/src/header/RowHeader.tsx +144 -0
  29. package/src/header/columnName.ts +31 -0
  30. package/src/header/resizeGates.ts +146 -0
  31. package/src/header/resizeStrips.ts +79 -0
  32. package/src/header/useHeaderResize.ts +598 -0
  33. package/src/header/useHeaderSelection.ts +454 -0
  34. package/src/index.ts +122 -0
  35. package/src/keyboard/compositionGuard.ts +53 -0
  36. package/src/keyboard/ensureCellVisible.ts +77 -0
  37. package/src/keyboard/shortcuts.ts +718 -0
  38. package/src/keyboard/useGridKeyboard.ts +940 -0
  39. package/src/model/axisModel.ts +186 -0
  40. package/src/model/cellAnnotations.ts +92 -0
  41. package/src/model/cellCoord.ts +114 -0
  42. package/src/model/cellFormat.ts +562 -0
  43. package/src/model/cellPaint.ts +537 -0
  44. package/src/model/conditionalFormat.ts +805 -0
  45. package/src/model/dataAdapter.ts +478 -0
  46. package/src/model/dataValidation.ts +542 -0
  47. package/src/model/formulaRefs.ts +984 -0
  48. package/src/model/frozenPartition.ts +100 -0
  49. package/src/model/labels.ts +228 -0
  50. package/src/model/viewStore.ts +124 -0
  51. package/src/overlay/SelectionOverlay.tsx +1425 -0
  52. package/src/overlay/useFillHandle.ts +432 -0
  53. package/src/popover/CommentPopover.tsx +109 -0
  54. package/src/popover/LinkPopover.tsx +108 -0
  55. package/src/popover/ListPopover.tsx +161 -0
  56. package/src/popover/MessagePopover.tsx +36 -0
  57. package/src/popover/NotePopover.tsx +106 -0
  58. package/src/popover/NoteTip.tsx +26 -0
  59. package/src/popover/PopoverLayer.tsx +210 -0
  60. package/src/popover/placement.ts +138 -0
  61. package/src/popover/usePopovers.ts +642 -0
  62. package/src/selection/selectionState.ts +239 -0
  63. package/src/selection/useAutoScroller.ts +226 -0
  64. package/src/selection/usePointDrag.ts +273 -0
  65. package/src/selection/useSelectionEngine.ts +687 -0
  66. package/src/styles/components.entry.css +10 -0
  67. package/src/styles/spreadsheet.css +1029 -0
  68. package/src/styles/standalone.entry.css +9 -0
@@ -0,0 +1,940 @@
1
+ /**
2
+ * @fileoverview Grid keymap implementation on the sink's keydown (§6.6.1 contract table,
3
+ * idle column; editing columns delegate to the editor session in Phase 3). v0.2 adds the
4
+ * findDataEdge navigation with pre-dispatch fail-fast validation (§2), the Ctrl/Shift+Space
5
+ * band rows (§4), visible-index stepping over hidden tracks (§5.2) and the corner-based exact
6
+ * page arithmetic (§9). Owns the Escape priority chain, the WCAG 2.1.2 escape hand-off, the
7
+ * printable-key edit trigger (plain printables open eagerly on keydown — including real AltGr
8
+ * characters, which evergreen browsers deliver WITHOUT ctrl+alt; ctrl+alt printable shapes —
9
+ * legacy AltGr emulation and barren literal shortcut chords — defer the session until a real
10
+ * character arrives on "input", so barren chords never open a destructive empty replace
11
+ * session — v0.3.3), the IME timing guards and
12
+ * the idle-sink self-repair. Modifier matching is STRICT per the table legend — combos not
13
+ * listed in a row fall through untouched. v0.8 (plan §4.2.7 / ADR-17): the IME guard is the
14
+ * shared `isCompositionKeydown`; the merged shortcut table is consulted ONCE per column
15
+ * (editing column: after the pointing rows, only for EDITING_COLUMN_COMMANDS; idle column:
16
+ * after the drag freeze, before the Arrow rows); the editing column gains the pointing rows
17
+ * (Arrow / Shift+Arrow / Ctrl+Arrow move the reference cursor, F2 toggles the arm, F4 cycles
18
+ * the anchor) and the reference-drag freeze (Escape aborts the drag only, every other key is
19
+ * consumed); Delete carries `hyperlink: null` when the aspect is declared; the Escape chain
20
+ * gains step 0 (hide the note tip) and step 1 (close the strict-rejection message popover);
21
+ * Ctrl+Shift+V arms the values-only paste for the native
22
+ * paste event; the pointing rows also paint the pointed cell / range on the overlay's
23
+ * resident drag-rect node through the `onPointFrame` writer (fixed "point" mode — Excel /
24
+ * Sheets outline the keyboard-pointed range), extinguished by any token-closing keydown
25
+ * (pure chord-preparation modifiers exempt), by the arrival of a printable character
26
+ * (`input`) and by the sink blur — the reference drag owns the node while it is in flight.
27
+ * The native letter rows (Ctrl+A / Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y / Ctrl+Shift+V)
28
+ * are matched through the table's VK rule (`matchChord` — the produced character when it is a
29
+ * Latin letter, else the physical `Key*` code), so they fire on non-Latin layouts (RU / EL …)
30
+ * exactly like Excel / Sheets and like every command row; on Latin layouts the match is
31
+ * identical to the produced-character test. Every addition is a capability param — absent =
32
+ * the v0.7 keymap.
33
+ *
34
+ * シンク keydown 上のキーマップ実装 (§6.6.1 の契約表 — idle 列。編集列は Phase 3 の編集
35
+ * セッションへ委譲)。v0.2 で findDataEdge ナビゲーション + ディスパッチ前 fail-fast 検証 (§2)・
36
+ * Ctrl/Shift+Space の帯行 (§4)・非表示トラックの可視 index ステップ (§5.2)・隅基点の正確
37
+ * ページ算術 (§9) を追加。Escape 優先順・WCAG 2.1.2 脱出・印字キー編集開始 (平印字は keydown
38
+ * 即開始 — evergreen ブラウザの実 AltGr 印字は修飾なしで届きこちら。ctrl+alt 印字形状 =
39
+ * レガシー AltGr エミュレーション / 文字を生まないリテラル和音は実文字到着まで遅延 —
40
+ * バレン和音の空置換セッション根絶、v0.3.3)・IME
41
+ * タイミングガード・idle シンク自己修復を所有する。修飾は表の凡例どおり厳密一致 — 表に無い
42
+ * 組合せは素通し。v0.8 (計画 §4.2.7 / ADR-17): IME ガードは共有の `isCompositionKeydown`、
43
+ * merge 済みショートカット表は列ごとに 1 回だけ参照 (編集列: ポインティング行の後、
44
+ * EDITING_COLUMN_COMMANDS に限る / idle 列: ドラッグ凍結の後、Arrow 系の前)。編集列は
45
+ * ポインティング行 (Arrow / Shift+Arrow / Ctrl+Arrow が参照カーソル、F2 が武装トグル、F4 が
46
+ * $ 循環) と参照ドラッグ凍結 (Escape はドラッグだけ中断、他キーは consume) を得る。Delete は
47
+ * 側面宣言時に `hyperlink: null` を運び、Escape チェーンは step 0 (ノート tip 消灯) と step 1
48
+ * (strict 拒否 message の消灯) を得て、
49
+ * Ctrl+Shift+V はネイティブ paste イベント用に値のみ貼り付けを武装する。ポインティング行は
50
+ * 指し先セル / レンジを `onPointFrame` writer (固定 "point" モード) で overlay の常設ドラッグ
51
+ * 矩形ノードへも描く (Excel / Sheets のキーボード指し枠) — 消灯はトークンを閉じ得る keydown
52
+ * (chord 準備の純修飾キーは除く)・印字の到着 (`input`)・シンク blur で、参照ドラッグ飛行中は
53
+ * ドラッグ側がノードを所有する。ネイティブ文字行 (Ctrl+A /
54
+ * Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y / Ctrl+Shift+V) は表と同じ VK 規則 (`matchChord` — 生成文字がラテン
55
+ * 文字ならその文字、非ラテンは物理 `Key*` code) で照合し、非ラテン配列 (RU / EL …) でも Excel /
56
+ * Sheets やコマンド行と同じく成立する (ラテン配列では生成文字照合と同一)。追加は全て能力 param —
57
+ * 不在 = v0.7 キーマップ。
58
+ */
59
+
60
+ import type { VirtualGridHandle } from "@aiquants/virtualscroll"
61
+ import { type RefObject, useEffect, useRef } from "react"
62
+ import type { PasteOptions } from "../clipboard/pasteSpecial.ts"
63
+ import type { PointingSeams } from "../editor/useCellEditor.ts"
64
+ import { type AxisModel, nextVisibleIndex } from "../model/axisModel.ts"
65
+ import { type CellCoord, type CellRange, countRangeCells, enumerateRangeCells, makeRange, type SpreadsheetDirection, type SpreadsheetOperation } from "../model/cellCoord.ts"
66
+ import { aspectCapabilities, type CellEdit, type EditRejection, type SpreadsheetDataAdapter } from "../model/dataAdapter.ts"
67
+ import { colToViewportX, rowToViewportY } from "../model/frozenPartition.ts"
68
+ import { reduceActiveCycle, type SelectionState } from "../selection/selectionState.ts"
69
+ import { COMPOSITION_CLEAR_MS, isCompositionKeydown } from "./compositionGuard.ts"
70
+ import { EDITING_COLUMN_COMMANDS, matchChord, parseChord, resolveCommand, type SpreadsheetCommand } from "./shortcuts.ts"
71
+
72
+ /** Discriminated moveActive input (§6.6.2-3). / moveActive の判別 union 入力。 */
73
+ export type MoveActiveInput = { mode: "cycle"; direction: SpreadsheetDirection } | { mode: "reset" | "extend"; cell: CellCoord }
74
+
75
+ /**
76
+ * The idle column's native letter rows (BUILTIN_ROWS non-command rows), parsed once and matched
77
+ * with the table's `matchChord` — the VK rule of plan §2.6, so RU `Ctrl+Ф` (code KeyA) selects
78
+ * all and RU `Ctrl+Shift+М` / EL `Ctrl+Shift+Ω` (code KeyV) arm the values-only paste.
79
+ * idle 列のネイティブ文字行 (BUILTIN_ROWS の非コマンド行)。1 回だけ解析し表と同じ `matchChord`
80
+ * (計画 §2.6 の VK 規則) で照合する — RU の `Ctrl+Ф` (code KeyA) は全選択、RU `Ctrl+Shift+М` / EL
81
+ * `Ctrl+Shift+Ω` (code KeyV) は値のみ貼り付けを武装する。
82
+ */
83
+ const NATIVE_LETTER_ROWS = {
84
+ selectAll: parseChord("Ctrl+A"),
85
+ undo: parseChord("Ctrl+Z"),
86
+ redo: parseChord("Ctrl+Y"),
87
+ redoShiftZ: parseChord("Ctrl+Shift+Z"),
88
+ pasteValues: parseChord("Ctrl+Shift+V"),
89
+ } as const
90
+
91
+ /**
92
+ * The command dispatch capability (ADR-17): the merged, validated chord table and the
93
+ * runner's execute / availability seams — one declaration, consulted once per column.
94
+ * コマンドディスパッチ能力 (ADR-17): merge + 検証済みチョード表と runner の execute /
95
+ * 利用可否シーム — 1 つの宣言として列ごとに 1 回参照する。
96
+ */
97
+ export type CommandDispatch = {
98
+ /** validateShortcuts' result (chord → command). / validateShortcuts の結果 (チョード → コマンド)。 */
99
+ readonly table: ReadonlyMap<string, SpreadsheetCommand>
100
+ /** Runs the command; the return value is the commit result (not consulted by the keymap). / コマンド実行 (戻り値は commit 結果 — キーマップは見ない)。 */
101
+ readonly execute: (command: SpreadsheetCommand) => boolean
102
+ /** Capability gate: false = the row is absent and the chord falls through. / 能力ゲート: false = 行不在 = 素通し。 */
103
+ readonly isAvailable: (command: SpreadsheetCommand) => boolean
104
+ }
105
+
106
+ type UseGridKeyboardParams = {
107
+ /** Zoom scale reader (v0.3): the keyboard cellRect is delivered in visual px (logical × zoom) so consumers can add the pane gBCR directly. / ズーム倍率 reader (v0.3)。keyboard cellRect は視覚 px (論理 × zoom) で渡す — consumer が pane の gBCR と直接加算できる契約。 */
108
+ zoomRef: RefObject<number>
109
+ /** Rewire gate: wiring sleeps while the grid is empty and re-attaches on the non-empty transition (§6.9-1 empty branch). / 空グリッド間は結線を眠らせ、非空遷移で再結線するゲート。 */
110
+ enabled: boolean
111
+ sinkRef: RefObject<HTMLTextAreaElement | null>
112
+ rootRef: RefObject<HTMLElement | null>
113
+ /** WCAG 2.1.2 hand-off: focuses the root group as the FLAGGED Escape exit — the only in-root focus move allowed to rest on the root (Spreadsheet's onRootFocus returns every other one to the sink). / WCAG 2.1.2 脱出 — 旗つきで root を focus する (root に留まれる唯一の内部移動)。 */
114
+ handOffFocusToRoot: () => void
115
+ getSelection: () => SelectionState
116
+ setSelection: (next: SelectionState) => void
117
+ moveActive: (input: MoveActiveInput) => void
118
+ ensureCellVisible: (cell: CellCoord) => void
119
+ isEditing: () => boolean
120
+ requestCommit: () => boolean
121
+ startEdit: (mode: "edit" | "replace") => void
122
+ /** The §6.9-2 write funnel (Delete / Backspace): the transaction id on success, null when the adapter rejected — the keyboard never branches on it (the wrapper already notified onEditRejected). / §6.9-2 の書込漏斗 (Delete / Backspace): 成功はトランザクション id、拒否は null — キーボードは戻り値で分岐しない (通知はラッパー側)。 */
123
+ commitEdits: (edits: readonly CellEdit[], rejection: EditRejection) => number | null
124
+ maxOperationCells: number
125
+ onOperationRejected?: (op: SpreadsheetOperation, cellCount: number, reason: "limit" | "bounds") => void
126
+ /** The armed clip (copy/cut) or null — Escape discard gate. / 装填中クリップ (Escape 破棄ゲート)。 */
127
+ getClip: () => { kind: "copy" | "cut"; range: CellRange } | null
128
+ discardClip: () => void
129
+ isDragging: () => boolean
130
+ abortDrag: () => void
131
+ /** The adapter's optional data-edge capability, re-read per keydown (adapter identity switches track naturally — §2.1). / adapter の任意データ端能力 (キーごとに読む — §2.1)。 */
132
+ findDataEdge: ((from: CellCoord, direction: SpreadsheetDirection) => CellCoord) | undefined
133
+ /** Editing mode probe — null = idle; drives the editing Arrow split (replace = commit + move, edit = native §6.6.1). / null = idle。編集列 Arrow 分岐用。 */
134
+ getEditingMode: () => "edit" | "replace" | null
135
+ /** Editing cancel, value discarded (editing Escape row). / 編集キャンセル (値破棄 — 編集列 Escape 行)。 */
136
+ cancelEdit: () => void
137
+ onUndoRequest?: () => void
138
+ onRedoRequest?: () => void
139
+ onCellContextMenu?: (cell: CellCoord, origin: { kind: "pointer"; event: MouseEvent } | { kind: "keyboard"; cellRect: { top: number; left: number; width: number; height: number } }) => void
140
+ rowAxis: AxisModel
141
+ colAxis: AxisModel
142
+ handleRef: RefObject<VirtualGridHandle | null>
143
+ rearmSink: () => void
144
+ /** v0.8 capability (ADR-17): consumer-rebindable command rows. Undefined = no command rows (v0.7 keymap). / v0.8 能力 (ADR-17): 再バインド可能なコマンド行。undefined = コマンド行なし (v0.7 キーマップ)。 */
145
+ commands?: CommandDispatch
146
+ /** v0.8 capability (§2.7): the editor's pointing seams for the editing column. Undefined = no pointing rows (v0.7 editing column). / v0.8 能力 (§2.7): 編集列のポインティングシーム。undefined = ポインティング行なし (v0.7 編集列)。 */
147
+ pointing?: PointingSeams
148
+ /** v0.8 capability: the adapter whose declared aspects shape the Delete patch (`hyperlink: null` only when getCellHyperlink is declared). Undefined = text-only clear (v0.7). / v0.8 能力: Delete パッチの側面を決めるアダプタ (getCellHyperlink 宣言時だけ `hyperlink: null`)。undefined = text だけの消去 (v0.7)。 */
149
+ adapter?: SpreadsheetDataAdapter
150
+ /** v0.8 capability: Escape chain step 0 — hides the visible note tip (true = handled). Undefined = the chain starts at the clip discard (v0.7). / v0.8 能力: Escape チェーン step 0 — 可視のノート tip を消灯 (true = 処理済み)。undefined = チェーンはクリップ破棄から (v0.7)。 */
151
+ hideNoteTip?: () => boolean
152
+ /** v0.8 capability: Escape chain step 1 (after the note tip, before the clip discard) — closes the visible strict-rejection message popover (true = handled); a passive inputMessage returns false (it lives until the active cell changes). Undefined = the chain proceeds (v0.7). / v0.8 能力: Escape チェーン step 1 (ノート tip の後・クリップ破棄の前) — strict 拒否の message ポップオーバーを消灯 (true = 処理済み)。受動 inputMessage は false (生存は active セル変化が決める)。undefined = チェーン続行 (v0.7)。 */
153
+ closeRejectedMessage?: () => boolean
154
+ /** v0.8 capability: the root-owned paste-options arm written by Ctrl+Shift+V (values only) for the native paste event; cleared on keyup / blur. Undefined = Ctrl+Shift+V passes through untouched (v0.7). / v0.8 能力: Ctrl+Shift+V (値のみ) がネイティブ paste イベント用に書く root 所有の貼り付けオプション武装。keyup / blur で解除。undefined = Ctrl+Shift+V は素通し (v0.7)。 */
155
+ pendingPasteOptionsRef?: RefObject<PasteOptions | null>
156
+ /** v0.8 capability (§2.7): the keyboard-pointing reference-rect writer — paints the pointed cell / range on the overlay's resident drag-rect node with the fixed "point" mode (the same node the reference drag paints; null hides it). Undefined = no rect (the pointing rows rewrite the reference only). / v0.8 能力 (§2.7): キーボードポインティングの参照矩形 writer — 指し先セル / レンジを overlay の常設ドラッグ矩形ノードへ固定 "point" モードで描く (参照ドラッグと同一ノード。null は消灯)。undefined = 矩形なし (ポインティング行は参照書換のみ)。 */
157
+ onPointFrame?: (range: CellRange | null) => void
158
+ }
159
+
160
+ /**
161
+ * Pure chord-preparation modifier keys: their own keydown never closes a pointed reference
162
+ * token, so they must not extinguish the keyboard point rect — Shift+Arrow range pointing
163
+ * REQUIRES pressing (and auto-repeating) Shift before the Arrow arrives.
164
+ * chord 準備の純修飾キー: 単独の keydown はポインティングトークンを閉じないため、キーボード
165
+ * 参照矩形を消してはならない — Shift+Arrow のレンジ指しは Arrow より先に Shift の押下
166
+ * (押しっぱなしの auto-repeat 込み) が届く。
167
+ */
168
+ const MODIFIER_KEYS = new Set(["Shift", "Control", "Alt", "Meta", "AltGraph"])
169
+
170
+ /** Arrow key to direction. / 矢印キー → 方向。 */
171
+ const ARROW_DIRECTIONS: Record<string, SpreadsheetDirection> = {
172
+ ArrowRight: "right",
173
+ ArrowLeft: "left",
174
+ ArrowDown: "down",
175
+ ArrowUp: "up",
176
+ }
177
+
178
+ /** Consume+no-op sheet-switch-class combos (§2.2 — the v0.1 destroying-nav set shrank to this in v0.2). / シート切替クラスの consume 対象 (v0.1 の選択破壊系は v0.2 で実装行になりここへ縮退)。 */
179
+ const isSelectionDestroyingNav = (event: KeyboardEvent): boolean => {
180
+ if (!event.shiftKey || event.altKey || !(event.ctrlKey || event.metaKey)) {
181
+ return false
182
+ }
183
+ // Ctrl+Shift+PageUp / PageDown (シート切替クラス — D-2 継続) のみ。素通しすると native
184
+ // 選択操作が armed 選択を collapse し Ctrl+C が無音失敗する (ADR-3 破壊)。v0.1 で同居して
185
+ // いた Shift+Home / End / PageUp / PageDown 系は §2.2 の実装行が受ける
186
+ return event.key === "PageUp" || event.key === "PageDown"
187
+ }
188
+
189
+ /**
190
+ * Wires the keymap to the sink. The engine owns no keydown — the Escape drag-abort travels
191
+ * through this hook's abortDrag seam (§6.6.2).
192
+ * キーマップをシンクへ結線する。エンジンは keydown を持たず、Escape のドラッグ中断は
193
+ * この abortDrag シームで実現する (§6.6.2)。
194
+ */
195
+ export const useGridKeyboard = (params: UseGridKeyboardParams): void => {
196
+ const compositionEndedAtRef = useRef<number | null>(null)
197
+ const clearTimerRef = useRef<number | null>(null)
198
+ // ctrl+alt 印字形状の遅延開始保留 (v0.3.3): 直前 keydown が ctrl+alt 同時の 1 文字キーの
199
+ // ときだけ true。実文字の到着 (input) で消費、次の keydown / keyup / blur で失効する一発
200
+ // フラグ。現実の通過者は文字を生まないリテラル和音 (Ctrl+Alt+X 等 — 空置換セッション破壊の
201
+ // 根絶対象) とレガシー AltGr エミュレーション形状 — evergreen ブラウザの実 AltGr 印字は
202
+ // 修飾なしで届き平印字の即開始経路に入る (R3(I) 確定)
203
+ const altGrPendingRef = useRef(false)
204
+ // キーボードポインティング矩形の生存フラグ (§2.7): true = 直前のポインティング Arrow が
205
+ // onPointFrame で描いた矩形が残り得る (消灯は冪等 — 参照ドラッグが先に消しても無害)
206
+ const pointFrameLiveRef = useRef(false)
207
+ // paramsRef パターン: リスナーは結線 1 回、最新パラメータはレンダー毎にここへ写す
208
+ const paramsRef = useRef(params)
209
+ paramsRef.current = params
210
+
211
+ const { enabled, sinkRef, rootRef, handleRef } = params
212
+ useEffect(() => {
213
+ const sink = sinkRef.current
214
+ const root = rootRef.current
215
+ // enabled は空グリッドの間 false — 非空遷移で deps が変わり、実 DOM が揃った後に再結線される
216
+ // (ref identity は不変のため、これ無しでは空マウント時の null bail が永久に固定される)
217
+ if (!enabled || sink === null || root === null) {
218
+ return
219
+ }
220
+
221
+ /** Extinguishes the keyboard-pointing reference rect when live (idempotent — a no-op after the reference drag already hid the node). / キーボードポインティング参照矩形の消灯 (冪等 — 参照ドラッグが既に消していても無害)。 */
222
+ const clearPointFrame = (): void => {
223
+ if (!pointFrameLiveRef.current) {
224
+ return
225
+ }
226
+ pointFrameLiveRef.current = false
227
+ paramsRef.current.onPointFrame?.(null)
228
+ }
229
+
230
+ /** One visible step on an axis: nextVisibleIndex, staying put when none exists (the 1-step family rule — §5.2). / 軸上の可視 1 歩 (可視なし = 動かない — 1 歩系共通規則)。 */
231
+ const stepVisible = (axis: AxisModel, from: number, step: 1 | -1): number => nextVisibleIndex(axis, from, step) ?? from
232
+
233
+ /** One visible arrow step from a coordinate (§5.2 — hidden runs jump in one keystroke). / 座標からの可視 1 歩 (非表示ランを 1 打鍵で跳ぶ)。 */
234
+ const arrowStep = (from: CellCoord, direction: SpreadsheetDirection): CellCoord => {
235
+ const p = paramsRef.current
236
+ if (direction === "left" || direction === "right") {
237
+ return { row: from.row, col: stepVisible(p.colAxis, from.col, direction === "right" ? 1 : -1) }
238
+ }
239
+ return { row: stepVisible(p.rowAxis, from.row, direction === "down" ? 1 : -1), col: from.col }
240
+ }
241
+
242
+ /** First visible index of an axis (§5.2 — an all-hidden axis fail-fasts inside indexAt). / 軸の最初の可視 index (全非表示軸は indexAt が fail-fast)。 */
243
+ const firstVisible = (axis: AxisModel): number => axis.indexAt(0)
244
+ /** Last visible index of an axis (same fail-fast trait as firstVisible). / 軸の最後の可視 index (fail-fast 特性は firstVisible と同一)。 */
245
+ const lastVisible = (axis: AxisModel): number => axis.indexAt(axis.totalSize() - 1)
246
+
247
+ /**
248
+ * The §5.3 hidden-landing rescan: forward in the travel direction first (a backward
249
+ * snap makes data beyond a hidden edge unreachable forever), then the nearest visible
250
+ * backward, else the fallback (= no move).
251
+ * §5.3 の非表示着地再スキャン: 進行方向優先 → 逆向き最寄り可視 → 可視なしは fallback
252
+ * (移動しない)。
253
+ */
254
+ const rescanAxis = (axis: AxisModel, landed: number, step: 1 | -1, fallback: number): number => {
255
+ if (axis.sizeOf(landed) > 0) {
256
+ return landed // 可視着地 — 再スキャン不要
257
+ }
258
+ // 進行方向の最寄り可視 (逆向きスナップは非表示端の先のデータを永久に不到達にする)
259
+ const forward = nextVisibleIndex(axis, landed, step)
260
+ if (forward !== null) {
261
+ return forward
262
+ }
263
+ // 逆向きの最寄り可視
264
+ const backward = nextVisibleIndex(axis, landed, step === 1 ? -1 : 1)
265
+ if (backward !== null) {
266
+ return backward
267
+ }
268
+ // 可視なし = 移動しない
269
+ return fallback
270
+ }
271
+
272
+ /** Anchor-diagonal corner of the tail range (the §6.6.2-3 extension-endpoint base — active would identity-bail from the 2nd keystroke). / anchor 対角の隅 (伸長端の基点 — active 起点は 2 打鍵目が identity bail)。 */
273
+ const extendCorner = (): CellCoord => {
274
+ const state = paramsRef.current.getSelection()
275
+ const range = state.ranges[state.ranges.length - 1]
276
+ // anchor がレンジ内部なら左上へ退化 — Ctrl+A 後の既知特性 (§6.6.2-3)
277
+ return {
278
+ row: state.anchor.row === range.startRow ? range.endRow : range.startRow,
279
+ col: state.anchor.col === range.startCol ? range.endCol : range.startCol,
280
+ }
281
+ }
282
+
283
+ /** Extend endpoint for Shift+Arrow: the corner stepped one VISIBLE step (§5.2 — the hidden-run lookalike dead key is what this closes). / Shift+Arrow の伸長端 = 隅の可視 1 歩。 */
284
+ const extendEndpoint = (direction: SpreadsheetDirection): CellCoord => arrowStep(extendCorner(), direction)
285
+
286
+ /**
287
+ * Ctrl+Arrow target from a base cell: the data edge (capability — validated fail-fast
288
+ * before dispatch §2.1) or the visible grid edge (v0.1), then the §5.3 hidden-landing
289
+ * rescan on the progression axis. Shared by the idle reset row and the pointing cursor.
290
+ * 基点セルからの Ctrl+Arrow 先: データ端 (能力 — ディスパッチ前 fail-fast 検証 §2.1) か
291
+ * 可視グリッド端 (v0.1)、続けて進行軸の §5.3 非表示着地再スキャン。idle の reset 行と
292
+ * ポインティングカーソルが共有する。
293
+ */
294
+ const dataEdgeTarget = (base: CellCoord, direction: SpreadsheetDirection): CellCoord => {
295
+ const p = paramsRef.current
296
+ const horizontal = direction === "left" || direction === "right"
297
+ const forwardStep: 1 | -1 = direction === "right" || direction === "down" ? 1 : -1
298
+ let cell: CellCoord
299
+ if (p.findDataEdge !== undefined) {
300
+ cell = p.findDataEdge({ row: base.row, col: base.col }, direction)
301
+ // 完全 fail-fast 検証 — moveActive へのディスパッチより前 (§2.1)。throw は
302
+ // ここから未捕捉で伝播させる (consumer バグの即時可視化 — 整流 catch を設けない)
303
+ validateDataEdge(cell, base, direction)
304
+ } else {
305
+ cell = {
306
+ row: direction === "down" ? p.rowAxis.count - 1 : direction === "up" ? 0 : base.row,
307
+ col: direction === "right" ? p.colAxis.count - 1 : direction === "left" ? 0 : base.col,
308
+ }
309
+ }
310
+ // §5.3: 進行軸の非表示着地は進行方向へ再スキャン (逆向きスナップのデッドキー閉塞)
311
+ return horizontal ? { row: cell.row, col: rescanAxis(p.colAxis, cell.col, forwardStep, base.col) } : { row: rescanAxis(p.rowAxis, cell.row, forwardStep, base.row), col: cell.col }
312
+ }
313
+
314
+ /**
315
+ * Full fail-fast validation of a reset-path findDataEdge return BEFORE dispatch (§2.1
316
+ * — No-Fallback rule 2; moveActive's clampCoord would silently absorb out-of-range
317
+ * values and the RangeError would never fire). Orthogonal drift is a consumer bug:
318
+ * Excel's Ctrl+Arrow stays on the origin row / column.
319
+ * reset 経路の findDataEdge 戻り値をディスパッチ前に完全 fail-fast 検証 (§2.1 —
320
+ * moveActive 内部の clampCoord が域外値を無音吸収するため検証はここ)。直交ドリフトは
321
+ * consumer バグ (Excel の Ctrl+Arrow は同一行 / 列に留まる)。
322
+ */
323
+ const validateDataEdge = (target: CellCoord, from: CellCoord, direction: SpreadsheetDirection): void => {
324
+ const p = paramsRef.current
325
+ if (!(Number.isInteger(target.row) && Number.isInteger(target.col) && target.row >= 0 && target.row < p.rowAxis.count && target.col >= 0 && target.col < p.colAxis.count)) {
326
+ throw new RangeError(`[spreadsheet] findDataEdge returned an out-of-grid coordinate {row: ${target.row}, col: ${target.col}} for a ${p.rowAxis.count} x ${p.colAxis.count} grid`)
327
+ }
328
+ const horizontal = direction === "left" || direction === "right"
329
+ if (horizontal ? target.row !== from.row : target.col !== from.col) {
330
+ throw new RangeError(`[spreadsheet] findDataEdge must stay on the origin ${horizontal ? "row" : "column"} (received {row: ${target.row}, col: ${target.col}} from {row: ${from.row}, col: ${from.col}})`)
331
+ }
332
+ }
333
+
334
+ /** Extend-path validation: ONLY the adopted progression-axis component (§2.1 — the orthogonal component is discarded, so it is not inspected). / extend 経路の検証 (採用する進行軸成分のみ — 直交成分は不採用のため検査しない)。 */
335
+ const validateDataEdgeComponent = (value: number, count: number, axisLabel: "row" | "col"): void => {
336
+ if (!(Number.isInteger(value) && value >= 0 && value < count)) {
337
+ throw new RangeError(`[spreadsheet] findDataEdge returned an out-of-axis ${axisLabel} component ${value} (count ${count})`)
338
+ }
339
+ }
340
+
341
+ /**
342
+ * Tab/Enter cycle with the identity-reset fallthrough (§3.4-4): reduceActiveCycle is
343
+ * probed directly (harmless double execution of a pure function) — identity (single
344
+ * 1x1 range / a full circle over duplicate ranges) falls through to a reset move, so
345
+ * neither shape becomes a cycling dead key. Shared by the idle and editing columns.
346
+ * Tab / Enter 巡回 + identity フォールスルー (§3.4-4): reduceActiveCycle を直接呼んで
347
+ * identity 判定 (純関数の二重実行 — 無害)。identity (単一 1×1 / 重複同一レンジ群の全周)
348
+ * は reset の通常移動へ — どちらの形も巡回デッドキーにならない。idle 列と編集列で共有。
349
+ */
350
+ const cycleOrReset = (cycleDirection: SpreadsheetDirection): void => {
351
+ const p = paramsRef.current
352
+ const state = p.getSelection()
353
+ if (reduceActiveCycle(state, cycleDirection) === state) {
354
+ // reset 移動は可視 1 歩 (§5.2 — 単一選択を畳む Excel の単セル Tab と同じ帰結)
355
+ p.moveActive({ mode: "reset", cell: arrowStep(state.active, cycleDirection) })
356
+ return
357
+ }
358
+ p.moveActive({ mode: "cycle", direction: cycleDirection })
359
+ }
360
+
361
+ /** Manual newline insertion at the caret (editing Alt+Enter — most browsers insert nothing natively). / キャレット位置へ改行を挿入 (編集列 Alt+Enter)。 */
362
+ const insertNewlineAtCaret = (): void => {
363
+ const sink = sinkRef.current
364
+ if (sink === null) {
365
+ return
366
+ }
367
+ const start = sink.selectionStart ?? sink.value.length
368
+ const end = sink.selectionEnd ?? start
369
+ sink.value = `${sink.value.slice(0, start)}\n${sink.value.slice(end)}`
370
+ sink.setSelectionRange(start + 1, start + 1)
371
+ }
372
+
373
+ /**
374
+ * Exact page-jump target row from a base row (§9 — the v0.1 uniform-division
375
+ * approximation replaced by Fenwick geometry; O(log n)). The base is the MOVING
376
+ * endpoint: active for reset moves, the extension corner for Shift+Page (an active
377
+ * base would mis-land on tall selections AND identity-bail the 2nd keystroke). The
378
+ * landing is structurally visible (indexAt skips zero-size tracks — §5.2).
379
+ * 基点行からの正確なページ移動先 (§9 — v0.1 の一様割り近似を Fenwick 幾何へ置換)。
380
+ * 基点は移動する端点 (reset = active、Shift 系 = 伸長隅)。着地は構造的に可視 (§5.2)。
381
+ */
382
+ const pageTargetRow = (baseRow: number, direction: "up" | "down"): number => {
383
+ const p = paramsRef.current
384
+ const viewportH = handleRef.current?.getViewportSize().height ?? -1
385
+ if (viewportH <= 0) {
386
+ // ペイン未計測 (番兵 -1 / 高さ 0) はページ幾何が定義できない — 可視 1 歩へ縮退
387
+ return stepVisible(p.rowAxis, baseRow, direction === "down" ? 1 : -1)
388
+ }
389
+ // indexAt は入力を [0, totalSize) へ内部クランプする — 端超過は最初 / 最後の可視行に解決
390
+ return p.rowAxis.indexAt(p.rowAxis.offsetOf(baseRow) + (direction === "down" ? viewportH : -viewportH))
391
+ }
392
+
393
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: §6.6.1 キーマップ表を 1 枚の平坦 dispatch として写経した契約実装 — 表の行 = コードの分岐を 1:1 で保つことが監査可能性の根拠であり、キー別関数へ分割すると表との突合が壊れる (ADR-9: 表が契約)
394
+ const onKeyDown = (event: KeyboardEvent) => {
395
+ const p = paramsRef.current
396
+ // 新しい打鍵は AltGr 保留を無条件に失効させる (保留を再設定するのは末尾の AltGr 分岐のみ)
397
+ altGrPendingRef.current = false
398
+ // 冒頭ガード (§6.6.2-1): 変換中 / keyCode 229 / compositionend 直後 50 ms 窓の Enter・Escape —
399
+ // 判定は compositionGuard.ts の単一真実源 (ポップオーバーの keydown も同じ関数を通す)
400
+ if (isCompositionKeydown(event, compositionEndedAtRef.current)) {
401
+ return
402
+ }
403
+ const ctrl = event.ctrlKey || event.metaKey
404
+ const alt = event.altKey
405
+ const direction = ARROW_DIRECTIONS[event.key]
406
+ // ポインティング参照矩形の消灯不変条件 (§2.7): 純修飾キー (chord 準備) 以外の打鍵は
407
+ // トークンを閉じ得るため必ず消す — ポインティング Arrow 行は同一ハンドラ内で直後に
408
+ // 再描画する (中間フレームなし)。参照ドラッグ飛行中はドラッグ側がノードを所有するため
409
+ // 触らない (消灯はドラッグの cleanup が担う)。生存フラグを先に見る — 矩形の無い通常
410
+ // 打鍵に合成ドラッグ面の読みを足さない
411
+ if (pointFrameLiveRef.current && !MODIFIER_KEYS.has(event.key) && !p.isDragging()) {
412
+ clearPointFrame()
413
+ }
414
+
415
+ if (p.isEditing()) {
416
+ // --- 編集列 (§6.6.1 の編集中 2 列 — セッション状態は useCellEditor が所有し、
417
+ // ディスパッチはこの表 1 枚に置く。表に無い組合せと「ネイティブ」行は素通し) ---
418
+ const editingMode = p.getEditingMode()
419
+ // --- 参照ドラッグ capture 中 (編集中に飛行し得るドラッグは参照ドラッグだけ — 他は commit
420
+ // ゲートで開始不能): Escape はドラッグだけ中断 (トークンはドラッグ前の綴りへ復元、
421
+ // セッションは残る — cancelEdit には届かない)、他キーは consume (spec 04 §3.6 / §3.7) ---
422
+ if (p.isDragging()) {
423
+ event.preventDefault()
424
+ if (event.key === "Escape" && !ctrl && !event.shiftKey && !alt) {
425
+ p.abortDrag()
426
+ }
427
+ return
428
+ }
429
+ // --- ポインティング (数式参照 — §2.7): 述語は判断点でその場評価。Arrow / Shift+Arrow /
430
+ // Ctrl+Arrow は参照カーソル (カーソルはキャレット下トークンから導出、状態なし)、F2 は
431
+ // 武装トグル、F4 は $ 循環。能力分岐: シーム未配線 = v0.7 編集列 ---
432
+ const pointing = p.pointing
433
+ if (pointing !== undefined) {
434
+ if (direction !== undefined && !alt) {
435
+ const reference = pointing.getReferenceCursor()
436
+ if (reference !== null) {
437
+ event.preventDefault()
438
+ // Ctrl はデータ端 (idle の Ctrl+Arrow と同じ導出)、素は可視 1 歩。active は動かさない
439
+ const next = ctrl ? dataEdgeTarget(reference.cursor, direction) : arrowStep(reference.cursor, direction)
440
+ p.ensureCellVisible(next)
441
+ // Shift はトークン起点とのレンジへ置換、素はカーソル単独
442
+ pointing.insertReference(event.shiftKey ? makeRange(reference.origin, next) : next, reference.axis)
443
+ // 指し先の参照矩形 (§2.7): 挿入したトークンと同じ範囲を "point" 矩形で描く
444
+ // (Excel / Sheets のキーボード指し枠)。帯参照 (A:C / 2:5) は軸全域へ展開 —
445
+ // トークンの意味する範囲そのもの。能力分岐: writer 未配線 = 矩形なし (参照書換のみ)
446
+ if (p.onPointFrame !== undefined) {
447
+ const rect = event.shiftKey ? makeRange(reference.origin, next) : makeRange(next, next)
448
+ p.onPointFrame(
449
+ reference.axis === "columns"
450
+ ? makeRange({ row: 0, col: rect.startCol }, { row: p.rowAxis.count - 1, col: rect.endCol })
451
+ : reference.axis === "rows"
452
+ ? makeRange({ row: rect.startRow, col: 0 }, { row: rect.endRow, col: p.colAxis.count - 1 })
453
+ : rect,
454
+ )
455
+ pointFrameLiveRef.current = true
456
+ }
457
+ return
458
+ }
459
+ }
460
+ if (event.key === "F2" && !ctrl && !event.shiftKey && !alt && pointing.togglePointingArm()) {
461
+ event.preventDefault()
462
+ return
463
+ }
464
+ if (event.key === "F4" && !ctrl && !event.shiftKey && !alt && pointing.cycleAnchorAtCaret()) {
465
+ event.preventDefault()
466
+ return
467
+ }
468
+ }
469
+ // --- 編集列のコマンド (ADR-17 — EDITING_COLUMN_COMMANDS のみ: fillSelection / insertDate /
470
+ // insertTime / 書式トグル / 揃え / 数値書式)。能力分岐: 表未配線 = 行不在 ---
471
+ const editingCommands = p.commands
472
+ if (editingCommands !== undefined) {
473
+ const command = resolveCommand(editingCommands.table, event)
474
+ if (command !== null && EDITING_COLUMN_COMMANDS.has(command) && editingCommands.isAvailable(command)) {
475
+ event.preventDefault()
476
+ editingCommands.execute(command)
477
+ return
478
+ }
479
+ }
480
+ if (direction !== undefined && !alt) {
481
+ if (ctrl && event.shiftKey) {
482
+ event.preventDefault() // Ctrl+Shift+Arrow: consume + no-op (全モード共通行)
483
+ return
484
+ }
485
+ if (ctrl) {
486
+ return // Ctrl+Arrow: ネイティブ単語キャレット移動 (逸脱 — Excel は確定 + ジャンプ)
487
+ }
488
+ if (editingMode === "replace") {
489
+ // replace: 確定 + 同方向へ移動 / Shift は確定 + その方向へ拡張開始 (Excel の Enter モード)
490
+ event.preventDefault()
491
+ if (!p.requestCommit()) {
492
+ return // applyEdits 拒否 — セッション維持 (§6.7.1)
493
+ }
494
+ if (event.shiftKey) {
495
+ p.moveActive({ mode: "extend", cell: extendEndpoint(direction) })
496
+ } else {
497
+ // 可視 1 歩 (§5.2 — idle の素 Arrow と同一規則)
498
+ p.moveActive({ mode: "reset", cell: arrowStep(p.getSelection().active, direction) })
499
+ }
500
+ return
501
+ }
502
+ return // edit モード: キャレット移動 / テキスト選択 (ネイティブ)
503
+ }
504
+ if (event.key === "Enter" && alt && !ctrl && !event.shiftKey) {
505
+ // Alt+Enter: キャレット位置へ改行挿入 (§6.6.1 — ネイティブは無挿入のブラウザが多い)。
506
+ // 修飾は厳密一致 (凡例) — Shift+Alt+Enter は表に無い = 素通し
507
+ event.preventDefault()
508
+ insertNewlineAtCaret()
509
+ return
510
+ }
511
+ if ((event.key === "Enter" || event.key === "Tab") && !ctrl && !alt) {
512
+ // 確定 + 巡回 (idle 列と同じ moveActive — cycleOrReset を共有)
513
+ event.preventDefault()
514
+ if (!p.requestCommit()) {
515
+ return
516
+ }
517
+ const forward = !event.shiftKey
518
+ cycleOrReset(event.key === "Tab" ? (forward ? "right" : "left") : forward ? "down" : "up")
519
+ return
520
+ }
521
+ if ((event.key === "PageUp" || event.key === "PageDown") && !event.shiftKey && !ctrl && !alt) {
522
+ // 確定 + 移動 (§6.6.1 — §9 の基点パラメタ化ページ算術)
523
+ event.preventDefault()
524
+ if (!p.requestCommit()) {
525
+ return
526
+ }
527
+ const active = p.getSelection().active
528
+ p.moveActive({ mode: "reset", cell: { row: pageTargetRow(active.row, event.key === "PageDown" ? "down" : "up"), col: active.col } })
529
+ return
530
+ }
531
+ if (event.key === "Escape" && !ctrl && !event.shiftKey && !alt) {
532
+ // 編集キャンセル (値破棄 — §6.6.1)
533
+ event.preventDefault()
534
+ p.cancelEdit()
535
+ return
536
+ }
537
+ // その他 (Home / End / Delete / Ctrl+A / Z / Y / C / X / V / F2 / 印字) はネイティブへ素通し
538
+ return
539
+ }
540
+ if (event.defaultPrevented) {
541
+ return
542
+ }
543
+ // ドラッグ中の非 Escape キーは consume + no-op (§6.4.1 の state 凍結契約 — キーボード面。
544
+ // 矢印 / Delete / Ctrl+A 等が飛行中ドラッグの anchor や adapter を動かすと、確定矩形と
545
+ // 画面のドラッグ矩形が乖離し onSelectionChange の「ドラッグ中は発火しない」契約が破れる。
546
+ // 素通しにしない理由: native caret 操作が armed 選択を崩し、確定が identity bail した
547
+ // 場合に rearm されない隙間が残る。Excel 同等 (ドラッグ中の打鍵は無効)。Escape だけは
548
+ // 下の優先順チェーン (中断) へ流す
549
+ if (p.isDragging() && event.key !== "Escape") {
550
+ event.preventDefault()
551
+ return
552
+ }
553
+
554
+ // --- consumer claim + 組込みコマンド行 (ADR-17: merge 済み表の 1 参照 — Alt 行は AltGraph で
555
+ // 不成立)。ドラッグ凍結の後・Arrow 系の前。能力分岐: 表未配線 = 行不在 (v0.7 キーマップ) ---
556
+ const commands = p.commands
557
+ if (commands !== undefined) {
558
+ const command = resolveCommand(commands.table, event)
559
+ if (command !== null && commands.isAvailable(command)) {
560
+ event.preventDefault()
561
+ commands.execute(command)
562
+ return
563
+ }
564
+ }
565
+
566
+ // --- Arrow 系 (修飾は厳密一致 — 表の凡例。Alt を含む組合せは表に無い = 素通し) ---
567
+ if (direction !== undefined && !alt) {
568
+ const horizontal = direction === "left" || direction === "right"
569
+ const forwardStep: 1 | -1 = direction === "right" || direction === "down" ? 1 : -1
570
+ if (ctrl && event.shiftKey) {
571
+ // Ctrl+Shift+Arrow (§2.3): 能力あり = 混成座標走査で extend / 能力なし = 隅の
572
+ // 進行軸成分を可視グリッド端へ (Ctrl+Arrow のグリッド端と対称 — 死キー非対称を作らない)
573
+ event.preventDefault()
574
+ const state = p.getSelection()
575
+ const corner = extendCorner()
576
+ let progression: number
577
+ if (p.findDataEdge !== undefined) {
578
+ // 走査開始 = 進行軸は隅、直交軸は active (Excel は active と同じ行 / 列で走査 — §2.3-1)
579
+ const scanStart: CellCoord = horizontal ? { row: state.active.row, col: corner.col } : { row: corner.row, col: state.active.col }
580
+ const target = p.findDataEdge(scanStart, direction)
581
+ // 検証は採用する進行軸成分のみ — ディスパッチ前 (§2.1)
582
+ progression = horizontal ? target.col : target.row
583
+ validateDataEdgeComponent(progression, horizontal ? p.colAxis.count : p.rowAxis.count, horizontal ? "col" : "row")
584
+ // 非表示着地は進行方向へ再スキャン (§5.2 行 8 / §5.3)
585
+ progression = rescanAxis(horizontal ? p.colAxis : p.rowAxis, progression, forwardStep, horizontal ? corner.col : corner.row)
586
+ } else {
587
+ const axis = horizontal ? p.colAxis : p.rowAxis
588
+ progression = forwardStep === 1 ? lastVisible(axis) : firstVisible(axis)
589
+ }
590
+ // 戻りから進行軸成分のみを採り直交軸は隅を維持 (素朴 extend は直交拡張幅を潰す — §2.3-2)。
591
+ // 2 打鍵目の前進は隅の進行軸成分の更新で自然に成立 (identity bail 回避 — §2.3-3)
592
+ p.moveActive({ mode: "extend", cell: horizontal ? { row: corner.row, col: progression } : { row: progression, col: corner.col } })
593
+ return
594
+ }
595
+ if (ctrl) {
596
+ // Ctrl+Arrow: データ端ジャンプ (能力あり — §2.1) / グリッド端 (能力なし — v0.1 従来)。
597
+ // 導出は dataEdgeTarget 1 点 (検証 + §5.3 再スキャン込み — ポインティングカーソルと共有)
598
+ event.preventDefault()
599
+ p.moveActive({ mode: "reset", cell: dataEdgeTarget(p.getSelection().active, direction) })
600
+ return
601
+ }
602
+ if (event.shiftKey) {
603
+ // Shift+Arrow: anchor 起点の現在レンジ拡張 (active 不動)。ensure-visible は伸長端
604
+ event.preventDefault()
605
+ p.moveActive({ mode: "extend", cell: extendEndpoint(direction) })
606
+ return
607
+ }
608
+ // 素の Arrow: active の可視 1 歩 + 選択リセット (§5.2 — 非表示ランを 1 打鍵で跳ぶ)
609
+ event.preventDefault()
610
+ p.moveActive({ mode: "reset", cell: arrowStep(p.getSelection().active, direction) })
611
+ return
612
+ }
613
+
614
+ // --- 選択破壊系ナビの consume (§6.6.1) ---
615
+ if (isSelectionDestroyingNav(event)) {
616
+ event.preventDefault()
617
+ return
618
+ }
619
+
620
+ // --- Tab / Enter (レンジ内巡回。1×1 は通常移動)。表の行は Tab / Shift+Tab / Enter /
621
+ // Shift+Enter のみ — Ctrl / Alt を含む組合せは素通し (Alt+Enter は編集列専用の「—」) ---
622
+ if ((event.key === "Tab" || event.key === "Enter") && !ctrl && !alt) {
623
+ event.preventDefault()
624
+ const forward = !event.shiftKey
625
+ cycleOrReset(event.key === "Tab" ? (forward ? "right" : "left") : forward ? "down" : "up")
626
+ return
627
+ }
628
+
629
+ // --- Home / End (§2.2 / §5.2: 素 + Ctrl = reset 行、Shift + Ctrl+Shift = 拡張行。
630
+ // 端点は常に可視 index — 全非表示軸は indexAt の RangeError をそのまま throw。
631
+ // Alt を含む組合せは素通し) ---
632
+ if (event.key === "Home" && !alt) {
633
+ event.preventDefault()
634
+ if (event.shiftKey) {
635
+ // Shift+Home = 隅の行で行頭拡張 (隅行規則は F8 拡張モード同型 — 実機検証ゲート対象
636
+ // §2.2) / Ctrl+Shift+Home = {最初の可視行, 最初の可視列} へ拡張
637
+ const corner = extendCorner()
638
+ p.moveActive({ mode: "extend", cell: { row: ctrl ? firstVisible(p.rowAxis) : corner.row, col: firstVisible(p.colAxis) } })
639
+ return
640
+ }
641
+ const active = p.getSelection().active
642
+ p.moveActive({ mode: "reset", cell: { row: ctrl ? firstVisible(p.rowAxis) : active.row, col: firstVisible(p.colAxis) } })
643
+ return
644
+ }
645
+ if (event.key === "End" && !alt) {
646
+ event.preventDefault()
647
+ if (event.shiftKey) {
648
+ // Shift+End = 隅の行で行末拡張 (Sheets 系 — 実 Excel は End モードトグル §10 逸脱表) /
649
+ // Ctrl+Shift+End = {最後の可視行, 最後の可視列} へ拡張 (Ctrl+End と同じ逸脱系)
650
+ const corner = extendCorner()
651
+ p.moveActive({ mode: "extend", cell: { row: ctrl ? lastVisible(p.rowAxis) : corner.row, col: lastVisible(p.colAxis) } })
652
+ return
653
+ }
654
+ const active = p.getSelection().active
655
+ // 逸脱: Excel の Ctrl+End は used range の右下 — 本実装は可視グリッド右下 (v0.1 逸脱の一貫継続)
656
+ p.moveActive({ mode: "reset", cell: { row: ctrl ? lastVisible(p.rowAxis) : active.row, col: lastVisible(p.colAxis) } })
657
+ return
658
+ }
659
+
660
+ // --- PageUp / PageDown (§9: 素 = active 基点の reset、Shift = 隅基点の拡張。
661
+ // Ctrl+Shift は上の consume 行、Ctrl 単独 / Alt は表に無い = 素通し) ---
662
+ if ((event.key === "PageUp" || event.key === "PageDown") && !ctrl && !alt) {
663
+ event.preventDefault()
664
+ const vertical = event.key === "PageDown" ? ("down" as const) : ("up" as const)
665
+ if (event.shiftKey) {
666
+ // 基点は移動する端点 = 隅 (active 基点は縦長選択で誤着地 + identity bail — §9)
667
+ const corner = extendCorner()
668
+ p.moveActive({ mode: "extend", cell: { row: pageTargetRow(corner.row, vertical), col: corner.col } })
669
+ return
670
+ }
671
+ const active = p.getSelection().active
672
+ p.moveActive({ mode: "reset", cell: { row: pageTargetRow(active.row, vertical), col: active.col } })
673
+ return
674
+ }
675
+
676
+ // --- F2: 編集開始 (edit モード) ---
677
+ if (event.key === "F2" && !ctrl && !event.shiftKey && !alt) {
678
+ event.preventDefault()
679
+ p.startEdit("edit")
680
+ return
681
+ }
682
+
683
+ // --- Delete / Backspace: 全選択セルを "" (上限内で commitEdits 1 回)。表の行は素のみ —
684
+ // Shift+Delete (レガシー cut) / Ctrl+Delete / Alt+Delete は表に無い = 素通し
685
+ // (修飾つきを consume すると cut 意図のユーザーのデータを無言破壊する) ---
686
+ if ((event.key === "Delete" || event.key === "Backspace") && !ctrl && !event.shiftKey && !alt) {
687
+ event.preventDefault()
688
+ const state = p.getSelection()
689
+ const count = countRangeCells(state.ranges)
690
+ if (count > p.maxOperationCells) {
691
+ p.onOperationRejected?.("delete", count, "limit")
692
+ return
693
+ }
694
+ const edits: CellEdit[] = []
695
+ // Delete はパッチ (v0.8 §4.2.7): text を消し、getCellHyperlink 宣言時だけ hyperlink も消す
696
+ // (Excel: 内容の削除はリンクも外す)。未宣言側面のキーは出さない — 能力分岐 (adapter 未配線 = text のみ)
697
+ const clearsHyperlink = p.adapter !== undefined && aspectCapabilities(p.adapter).hyperlink
698
+ // enumerateRangeCells の重複排除で同一セルの二重編集を作らない (§6.2.1)
699
+ enumerateRangeCells(state.ranges, (cell) => {
700
+ edits.push(clearsHyperlink ? { row: cell.row, col: cell.col, text: "", hyperlink: null } : { row: cell.row, col: cell.col, text: "" })
701
+ })
702
+ p.commitEdits(edits, { source: "delete" })
703
+ return
704
+ }
705
+
706
+ // --- Escape: 優先順 = ドラッグ中断 > ノート tip 消灯 > strict 拒否 message 消灯 > クリップ破棄 (copy / cut アンツ) > root へ脱出 (1 押下 1 動作) ---
707
+ if (event.key === "Escape") {
708
+ // ドラッグ中断は修飾に寛容 (意図的逸脱): Shift ドラッグ中の Escape は Shift+Escape に
709
+ // なるのが自然な指運びで、厳密一致だと中断を取りこぼす。安全動作のみ寛容にする
710
+ if (p.isDragging()) {
711
+ event.preventDefault()
712
+ p.abortDrag()
713
+ return
714
+ }
715
+ if (ctrl || event.shiftKey || alt) {
716
+ return // 表の行は素の Escape のみ — 修飾つきは素通し
717
+ }
718
+ event.preventDefault()
719
+ // step 0 (v0.8 §4.2.7): 可視のノート tip を消灯 (ポインタがセルを離れるまで再表示抑止) —
720
+ // クリップ破棄より先。能力分岐: ポップオーバー未配線ならチェーンはクリップ破棄から
721
+ if (p.hideNoteTip?.()) {
722
+ return
723
+ }
724
+ // step 1 (v0.8): strict 拒否で残った message ポップオーバーを消灯 — クリップ破棄より先
725
+ // (拒否告知は最前面の作業残骸)。受動 inputMessage はシームが false を返しチェーン続行
726
+ // (生存は active セル変化が決める — Excel 同等)。能力分岐: 未配線 = チェーン続行 (v0.7)
727
+ if (p.closeRejectedMessage?.()) {
728
+ return
729
+ }
730
+ if (p.getClip() !== null) {
731
+ // foreign でも破棄 (どのシートからでもマーキーを消せる — R2-5)
732
+ p.discardClip()
733
+ return
734
+ }
735
+ // WCAG 2.1.2 脱出: 旗つきの root.focus() を 1 回呼ぶ (Spreadsheet の handOffFocusToRoot)。
736
+ // ❗ sink.blur() を先に呼ばない — シンク保持のまま root.focus() すると暗黙 blur で
737
+ // relatedTarget = シンクになり、§10-4 の再入規則が自己循環しない (先 blur だと relatedTarget
738
+ // null で跳ね返る)。旗は onRootFocus の迷子回収 (内部から root 自身へ着地したフォーカスは
739
+ // シンクへ戻す — R3 是正) に「これは脱出」と伝える唯一の手段
740
+ p.handOffFocusToRoot()
741
+ return
742
+ }
743
+
744
+ // --- Ctrl+A: 全選択 (active / anchor は現在位置に留まる — コーナー click とは別規則)。文字行は
745
+ // 表と同じ VK 規則 (matchChord — 修飾は厳密一致、生成文字がラテン文字ならその文字、非ラテンは
746
+ // 物理 Key* code) で照合する: RU の Ctrl+Ф も全選択 (Excel / Sheets 同等) ---
747
+ if (matchChord(NATIVE_LETTER_ROWS.selectAll, event)) {
748
+ event.preventDefault()
749
+ const state = p.getSelection()
750
+ const all: CellRange = makeRange({ row: 0, col: 0 }, { row: p.rowAxis.count - 1, col: p.colAxis.count - 1 })
751
+ p.setSelection({ ranges: [all], anchor: state.anchor, active: state.active })
752
+ return
753
+ }
754
+
755
+ // --- Ctrl+Z / Y (+Shift+Z): Undo / Redo 中継 (VK 規則 — RU の Ctrl+Я / Ctrl+Н も成立) ---
756
+ if (matchChord(NATIVE_LETTER_ROWS.undo, event)) {
757
+ event.preventDefault()
758
+ p.onUndoRequest?.()
759
+ return
760
+ }
761
+ if (matchChord(NATIVE_LETTER_ROWS.redo, event) || matchChord(NATIVE_LETTER_ROWS.redoShiftZ, event)) {
762
+ event.preventDefault()
763
+ p.onRedoRequest?.()
764
+ return
765
+ }
766
+
767
+ // --- Shift+F10 / ContextMenu: 先に可視化 → keyboard 起点のメニュー通知 ---
768
+ if ((event.key === "F10" && event.shiftKey && !ctrl && !alt) || (event.key === "ContextMenu" && !ctrl && !event.shiftKey && !alt)) {
769
+ event.preventDefault()
770
+ const active = p.getSelection().active
771
+ // active はスクロールで画面外に出得る — 画面外座標へメニューを出さない (§6.6.1)
772
+ p.ensureCellVisible(active)
773
+ // 両軸ともハンドルの同期読み (§7-4): ensureCellVisible 直後の cellRect は
774
+ // 最新位置で計算しないと横画面外からのメニュー表示が旧位置へ誤配置される
775
+ const handle = handleRef.current
776
+ const position = handle?.getScrollPosition() ?? { x: 0, y: 0 }
777
+ // 両軸とも読点で番兵クランプ (対称 — detached の {-1,-1} を座標へ流さない)
778
+ const vy = Math.max(0, position.y)
779
+ const hx = Math.max(0, position.x)
780
+ // ズーム境界 (v0.3): cellRect は視覚 px (論理 × z) — consumer は胴体ペインの
781
+ // gBCR (視覚 px) と直接加算して fixed 配置できる (デモ /render が参照実装)
782
+ const zMenu = p.zoomRef.current
783
+ p.onCellContextMenu?.(active, {
784
+ kind: "keyboard",
785
+ cellRect: {
786
+ // 行 / 列とも凍結パーティション順写像 (v0.6 / v0.5 計画 §2 — 凍結軸は静的座標)
787
+ top: rowToViewportY(p.rowAxis, active.row, vy, handle?.getFrozenSize().rows ?? 0) * zMenu,
788
+ left: colToViewportX(p.colAxis, active.col, hx, handle?.getFrozenSize().cols ?? 0) * zMenu,
789
+ width: p.colAxis.sizeOf(active.col) * zMenu,
790
+ height: p.rowAxis.sizeOf(active.row) * zMenu,
791
+ },
792
+ })
793
+ return
794
+ }
795
+
796
+ // --- Ctrl+Space / Shift+Space (§4 KB-2): 全レンジの列帯化 / 行帯化。印字キー分岐より
797
+ // 前に置く — idle の Shift+Space はここで v0.1 の replace 編集開始を置換する (挙動
798
+ // 変更 — CHANGELOG)。条件は「ctrl / shift の排他的どちらか一方」= Ctrl+Shift+Space は
799
+ // 表に無い組合せとして素通し (Ctrl+A で代替 — README 注記) ---
800
+ if (event.key === " " && !alt && ctrl !== event.shiftKey) {
801
+ event.preventDefault()
802
+ const state = p.getSelection()
803
+ // Excel の複数エリア Ctrl+Space は全エリアを列化する — 各レンジを帯へ置換。
804
+ // 重複帯は併合しない (v0.1 方針)。anchor / active 不動 (包含拡大で両不変条件は自明に維持)
805
+ const banded = state.ranges.map((range) => (ctrl ? makeRange({ row: 0, col: range.startCol }, { row: p.rowAxis.count - 1, col: range.endCol }) : makeRange({ row: range.startRow, col: 0 }, { row: range.endRow, col: p.colAxis.count - 1 })))
806
+ // 単一構築 state の setSelection 1 回 (過渡状態なし — 構造 bail が等価再打鍵を無発火化)
807
+ p.setSelection({ ranges: banded, anchor: state.anchor, active: state.active })
808
+ return
809
+ }
810
+
811
+ // --- Ctrl+C / X / V: 何もしない (ClipboardEvent 経由で §6.5 — preventDefault 禁止) ---
812
+ // --- Ctrl+Shift+V (v0.8 §4.2.7): 値のみ貼り付けを武装するだけ — preventDefault しない (ネイティブ
813
+ // paste イベントが発火し useClipboard が武装を読んで消す)。keyup / blur で失効。照合は VK 規則
814
+ // (RU の Ctrl+Shift+М / EL の Ctrl+Shift+Ω も武装 — 生成文字だけを見ると非ラテン配列で武装が
815
+ // 抜け、続く text/plain の paste が all モードで format: null を書き書式を消す)。
816
+ // 能力分岐: ref 未配線なら従来どおり素通し (通常貼り付け) ---
817
+ if (p.pendingPasteOptionsRef !== undefined && matchChord(NATIVE_LETTER_ROWS.pasteValues, event)) {
818
+ p.pendingPasteOptionsRef.current = { mode: "values" }
819
+ return
820
+ }
821
+ // --- 印字キー: 編集開始 (replace)。preventDefault しない — 打鍵をシンクへ自然流入させる ---
822
+ if (event.key.length === 1 && !event.metaKey && !(event.ctrlKey !== event.altKey)) {
823
+ // 逸脱: macOS Option+文字は alt 単独扱いで編集開始しない (README / §14)
824
+ if (event.ctrlKey && event.altKey) {
825
+ // ctrl+alt 印字形状 (§6.6.2-4 — レガシー AltGr エミュレーションとリテラル
826
+ // 和音) はセッションを開かず、実文字の到着 (input) まで保留する。文字を
827
+ // 生まないバレン和音 (Ctrl+Alt+X や US 配列の Ctrl+Alt+Shift+`-` = "_" 等)
828
+ // が空の置換セッションを開き、次クリックの確定で "" がコミットされてセル
829
+ // 内容が破壊される経路の根絶 (R1(I) blocking — jsdom / 実機の両実測)。
830
+ // evergreen ブラウザの実 AltGr 印字は修飾なしで届き本分岐を通らない。
831
+ // IME が compositionstart で遅延開始するのと同型の設計
832
+ altGrPendingRef.current = true
833
+ return
834
+ }
835
+ // 修飾なしの印字は即開始 (§6.6.2-4) — 実文字が到着しない平印字は存在しない
836
+ // (dead key は key 長 > 1 で本分岐に入らない)
837
+ p.startEdit("replace")
838
+ return
839
+ }
840
+ // 表に無いキーは触らない (凡例)
841
+ }
842
+
843
+ const onCompositionStart = () => {
844
+ const p = paramsRef.current
845
+ if (!p.isEditing()) {
846
+ // IME トリガー: value = "" の設定 1 点で replace モード開始 (§6.6.2-5)
847
+ p.startEdit("replace")
848
+ }
849
+ }
850
+
851
+ const onCompositionEnd = (event: CompositionEvent) => {
852
+ compositionEndedAtRef.current = event.timeStamp
853
+ if (clearTimerRef.current !== null) {
854
+ window.clearTimeout(clearTimerRef.current)
855
+ }
856
+ // 解除は保険タイマ (setTimeout(0) はタスク境界依存でガードが先に消える — §6.6.2-1)
857
+ clearTimerRef.current = window.setTimeout(() => {
858
+ compositionEndedAtRef.current = null
859
+ clearTimerRef.current = null
860
+ }, COMPOSITION_CLEAR_MS)
861
+ }
862
+
863
+ const onInput = () => {
864
+ const p = paramsRef.current
865
+ // 印字の到着はポインティングトークンを構造的に閉じる (§2.7) — 参照矩形を消灯する。
866
+ // insertReference のプログラム的な value 代入は input を発火しないため自己消灯はない
867
+ clearPointFrame()
868
+ if (!p.isEditing()) {
869
+ // ctrl+alt 印字形状の遅延開始 (v0.3.3): 直前 keydown が保留対象で、かつ実文字が
870
+ // 実際に到着した場合だけセッションを開く。startEdit("replace") は value を "" へ戻す
871
+ // ため、到着値を保存してから開き復元する (即開始経路と同じ終状態)
872
+ if (altGrPendingRef.current) {
873
+ altGrPendingRef.current = false
874
+ const arrived = sink.value
875
+ // 保留窓 (keydown〜keyup / blur) 内のキー由来 input は当該和音自身の挿入で、
876
+ // 挿入後の value が空になることはない — 非空 = 実文字到着 (AltGr+Space の " "
877
+ // も正当)。和音を押下したままのマウス由来変異 (ドロップ等) も到着扱いになる
878
+ // 有界残余は R3(I) 登記 (paste は useClipboard が preventDefault し到達しない)
879
+ if (arrived !== "") {
880
+ p.startEdit("replace")
881
+ // startEdit はドラッグ中 / シンク不在で bail し得る — 開いた場合のみ復元
882
+ if (p.isEditing()) {
883
+ sink.value = arrived
884
+ sink.setSelectionRange(arrived.length, arrived.length)
885
+ return
886
+ }
887
+ }
888
+ }
889
+ // idle シンク自己修復: 編集セッション外のネイティブ変異 (自動修正等) を即時復旧 (§6.6.2-4)
890
+ p.rearmSink()
891
+ }
892
+ }
893
+
894
+ // AltGr 保留の失効 (一発性の後ろ半分): 実文字は必ず keyup より先に input で到着する —
895
+ // keyup まで残った保留はバレン和音の残骸で、後続の無関係変異 (自動修正等) を誤って
896
+ // セッション化させないうちに消す
897
+ const onKeyUp = () => {
898
+ altGrPendingRef.current = false
899
+ // Ctrl+Shift+V の武装も keyup で失効 (paste イベントは keydown〜keyup の間に届く)
900
+ const pending = paramsRef.current.pendingPasteOptionsRef
901
+ if (pending !== undefined) {
902
+ pending.current = null
903
+ }
904
+ }
905
+ // フォーカス喪失も失効させる — 和音がホストのフォーカス移動ショートカットを兼ねると
906
+ // keyup がシンクへ戻らず保留が残留し、復帰後の無関係変異 (ドロップ / 自動修正等) を
907
+ // 誤ってセッション化し得る (R2(I) minor の実測)
908
+ const onBlur = () => {
909
+ altGrPendingRef.current = false
910
+ // 武装済みの貼り付けオプションもフォーカス喪失で失効 (次の無関係な paste へ漏らさない)
911
+ const pending = paramsRef.current.pendingPasteOptionsRef
912
+ if (pending !== undefined) {
913
+ pending.current = null
914
+ }
915
+ // フォーカス喪失はセッションの grid-exit 確定 (focusout) を伴い得る — キーボードの
916
+ // 参照矩形をフォーカス無き画面に残さない (§2.7)
917
+ clearPointFrame()
918
+ }
919
+
920
+ sink.addEventListener("keydown", onKeyDown)
921
+ sink.addEventListener("keyup", onKeyUp)
922
+ sink.addEventListener("blur", onBlur)
923
+ sink.addEventListener("compositionstart", onCompositionStart)
924
+ sink.addEventListener("compositionend", onCompositionEnd)
925
+ sink.addEventListener("input", onInput)
926
+ return () => {
927
+ sink.removeEventListener("keydown", onKeyDown)
928
+ sink.removeEventListener("keyup", onKeyUp)
929
+ sink.removeEventListener("blur", onBlur)
930
+ sink.removeEventListener("compositionstart", onCompositionStart)
931
+ sink.removeEventListener("compositionend", onCompositionEnd)
932
+ sink.removeEventListener("input", onInput)
933
+ if (clearTimerRef.current !== null) {
934
+ window.clearTimeout(clearTimerRef.current)
935
+ }
936
+ // 再結線 / アンマウントを跨いで矩形を残さない (writer リレーはノード不在なら no-op)
937
+ clearPointFrame()
938
+ }
939
+ }, [enabled, sinkRef, rootRef, handleRef])
940
+ }