@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,718 @@
1
+ /**
2
+ * @fileoverview Rebindable command table (v0.8 ADR-17 — plan §1.5 / §2.6 / §4.1.11). Pure,
3
+ * React-free: the SpreadsheetCommand vocabulary, the canonical chord grammar
4
+ * (`Ctrl+Alt+Shift+KEY` — used modifiers only, fixed order, Cmd = Ctrl), the ONE table of exact
5
+ * built-in rows (BUILTIN_ROWS — every non-rebindable row of spec 04 §3.2 plus every rebindable
6
+ * command default row), the constants derived from that table (DEFAULT_SHORTCUTS,
7
+ * SPREADSHEET_RESERVED_SHORTCUTS, EDITING_COLUMN_COMMANDS), the VK rule that maps a keydown to a
8
+ * KEY token independently of the keyboard layout (letters by `event.key` when it is a single
9
+ * Latin letter, otherwise by the `event.code` letter; digits / punctuation / function keys always
10
+ * by code), strict chord matching with the AltGraph refusal (a row containing Alt is never
11
+ * claimed by a real AltGr press), the column-aware consumer validation (merge = REPLACE per
12
+ * command; every invalid input is a RangeError — fail-fast on the API surface) and the O(1)
13
+ * runtime resolution of one keydown to a command.
14
+ *
15
+ * 再バインド可能コマンド表 (v0.8 ADR-17 — 計画 §1.5 / §2.6 / §4.1.11)。純関数・React 非依存:
16
+ * SpreadsheetCommand の語彙、正準チョード文法 (`Ctrl+Alt+Shift+KEY` — 使う修飾だけ・順序固定・
17
+ * Cmd = Ctrl)、正確な組込み行の単一表 (BUILTIN_ROWS — spec 04 §3.2 の非再バインド行全件 +
18
+ * 再バインド可コマンドの既定行全件)、そこから導出する定数 (DEFAULT_SHORTCUTS /
19
+ * SPREADSHEET_RESERVED_SHORTCUTS / EDITING_COLUMN_COMMANDS)、keydown をレイアウト非依存の KEY
20
+ * トークンへ写す VK 規則 (文字は `event.key` が単一ラテン文字ならそれ、そうでなければ
21
+ * `event.code` の文字。数字 / 記号 / 機能キーは常に code)、AltGraph 拒否つきの厳密チョード照合
22
+ * (Alt を含む行は実 AltGr 押下で決して成立しない)、列を意識した consumer 検証 (merge = コマンド毎
23
+ * REPLACE。無効入力は全て RangeError — API 面での fail-fast)、keydown 1 件の O(1) コマンド解決。
24
+ */
25
+
26
+ /**
27
+ * Every rebindable command of the grid (camelCase — the exact plan §1.5 list). The first block
28
+ * carries Google-Sheets-parity defaults; the extension block is unbound by default (`[]`) and
29
+ * exists so a consumer can bind it through `shortcuts`.
30
+ * グリッドの再バインド可能コマンド全件 (camelCase — 計画 §1.5 の正確な一覧)。前半は Google Sheets
31
+ * 同等の既定束縛、拡張ブロックは既定未束縛 (`[]`) で consumer が `shortcuts` で束縛するための存在。
32
+ */
33
+ export type SpreadsheetCommand =
34
+ // Sheets 既定束縛
35
+ | "bold"
36
+ | "italic"
37
+ | "underline"
38
+ | "strikethrough"
39
+ | "clearFormatting"
40
+ | "alignLeft"
41
+ | "alignCenter"
42
+ | "alignRight"
43
+ | "formatNumber"
44
+ | "formatTime"
45
+ | "formatDate"
46
+ | "formatCurrency"
47
+ | "formatPercent"
48
+ | "formatScientific"
49
+ | "bordersOuter"
50
+ | "bordersClear"
51
+ | "borderTop"
52
+ | "borderRight"
53
+ | "borderBottom"
54
+ | "borderLeft"
55
+ | "insertLink"
56
+ | "insertNote"
57
+ | "insertComment"
58
+ | "openHyperlink"
59
+ | "openValidationList"
60
+ | "pasteSpecial"
61
+ | "fillDown"
62
+ | "fillRight"
63
+ | "fillSelection"
64
+ | "insertDate"
65
+ | "insertTime"
66
+ | "hideRows"
67
+ | "hideColumns"
68
+ | "unhideRows"
69
+ | "unhideColumns"
70
+ // 拡張コマンド (既定 [] = 未束縛、再バインド可)
71
+ | "formatGeneral"
72
+ | "bordersAll"
73
+ | "pasteFormats"
74
+ | "pasteFormulas"
75
+ | "pasteAdd"
76
+ | "pasteSubtract"
77
+ | "pasteMultiply"
78
+ | "pasteDivide"
79
+ | "multiplyBy100"
80
+ | "multiplyBy1000"
81
+ | "divideBy100"
82
+ | "divideBy1000"
83
+ | "signReversal"
84
+ | "fillColorYellow"
85
+ | "fillColorBlue"
86
+ | "fillColorGreen"
87
+ | "fillColorRed"
88
+ | "fillColorCustom1"
89
+ | "fillColorCustom2"
90
+ | "fillColorCustom3"
91
+ | "fillColorCustom4"
92
+ | "fillColorCustom5"
93
+ | "fontColorYellow"
94
+ | "fontColorBlue"
95
+ | "fontColorGreen"
96
+ | "fontColorRed"
97
+ | "fontColorCustom1"
98
+ | "fontColorCustom2"
99
+ | "fontColorCustom3"
100
+ | "fontColorCustom4"
101
+ | "fontColorCustom5"
102
+ | "toggleRowHighlight"
103
+ | "toggleColumnHighlight"
104
+ | "toggleBothHighlight"
105
+ | "toggleIntersectionHighlight"
106
+
107
+ /**
108
+ * The command vocabulary as an ordered runtime list (declaration order = the order validation
109
+ * reports and DEFAULT_SHORTCUTS enumerates). Parity with the SpreadsheetCommand union is pinned
110
+ * both ways by the spec's non-distributive type guards.
111
+ * コマンド語彙の順序つき実行時一覧 (宣言順 = 検証の報告順 = DEFAULT_SHORTCUTS の列挙順)。
112
+ * SpreadsheetCommand union との一致は spec の非分配型ガードが両方向で固定。
113
+ */
114
+ export const SPREADSHEET_COMMANDS: readonly SpreadsheetCommand[] = [
115
+ "bold",
116
+ "italic",
117
+ "underline",
118
+ "strikethrough",
119
+ "clearFormatting",
120
+ "alignLeft",
121
+ "alignCenter",
122
+ "alignRight",
123
+ "formatNumber",
124
+ "formatTime",
125
+ "formatDate",
126
+ "formatCurrency",
127
+ "formatPercent",
128
+ "formatScientific",
129
+ "bordersOuter",
130
+ "bordersClear",
131
+ "borderTop",
132
+ "borderRight",
133
+ "borderBottom",
134
+ "borderLeft",
135
+ "insertLink",
136
+ "insertNote",
137
+ "insertComment",
138
+ "openHyperlink",
139
+ "openValidationList",
140
+ "pasteSpecial",
141
+ "fillDown",
142
+ "fillRight",
143
+ "fillSelection",
144
+ "insertDate",
145
+ "insertTime",
146
+ "hideRows",
147
+ "hideColumns",
148
+ "unhideRows",
149
+ "unhideColumns",
150
+ "formatGeneral",
151
+ "bordersAll",
152
+ "pasteFormats",
153
+ "pasteFormulas",
154
+ "pasteAdd",
155
+ "pasteSubtract",
156
+ "pasteMultiply",
157
+ "pasteDivide",
158
+ "multiplyBy100",
159
+ "multiplyBy1000",
160
+ "divideBy100",
161
+ "divideBy1000",
162
+ "signReversal",
163
+ "fillColorYellow",
164
+ "fillColorBlue",
165
+ "fillColorGreen",
166
+ "fillColorRed",
167
+ "fillColorCustom1",
168
+ "fillColorCustom2",
169
+ "fillColorCustom3",
170
+ "fillColorCustom4",
171
+ "fillColorCustom5",
172
+ "fontColorYellow",
173
+ "fontColorBlue",
174
+ "fontColorGreen",
175
+ "fontColorRed",
176
+ "fontColorCustom1",
177
+ "fontColorCustom2",
178
+ "fontColorCustom3",
179
+ "fontColorCustom4",
180
+ "fontColorCustom5",
181
+ "toggleRowHighlight",
182
+ "toggleColumnHighlight",
183
+ "toggleBothHighlight",
184
+ "toggleIntersectionHighlight",
185
+ ]
186
+
187
+ /** Command → canonical chords (empty = unbound; several chords allowed). / コマンド → 正準チョード群 (空 = 未束縛、複数可)。 */
188
+ export type ShortcutMap = { readonly [K in SpreadsheetCommand]: readonly string[] }
189
+
190
+ /**
191
+ * Consumer overrides: an array REPLACES the command's default list, `null` unbinds it, an absent
192
+ * key keeps the default. A single string is a RangeError (never silently wrapped).
193
+ * consumer の上書き: 配列はそのコマンドの既定一覧を置換、`null` は未束縛、キー不在は既定維持。
194
+ * 単一 string は RangeError (黙って 1 要素配列に包まない)。
195
+ */
196
+ export type ShortcutOverrides = { readonly [K in SpreadsheetCommand]?: readonly string[] | null }
197
+
198
+ /** Keymap column(s) a row is consulted in. / 行が参照されるキーマップ列。 */
199
+ export type BuiltinColumn = "idle" | "editing" | "both"
200
+
201
+ /**
202
+ * One row of the built-in table: an EXACT canonical chord (no families), the column(s) where the
203
+ * keymap has a branch for it, whether a consumer may take the chord (`rebindable: false` = the
204
+ * chord is reserved in that column), the command it fires (command rows only) and a
205
+ * documentation note.
206
+ * 組込み表の 1 行: 正確な正準チョード (族なし)、キーマップに分岐がある列、consumer が奪えるか
207
+ * (`rebindable: false` = その列で予約)、発火するコマンド (コマンド行のみ)、文書化用の一言。
208
+ */
209
+ export type BuiltinRow = { readonly chord: string; readonly column: BuiltinColumn; readonly rebindable: boolean; readonly command?: SpreadsheetCommand; readonly note: string }
210
+
211
+ /** A canonical chord decomposed: modifier flags + KEY token (`"A".."Z"` or a code name). / 正準チョードの分解形: 修飾フラグ + KEY トークン (`"A".."Z"` または code 名)。 */
212
+ export type ParsedChord = { ctrl: boolean; alt: boolean; shift: boolean; key: string }
213
+
214
+ /** Latin-letter KEY token (the VK rule resolves these layout-independently). / ラテン文字 KEY トークン (VK 規則がレイアウト非依存に解決)。 */
215
+ const LETTER_TOKEN = /^[A-Z]$/
216
+
217
+ /** Single Latin letter as reported by `event.key` (either case, ASCII only — `ö` / `и` / `µ` are NOT letters here). / `event.key` の単一ラテン文字 (大小いずれか・ASCII のみ — `ö` / `и` / `µ` は非該当)。 */
218
+ const LATIN_KEY = /^[A-Za-z]$/
219
+
220
+ /** `event.code` of a letter key (`KeyA`..`KeyZ`). / 文字キーの `event.code` (`KeyA`..`KeyZ`)。 */
221
+ const LETTER_CODE = /^Key([A-Z])$/
222
+
223
+ /**
224
+ * The closed vocabulary of non-letter KEY tokens = `KeyboardEvent.code` names of the digit,
225
+ * numpad, function, punctuation, navigation and editing keys. Digits are ALWAYS by code
226
+ * (`Digit0..9` / `Numpad0..9`) so a chord survives layouts whose digit row produces symbols
227
+ * (AZERTY); punctuation by code so `Ctrl+Semicolon` is the same physical key on DE (`ö`) and RU
228
+ * (`ж`). A token outside this set (or a lowercase letter, or a produced character such as `;`)
229
+ * is not canonical.
230
+ * 文字以外の KEY トークンの閉じた語彙 = 数字 / テンキー / 機能 / 記号 / ナビゲーション / 編集キーの
231
+ * `KeyboardEvent.code` 名。数字は常に code (`Digit0..9` / `Numpad0..9`) — 数字行が記号を生む
232
+ * レイアウト (AZERTY) でもチョードが生き残る。記号も code — `Ctrl+Semicolon` は DE (`ö`) / RU
233
+ * (`ж`) でも同じ物理キー。集合外のトークン (小文字・`;` 等の生成文字を含む) は非正準。
234
+ */
235
+ const KEY_CODE_TOKENS: ReadonlySet<string> = new Set([
236
+ ...Array.from({ length: 10 }, (_, i) => `Digit${i}`),
237
+ ...Array.from({ length: 10 }, (_, i) => `Numpad${i}`),
238
+ "NumpadAdd",
239
+ "NumpadSubtract",
240
+ "NumpadMultiply",
241
+ "NumpadDivide",
242
+ "NumpadDecimal",
243
+ "NumpadEnter",
244
+ ...Array.from({ length: 12 }, (_, i) => `F${i + 1}`),
245
+ "Semicolon",
246
+ "Quote",
247
+ "Comma",
248
+ "Period",
249
+ "Slash",
250
+ "Backslash",
251
+ "BracketLeft",
252
+ "BracketRight",
253
+ "Backquote",
254
+ "Minus",
255
+ "Equal",
256
+ "IntlYen",
257
+ "IntlRo",
258
+ "IntlBackslash",
259
+ "ArrowUp",
260
+ "ArrowDown",
261
+ "ArrowLeft",
262
+ "ArrowRight",
263
+ "Home",
264
+ "End",
265
+ "PageUp",
266
+ "PageDown",
267
+ "Enter",
268
+ "Escape",
269
+ "Space",
270
+ "Tab",
271
+ "Delete",
272
+ "Backspace",
273
+ "Insert",
274
+ "ContextMenu",
275
+ ])
276
+
277
+ /** True for a canonical KEY token (`A`..`Z` or a member of KEY_CODE_TOKENS). / 正準 KEY トークンなら true (`A`..`Z` または KEY_CODE_TOKENS の要素)。 */
278
+ const isKeyToken = (token: string): boolean => LETTER_TOKEN.test(token) || KEY_CODE_TOKENS.has(token)
279
+
280
+ /**
281
+ * Parses a chord in canonical spelling ONLY — `[Ctrl+][Alt+][Shift+]KEY`, this order, each
282
+ * modifier at most once, KEY a canonical token. Anything else (lowercase, `Cmd`, a reordered or
283
+ * repeated modifier, a produced character, a trailing `+`, whitespace) is a RangeError: the
284
+ * grammar has exactly one spelling per chord so table keys compare as strings. A bare KEY is
285
+ * accepted here (built-in rows such as `F2` are modifier-less); the consumer-facing modifier
286
+ * requirement lives in validateShortcuts.
287
+ * 正準綴りのみのチョード解析 — `[Ctrl+][Alt+][Shift+]KEY` (この順・各修飾は高々 1 回・KEY は正準
288
+ * トークン)。それ以外 (小文字・`Cmd`・順序違い / 重複修飾・生成文字・末尾 `+`・空白) は RangeError:
289
+ * 文法はチョード 1 つに綴り 1 つで、表のキーが文字列比較で済む。素の KEY はここでは受理 (`F2`
290
+ * 等の組込み行は修飾なし) — consumer 向けの修飾必須は validateShortcuts が持つ。
291
+ */
292
+ export const parseChord = (chord: string): ParsedChord => {
293
+ const parts = chord.split("+")
294
+ let index = 0
295
+ // 修飾は Ctrl → Alt → Shift の固定順で、それぞれ高々 1 回だけ消費する
296
+ const ctrl = parts[index] === "Ctrl"
297
+ if (ctrl) {
298
+ index += 1
299
+ }
300
+ const alt = parts[index] === "Alt"
301
+ if (alt) {
302
+ index += 1
303
+ }
304
+ const shift = parts[index] === "Shift"
305
+ if (shift) {
306
+ index += 1
307
+ }
308
+ // 残りは KEY トークン 1 個だけ (余剰 / 不足 / 順序違いの修飾は全てここで落ちる)
309
+ const key = parts[index]
310
+ if (index !== parts.length - 1 || key === undefined || !isKeyToken(key)) {
311
+ throw new RangeError(`[spreadsheet] shortcut ${JSON.stringify(chord)} is not canonical (expected Ctrl+Alt+Shift+KEY with used modifiers only, KEY = A-Z or a code name such as Digit1 / Semicolon / F2)`)
312
+ }
313
+ return { ctrl, alt, shift, key }
314
+ }
315
+
316
+ /** Formats a ParsedChord back to its canonical spelling (the exact inverse of parseChord). / ParsedChord を正準綴りへ戻す (parseChord の正確な逆写像)。 */
317
+ export const formatChord = (parsed: ParsedChord): string => `${parsed.ctrl ? "Ctrl+" : ""}${parsed.alt ? "Alt+" : ""}${parsed.shift ? "Shift+" : ""}${parsed.key}`
318
+
319
+ /**
320
+ * The VK rule (plan §2.6): maps one keydown to a KEY token independently of the layout.
321
+ * (a) `event.key` is a single Latin letter (any modifiers) ⇒ that letter upper-cased — so
322
+ * AZERTY's `Ctrl+Alt` + `{ key: "m", code: "Semicolon" }` is `M`; (b) otherwise `event.code` is
323
+ * `Key*` ⇒ the code letter — DE AltGr `µ`, RU `и`, macOS Option `∂` / `Dead` all resolve to the
324
+ * physical letter; (c) otherwise a code in the closed vocabulary ⇒ that code (digits, punctuation,
325
+ * function / navigation keys — always physical); (d) otherwise null (a modifier key, `Process`,
326
+ * `Unidentified`, IME keys).
327
+ * VK 規則 (計画 §2.6): keydown 1 件をレイアウト非依存の KEY トークンへ写す。(a) `event.key` が単一
328
+ * ラテン文字 (修飾の有無を問わない) ⇒ 大文字化 — AZERTY の `Ctrl+Alt` + `{ key: "m", code:
329
+ * "Semicolon" }` は `M`。(b) それ以外で `event.code` が `Key*` ⇒ その文字 — DE AltGr `µ` / RU `и` /
330
+ * macOS Option `∂` / `Dead` は物理文字へ。(c) それ以外で閉じた語彙内の code ⇒ その code (数字 /
331
+ * 記号 / 機能 / ナビゲーション — 常に物理)。(d) それ以外 null (修飾キー・`Process`・`Unidentified`・
332
+ * IME キー)。
333
+ */
334
+ export const resolveKeyToken = (event: KeyboardEvent): string | null => {
335
+ // (a) 単一ラテン文字はレイアウトの生成文字を信頼する (AZERTY の M は Semicolon 位置)
336
+ if (LATIN_KEY.test(event.key)) {
337
+ return event.key.toUpperCase()
338
+ }
339
+ // (b) 非ラテン生成文字 (AltGr 記号 / キリル / macOS Option / dead key) は物理文字キーで解決
340
+ const letter = LETTER_CODE.exec(event.code)
341
+ if (letter !== null) {
342
+ return letter[1]
343
+ }
344
+ // (c) 数字 / 記号 / 機能キーは常に code そのもの
345
+ if (KEY_CODE_TOKENS.has(event.code)) {
346
+ return event.code
347
+ }
348
+ // (d) 修飾キー単独・IME 処理キー・未知の code
349
+ return null
350
+ }
351
+
352
+ /**
353
+ * Strict match of one keydown against a parsed chord: `ctrl = ctrlKey || metaKey` (Cmd = Ctrl),
354
+ * `alt`, `shift` must all equal the row's flags; the KEY token comes from resolveKeyToken. A row
355
+ * containing Alt is refused when `event.getModifierState("AltGraph")` is true — a real AltGr press
356
+ * (DE `AltGr+M` = `µ`) is character input, never a command — while the legacy ctrl+alt emulation
357
+ * shape (same key / code, AltGraph false) resolves through the code letter.
358
+ * keydown 1 件と解析済みチョードの厳密照合: `ctrl = ctrlKey || metaKey` (Cmd = Ctrl)・`alt`・`shift`
359
+ * が行のフラグと全て等しく、KEY トークンは resolveKeyToken。Alt を含む行は
360
+ * `event.getModifierState("AltGraph")` が true なら不成立 — 実 AltGr 押下 (DE `AltGr+M` = `µ`) は
361
+ * 文字入力であってコマンドではない。レガシーな ctrl+alt エミュレーション形状 (同じ key / code、
362
+ * AltGraph false) は code 文字で解決する。
363
+ */
364
+ export const matchChord = (parsed: ParsedChord, event: KeyboardEvent): boolean => {
365
+ // 修飾は厳密一致 (Cmd は Ctrl と同一視)
366
+ if (parsed.ctrl !== (event.ctrlKey || event.metaKey) || parsed.alt !== event.altKey || parsed.shift !== event.shiftKey) {
367
+ return false
368
+ }
369
+ // Alt 行だけ AltGraph を拒否 (Ctrl+B 行は AltGraph true でも成立する)
370
+ if (parsed.alt && event.getModifierState("AltGraph")) {
371
+ return false
372
+ }
373
+ return resolveKeyToken(event) === parsed.key
374
+ }
375
+
376
+ /** Builds a non-rebindable built-in row. / 非再バインド組込み行を作る。 */
377
+ const builtin = (chord: string, column: BuiltinColumn, note: string): BuiltinRow => ({ chord, column, rebindable: false, note })
378
+
379
+ /** Builds a rebindable command default row. / 再バインド可コマンド既定行を作る。 */
380
+ const command = (name: SpreadsheetCommand, chord: string, column: BuiltinColumn, note: string): BuiltinRow => ({ chord, column, rebindable: true, command: name, note })
381
+
382
+ /** Arrow-family rows: every actual modifier row of spec 04 §3.2 (bare / Shift / Ctrl / Ctrl+Shift × 4 directions). / Arrow 族の行: spec 04 §3.2 に実在する修飾行 (素 / Shift / Ctrl / Ctrl+Shift × 4 方向)。 */
383
+ const arrowRows = (): readonly BuiltinRow[] => {
384
+ const rows: BuiltinRow[] = []
385
+ for (const arrow of ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"]) {
386
+ rows.push(builtin(arrow, "both", "move active (idle) / commit + move in replace mode, caret in edit mode, reference cursor while pointing"))
387
+ rows.push(builtin(`Shift+${arrow}`, "both", "extend selection (idle) / commit + extend in replace mode, text selection in edit mode"))
388
+ rows.push(builtin(`Ctrl+${arrow}`, "both", "data-edge jump (idle) / native word caret (editing), Ctrl reference cursor while pointing"))
389
+ rows.push(builtin(`Ctrl+Shift+${arrow}`, "both", "extend to data edge (idle) / consume + no-op (editing)"))
390
+ }
391
+ return rows
392
+ }
393
+
394
+ /** Zoom token space: `Ctrl+Alt` AND `Ctrl+Alt+Shift` × 6 (Shift-independent — the root capture listener matches the produced character). / ズームトークン空間: `Ctrl+Alt` と `Ctrl+Alt+Shift` × 6 (Shift 非依存 — root capture リスナーは生成文字で照合)。 */
395
+ const zoomRows = (): readonly BuiltinRow[] => {
396
+ const rows: BuiltinRow[] = []
397
+ for (const token of ["Equal", "Minus", "Digit0", "NumpadAdd", "NumpadSubtract", "Numpad0"]) {
398
+ rows.push(builtin(`Ctrl+Alt+${token}`, "both", "zoom token space (root capture listener, produced-character matching — v0.3.3 contract)"))
399
+ rows.push(builtin(`Ctrl+Alt+Shift+${token}`, "both", "zoom token space, Shift-independent (JIS `=` is Shift+Minus, AZERTY `0` is Shift+Digit0)"))
400
+ }
401
+ return rows
402
+ }
403
+
404
+ /**
405
+ * ONE table of EXACT chords (no families): every non-rebindable built-in row of spec 04 §3.2 —
406
+ * Arrow / Home / End / Page* per actual modifier row, Delete, Backspace, Escape, Tab, Shift+Tab,
407
+ * Enter, Shift+Enter, the editing Alt+Enter newline, F2, F4 (editing-column anchor cycling),
408
+ * Ctrl+A, Ctrl+Z / Y, Ctrl+Shift+Z, Ctrl / Shift+Space, Shift+F10, ContextMenu, the native-chord
409
+ * rows Ctrl+C / X / V and Ctrl+Shift+V (pasteValues arms paste options only), Ctrl+Alt+V =
410
+ * pasteSpecial (a command row that is NOT rebindable) and the zoom space Ctrl+Alt AND
411
+ * Ctrl+Alt+Shift × {Equal, Minus, Digit0, NumpadAdd, NumpadSubtract, Numpad0} — plus every
412
+ * rebindable command row (command set; its chords are the defaults). `column` = where the keymap
413
+ * has a branch for the chord (a native passthrough in a column is NOT a row of that column, so
414
+ * Home / End / Delete / Ctrl+A / Z / Y are idle-only while Arrow / Enter / Tab / Page / Escape
415
+ * are both). Every derived constant below reads this table.
416
+ * 正確なチョードだけの単一表 (族なし): spec 04 §3.2 の非再バインド組込み行全件 — Arrow / Home /
417
+ * End / Page* の実在修飾行、Delete、Backspace、Escape、Tab、Shift+Tab、Enter、Shift+Enter、編集列の
418
+ * Alt+Enter 改行、F2、F4 (編集列の $ 循環)、Ctrl+A、Ctrl+Z / Y、Ctrl+Shift+Z、Ctrl / Shift+Space、
419
+ * Shift+F10、ContextMenu、ネイティブチョード行 Ctrl+C / X / V と Ctrl+Shift+V (pasteValues は貼付
420
+ * オプション武装のみ)、Ctrl+Alt+V = pasteSpecial (非再バインドのコマンド行)、ズーム空間 Ctrl+Alt
421
+ * および Ctrl+Alt+Shift × {Equal, Minus, Digit0, NumpadAdd, NumpadSubtract, Numpad0} — に加え、
422
+ * 再バインド可コマンド行全件 (command あり。そのチョードが既定)。`column` = キーマップに分岐が
423
+ * ある列 (ある列でのネイティブ素通しはその列の行ではない — Home / End / Delete / Ctrl+A / Z / Y
424
+ * は idle 限定、Arrow / Enter / Tab / Page / Escape は both)。以下の導出定数は全てこの表を読む。
425
+ */
426
+ export const BUILTIN_ROWS: readonly BuiltinRow[] = [
427
+ ...arrowRows(),
428
+ builtin("Home", "idle", "row start (first visible column)"),
429
+ builtin("Ctrl+Home", "idle", "first visible cell"),
430
+ builtin("Shift+Home", "idle", "extend to row start"),
431
+ builtin("Ctrl+Shift+Home", "idle", "extend to first visible cell"),
432
+ builtin("End", "idle", "row end (last visible column)"),
433
+ builtin("Ctrl+End", "idle", "last visible cell"),
434
+ builtin("Shift+End", "idle", "extend to row end"),
435
+ builtin("Ctrl+Shift+End", "idle", "extend to last visible cell"),
436
+ builtin("PageUp", "both", "page up (idle) / commit + page up (editing)"),
437
+ builtin("PageDown", "both", "page down (idle) / commit + page down (editing)"),
438
+ builtin("Shift+PageUp", "idle", "extend one page up"),
439
+ builtin("Shift+PageDown", "idle", "extend one page down"),
440
+ builtin("Ctrl+Shift+PageUp", "idle", "consume + no-op (sheet-switch class)"),
441
+ builtin("Ctrl+Shift+PageDown", "idle", "consume + no-op (sheet-switch class)"),
442
+ builtin("Tab", "both", "cycle right (idle) / commit + cycle right (editing)"),
443
+ builtin("Shift+Tab", "both", "cycle left (idle) / commit + cycle left (editing)"),
444
+ builtin("Enter", "both", "cycle down (idle) / commit + cycle down (editing)"),
445
+ builtin("Shift+Enter", "both", "cycle up (idle) / commit + cycle up (editing)"),
446
+ builtin("Alt+Enter", "editing", "newline at the caret (editing column only — coexists with the idle openHyperlink default)"),
447
+ builtin("F2", "both", "start editing (idle) / toggle the pointing arm (editing)"),
448
+ builtin("F4", "editing", "cycle the reference anchor under the caret (formula session)"),
449
+ builtin("Delete", "idle", "clear every selected cell"),
450
+ builtin("Backspace", "idle", "clear every selected cell"),
451
+ builtin("Escape", "both", "abort drag > hide note tip > close rejected message > discard clip > hand off focus (idle) / cancel editing"),
452
+ builtin("Ctrl+A", "idle", "select all"),
453
+ builtin("Ctrl+Z", "idle", "undo request"),
454
+ builtin("Ctrl+Y", "idle", "redo request"),
455
+ builtin("Ctrl+Shift+Z", "idle", "redo request"),
456
+ builtin("Shift+F10", "idle", "keyboard context menu"),
457
+ builtin("ContextMenu", "idle", "keyboard context menu"),
458
+ builtin("Ctrl+Space", "idle", "column bands"),
459
+ builtin("Shift+Space", "idle", "row bands"),
460
+ builtin("Ctrl+C", "idle", "native copy (ClipboardEvent path — never preventDefault)"),
461
+ builtin("Ctrl+X", "idle", "native cut (ClipboardEvent path — never preventDefault)"),
462
+ builtin("Ctrl+V", "idle", "native paste (ClipboardEvent path — never preventDefault)"),
463
+ builtin("Ctrl+Shift+V", "idle", "pasteValues: arms the paste options, the native paste follows (not a command)"),
464
+ { chord: "Ctrl+Alt+V", column: "idle", rebindable: false, command: "pasteSpecial", note: "paste special (onPasteSpecialRequest / formats-only branch) — a command row that cannot be rebound" },
465
+ ...zoomRows(),
466
+ command("bold", "Ctrl+B", "both", "toggle bold"),
467
+ command("italic", "Ctrl+I", "both", "toggle italic"),
468
+ command("underline", "Ctrl+U", "both", "toggle underline"),
469
+ command("strikethrough", "Alt+Shift+Digit5", "both", "toggle strikethrough"),
470
+ command("clearFormatting", "Ctrl+Backslash", "idle", "clear formatting"),
471
+ command("clearFormatting", "Ctrl+IntlYen", "idle", "clear formatting (JIS ¥ key)"),
472
+ command("clearFormatting", "Ctrl+IntlRo", "idle", "clear formatting (JIS ろ key)"),
473
+ command("alignLeft", "Ctrl+Shift+L", "both", "align left"),
474
+ command("alignCenter", "Ctrl+Shift+E", "both", "align center"),
475
+ command("alignRight", "Ctrl+Shift+R", "both", "align right"),
476
+ command("formatNumber", "Ctrl+Shift+Digit1", "both", "number format"),
477
+ command("formatTime", "Ctrl+Shift+Digit2", "both", "time format"),
478
+ command("formatDate", "Ctrl+Shift+Digit3", "both", "date format"),
479
+ command("formatCurrency", "Ctrl+Shift+Digit4", "both", "currency format"),
480
+ command("formatPercent", "Ctrl+Shift+Digit5", "both", "percent format"),
481
+ command("formatScientific", "Ctrl+Shift+Digit6", "both", "scientific format"),
482
+ command("bordersOuter", "Alt+Shift+Digit7", "idle", "outer borders"),
483
+ command("bordersClear", "Alt+Shift+Digit6", "idle", "clear borders"),
484
+ command("borderTop", "Alt+Shift+Digit1", "idle", "top border"),
485
+ command("borderRight", "Alt+Shift+Digit2", "idle", "right border"),
486
+ command("borderBottom", "Alt+Shift+Digit3", "idle", "bottom border"),
487
+ command("borderLeft", "Alt+Shift+Digit4", "idle", "left border"),
488
+ command("insertLink", "Ctrl+K", "idle", "insert / edit hyperlink"),
489
+ command("insertNote", "Shift+F2", "idle", "insert / edit note"),
490
+ command("insertComment", "Ctrl+Alt+M", "idle", "insert comment"),
491
+ command("openHyperlink", "Alt+Enter", "idle", "open the hyperlink (idle column — the editing Alt+Enter row is a different column)"),
492
+ command("openValidationList", "Alt+ArrowDown", "idle", "open the validation list"),
493
+ command("fillDown", "Ctrl+D", "idle", "fill down"),
494
+ command("fillRight", "Ctrl+R", "idle", "fill right"),
495
+ command("fillSelection", "Ctrl+Enter", "both", "fill the selection with the active / session text"),
496
+ command("insertDate", "Ctrl+Semicolon", "both", "insert today's date (active cell / caret)"),
497
+ command("insertTime", "Ctrl+Shift+Semicolon", "both", "insert the current time (active cell / caret)"),
498
+ command("hideRows", "Ctrl+Alt+Digit9", "idle", "hide the selected rows"),
499
+ command("unhideRows", "Ctrl+Shift+Digit9", "idle", "unhide rows"),
500
+ command("unhideColumns", "Ctrl+Shift+Digit0", "idle", "unhide columns"),
501
+ ]
502
+
503
+ /** True when a row is consulted in the given column (`both` covers idle and editing). / 行が指定列で参照されるなら true (`both` は idle と編集の両方)。 */
504
+ const rowInColumn = (row: BuiltinRow, column: "idle" | "editing"): boolean => row.column === "both" || row.column === column
505
+
506
+ /**
507
+ * Reserved chords for EVERY command: the exact chords of `rebindable: false` rows consulted in
508
+ * the idle column (`"idle" | "both"` — every command is consulted there). Editing-only rows
509
+ * (`Alt+Enter`, `F4`) are not in this list: they bind only EDITING_COLUMN_COMMANDS, which
510
+ * validateShortcuts checks column-aware. Families are NOT reserved — `Alt+ArrowDown`, `Alt+Enter`
511
+ * (idle), `Ctrl+Enter` and `Shift+F2` are valid defaults.
512
+ * 全コマンドに対する予約チョード: idle 列で参照される `rebindable: false` 行 (`"idle" | "both"` —
513
+ * 全コマンドが idle 列で参照される) の正確なチョード。編集列限定行 (`Alt+Enter`、`F4`) は含まない
514
+ * — EDITING_COLUMN_COMMANDS にだけ効き、validateShortcuts が列を見て検査する。族は予約しない —
515
+ * `Alt+ArrowDown` / `Alt+Enter` (idle) / `Ctrl+Enter` / `Shift+F2` は正当な既定。
516
+ */
517
+ export const SPREADSHEET_RESERVED_SHORTCUTS: readonly string[] = Array.from(new Set(BUILTIN_ROWS.filter((row) => !row.rebindable && rowInColumn(row, "idle")).map((row) => row.chord)))
518
+
519
+ /**
520
+ * Commands consulted in the editing column as well as idle (every command row whose column is
521
+ * `editing` or `both`): fillSelection, insertDate, insertTime, bold, italic, underline,
522
+ * strikethrough, alignLeft / Center / Right, formatNumber .. formatScientific.
523
+ * idle に加え編集列でも参照されるコマンド (column が `editing` / `both` のコマンド行全件):
524
+ * fillSelection、insertDate、insertTime、bold、italic、underline、strikethrough、alignLeft /
525
+ * Center / Right、formatNumber .. formatScientific。
526
+ */
527
+ export const EDITING_COLUMN_COMMANDS: ReadonlySet<SpreadsheetCommand> = new Set(BUILTIN_ROWS.filter((row) => row.command !== undefined && rowInColumn(row, "editing")).map((row) => row.command as SpreadsheetCommand))
528
+
529
+ /** Commands whose row is `rebindable: false` (`pasteSpecial`) — any override is a RangeError. / 行が `rebindable: false` のコマンド (`pasteSpecial`) — 上書きは全て RangeError。 */
530
+ const NON_REBINDABLE_COMMANDS: ReadonlySet<SpreadsheetCommand> = new Set(BUILTIN_ROWS.filter((row) => row.command !== undefined && !row.rebindable).map((row) => row.command as SpreadsheetCommand))
531
+
532
+ /** Builds a complete ShortcutMap from a per-command chord reader. / コマンド毎のチョード reader から完全な ShortcutMap を組む。 */
533
+ const buildShortcutMap = (chordsOf: (name: SpreadsheetCommand) => readonly string[]): ShortcutMap => {
534
+ const map: Partial<Record<SpreadsheetCommand, readonly string[]>> = {}
535
+ for (const name of SPREADSHEET_COMMANDS) {
536
+ map[name] = chordsOf(name)
537
+ }
538
+ return map as ShortcutMap
539
+ }
540
+
541
+ /**
542
+ * Default chords per command, derived from the `command === K` rows of BUILTIN_ROWS in table
543
+ * order (`[]` = unbound by default: hideColumns and every extension command). Validates under
544
+ * validateShortcuts(undefined) — pinned by the spec.
545
+ * コマンド毎の既定チョード — BUILTIN_ROWS の `command === K` 行から表順で導出 (`[]` = 既定未束縛:
546
+ * hideColumns と拡張コマンド全件)。validateShortcuts(undefined) を通ることを spec が固定。
547
+ */
548
+ export const DEFAULT_SHORTCUTS: ShortcutMap = buildShortcutMap((name) => BUILTIN_ROWS.filter((row) => row.command === name).map((row) => row.chord))
549
+
550
+ /**
551
+ * Chord census of a (partial) shortcut map: every chord that appears → the DISTINCT commands
552
+ * bound to it, in SPREADSHEET_COMMANDS order. `null` / absent entries contribute nothing; a chord
553
+ * repeated inside one command counts once. An entry with two or more commands is a duplicate
554
+ * binding (validateShortcuts condition 5); the spec pins DEFAULT_SHORTCUTS to have none.
555
+ * (部分) ショートカット表のチョード census: 出現する各チョード → 束縛された相異なるコマンド群
556
+ * (SPREADSHEET_COMMANDS 順)。`null` / 不在の項は寄与せず、同一コマンド内の重複は 1 回に数える。
557
+ * 2 コマンド以上の項が重複束縛 (validateShortcuts 条件 5)。DEFAULT_SHORTCUTS に重複がないことを
558
+ * spec が固定。
559
+ */
560
+ export const censusChords = (map: ShortcutOverrides): ReadonlyMap<string, readonly SpreadsheetCommand[]> => {
561
+ const census = new Map<string, SpreadsheetCommand[]>()
562
+ for (const name of SPREADSHEET_COMMANDS) {
563
+ const chords = map[name]
564
+ // null / 不在は未束縛 = census に寄与しない
565
+ if (chords === null || chords === undefined) {
566
+ continue
567
+ }
568
+ for (const chord of chords) {
569
+ const bound = census.get(chord)
570
+ if (bound === undefined) {
571
+ census.set(chord, [name])
572
+ } else if (!bound.includes(name)) {
573
+ // 同一コマンド内の重複は 1 回 (別コマンドだけが重複束縛)
574
+ bound.push(name)
575
+ }
576
+ }
577
+ }
578
+ return census
579
+ }
580
+
581
+ /**
582
+ * Per-key shape validation of the overrides object (conditions 3 and the non-rebindable row):
583
+ * unknown command key, a value that is neither an array nor null (a single string is the
584
+ * documented trap — never wrapped), a non-string element, or any override of a non-rebindable
585
+ * command row (`pasteSpecial`) ⇒ RangeError. `undefined` values are "not overridden".
586
+ * overrides オブジェクトのキー毎の形検査 (条件 3 と非再バインド行): 未知のコマンドキー、配列でも
587
+ * null でもない値 (単一 string が文書化された罠 — 包まない)、非 string 要素、非再バインドの
588
+ * コマンド行 (`pasteSpecial`) への上書き ⇒ RangeError。`undefined` は「上書きなし」。
589
+ */
590
+ const validateOverrideShape = (overrides: ShortcutOverrides): void => {
591
+ const known: ReadonlySet<string> = new Set(SPREADSHEET_COMMANDS)
592
+ for (const key of Object.keys(overrides)) {
593
+ // 型で守られていても実行時の綴り違い (blod 等) は黙って無視せず fail-fast
594
+ if (!known.has(key)) {
595
+ throw new RangeError(`[spreadsheet] shortcuts.${key} is not a SpreadsheetCommand`)
596
+ }
597
+ const value = overrides[key as SpreadsheetCommand]
598
+ if (value === undefined) {
599
+ continue
600
+ }
601
+ // 非再バインドのコマンド行は null でも配列でも上書き不可
602
+ if (NON_REBINDABLE_COMMANDS.has(key as SpreadsheetCommand)) {
603
+ throw new RangeError(`[spreadsheet] shortcuts.${key} is not rebindable`)
604
+ }
605
+ if (value === null) {
606
+ continue
607
+ }
608
+ // 単一 string を 1 要素配列へ包まない (配列か null のみ受理)
609
+ if (!Array.isArray(value)) {
610
+ throw new RangeError(`[spreadsheet] shortcuts.${key} must be an array or null`)
611
+ }
612
+ for (const chord of value) {
613
+ if (typeof chord !== "string") {
614
+ throw new RangeError(`[spreadsheet] shortcuts.${key} must be an array of chord strings`)
615
+ }
616
+ }
617
+ }
618
+ }
619
+
620
+ /**
621
+ * Per-chord validation for one command (conditions 1, 2 and 4): the chord must parse
622
+ * canonically, carry at least one modifier, and must not equal a `rebindable: false` row in ANY
623
+ * column where the command is consulted (idle for every command, plus editing for
624
+ * EDITING_COLUMN_COMMANDS) — a row that fires the command itself (`pasteSpecial`'s own row) is not
625
+ * a collision.
626
+ * コマンド 1 件のチョード毎検査 (条件 1・2・4): 正準に解析でき、修飾を 1 つ以上持ち、コマンドが
627
+ * 参照されるいずれかの列 (全コマンド = idle、EDITING_COLUMN_COMMANDS は編集列も) に存在する
628
+ * `rebindable: false` 行と等しくないこと — そのコマンド自身を発火する行 (`pasteSpecial` 自身の行)
629
+ * は衝突ではない。
630
+ */
631
+ const validateCommandChord = (name: SpreadsheetCommand, chord: string): void => {
632
+ const parsed = parseChord(chord)
633
+ if (!(parsed.ctrl || parsed.alt || parsed.shift)) {
634
+ throw new RangeError(`[spreadsheet] shortcut ${chord} for ${name} has no modifier`)
635
+ }
636
+ // 列を意識した予約検査: コマンドが参照される列にある非再バインド行だけが衝突
637
+ const editing = EDITING_COLUMN_COMMANDS.has(name)
638
+ for (const row of BUILTIN_ROWS) {
639
+ if (row.rebindable || row.command === name || row.chord !== chord) {
640
+ continue
641
+ }
642
+ if (rowInColumn(row, "idle") || (editing && rowInColumn(row, "editing"))) {
643
+ throw new RangeError(`[spreadsheet] shortcut ${chord} for ${name} is reserved by a built-in row (${row.column} column: ${row.note})`)
644
+ }
645
+ }
646
+ }
647
+
648
+ /**
649
+ * Validates consumer overrides and returns the merged chord → command table. Merge = REPLACE per
650
+ * command over DEFAULT_SHORTCUTS (`null` / `[]` unbind; an array replaces the default list; an
651
+ * absent key keeps the default). RangeError, in this order: (3) a value that is a single string
652
+ * (or otherwise not an array / null), an unknown key, or an override of the non-rebindable
653
+ * `pasteSpecial`; (1) a non-canonical spelling; (2) a modifier-less chord; (4) a chord equal to a
654
+ * non-rebindable row in a column where the command is consulted (column-aware: `openHyperlink:
655
+ * ["Alt+Enter"]` is legal, `bold: ["Alt+Enter"]` collides with the editing newline row,
656
+ * `insertComment: ["Ctrl+Alt+V"]` collides with the pasteSpecial row, `Ctrl+Alt+Shift+Equal` is
657
+ * zoom space); (5) one chord bound to two commands after the merge. `undefined` validates the
658
+ * defaults themselves.
659
+ * consumer の上書きを検証し、merge 済みのチョード → コマンド表を返す。merge = DEFAULT_SHORTCUTS への
660
+ * コマンド毎 REPLACE (`null` / `[]` は未束縛、配列は既定一覧の置換、キー不在は既定維持)。RangeError
661
+ * はこの順: (3) 単一 string (または配列 / null 以外) の値・未知キー・非再バインド `pasteSpecial` の
662
+ * 上書き、(1) 非正準綴り、(2) 修飾なし、(4) コマンドが参照される列の非再バインド行と等しい
663
+ * (列を意識: `openHyperlink: ["Alt+Enter"]` は合法、`bold: ["Alt+Enter"]` は編集列の改行行と衝突、
664
+ * `insertComment: ["Ctrl+Alt+V"]` は pasteSpecial 行と衝突、`Ctrl+Alt+Shift+Equal` はズーム空間)、
665
+ * (5) merge 後に 1 チョードが 2 コマンドへ束縛。`undefined` は既定自身の検証。
666
+ */
667
+ export const validateShortcuts = (overrides: ShortcutOverrides | undefined): ReadonlyMap<string, SpreadsheetCommand> => {
668
+ if (overrides !== undefined) {
669
+ validateOverrideShape(overrides)
670
+ }
671
+ // merge = REPLACE: 上書きされたコマンドは既定一覧を丸ごと置換 (union ではない)
672
+ const merged = buildShortcutMap((name) => {
673
+ const value = overrides === undefined ? undefined : overrides[name]
674
+ if (value === undefined) {
675
+ return DEFAULT_SHORTCUTS[name]
676
+ }
677
+ return value === null ? [] : value
678
+ })
679
+ // 条件 1・2・4 をコマンド順・チョード順に検査
680
+ for (const name of SPREADSHEET_COMMANDS) {
681
+ for (const chord of merged[name]) {
682
+ validateCommandChord(name, chord)
683
+ }
684
+ }
685
+ // 条件 5: merge 後の重複束縛 (census の 2 コマンド以上の項)
686
+ const table = new Map<string, SpreadsheetCommand>()
687
+ for (const [chord, commands] of censusChords(merged)) {
688
+ if (commands.length > 1) {
689
+ throw new RangeError(`[spreadsheet] shortcut ${chord} bound to ${commands.slice(0, -1).join(", ")} and ${commands[commands.length - 1]}`)
690
+ }
691
+ table.set(chord, commands[0])
692
+ }
693
+ return table
694
+ }
695
+
696
+ /**
697
+ * Runtime resolution for one keydown: the command whose (merged) chord matches, or null. O(1) —
698
+ * the event is folded to its canonical chord through the VK rule and looked up; an Alt chord
699
+ * under a real AltGr press (`getModifierState("AltGraph")`) is never claimed. Equivalent to
700
+ * "some table entry satisfies matchChord" because the grammar has one spelling per chord.
701
+ * keydown 1 件のコマンド解決: (merge 済み) チョードが一致するコマンド、なければ null。O(1) —
702
+ * event を VK 規則で正準チョードへ畳んで引く。実 AltGr 押下 (`getModifierState("AltGraph")`) の
703
+ * Alt チョードは決して請求しない。文法がチョード 1 つに綴り 1 つなので「表のいずれかの項が
704
+ * matchChord を満たす」と等価。
705
+ */
706
+ export const resolveCommand = (table: ReadonlyMap<string, SpreadsheetCommand>, event: KeyboardEvent): SpreadsheetCommand | null => {
707
+ const key = resolveKeyToken(event)
708
+ if (key === null) {
709
+ return null
710
+ }
711
+ const alt = event.altKey
712
+ // 実 AltGr 押下は文字入力 — Alt チョードを請求しない (matchChord と同じ拒否)
713
+ if (alt && event.getModifierState("AltGraph")) {
714
+ return null
715
+ }
716
+ const found = table.get(formatChord({ ctrl: event.ctrlKey || event.metaKey, alt, shift: event.shiftKey, key }))
717
+ return found === undefined ? null : found
718
+ }