@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,687 @@
1
+ /**
2
+ * @fileoverview Pointer state machine for cell selection (§6.4.3): single body listener,
3
+ * mouse-only, contextmenu delivery, rAF-coalesced drag rect, one setState per press. A
4
+ * Ctrl-press on a selected cell enters SUBTRACT mode (§3.2): the press writes nothing, the
5
+ * drag rect is styled via the getDragMode reader relay, and the pointerup confirms one
6
+ * reduceSelectionSubtract. The sink is structurally out of reach (overlay sibling of the
7
+ * body — §8.1 forbids unobservable guards, so no sink-exclusion code exists here). Produces
8
+ * isDragging/getDragMode/abortDrag for the keyboard Escape split (§6.6.2). Listeners are
9
+ * attached ONCE and read live params through a ref (paramsRef pattern) — effect re-runs
10
+ * mid-drag would call cleanupDrag and kill the drag on the press re-render. v0.8 (plan
11
+ * §4.2.8): formula POINTING is delegated — a primary cell press consults the distinct
12
+ * reference-drag owner (usePointDrag, through `pointDragRef`) BEFORE the commit gate, and a
13
+ * consumed press leaves selection / anchor / active frozen; while pointing, dblclick and the
14
+ * bar-band press are inert (consume + focusSink, no commit). v0.8 D-53 (spec 08 §11): a
15
+ * Ctrl / Meta primary press (no Shift) consults the pointer link-opening capability
16
+ * (`openHyperlinkAt`) before the append / subtract semantics — a consumed press freezes the
17
+ * selection and mutes the derived dblclick. All are capability params: absent = the v0.7
18
+ * press path byte-for-byte.
19
+ *
20
+ * セル選択の pointer ステートマシン (§6.4.3)。body 単一リスナー・mouse 限定・contextmenu 配送・
21
+ * rAF 合流ドラッグ矩形・押下 1 回 setState。選択済みセルへの Ctrl+押下は減算モード (§3.2) —
22
+ * press 非書込・矩形スタイルは getDragMode reader リレー配送・up で reduceSelectionSubtract を
23
+ * 1 回確定。シンクは overlay 内 (body の兄弟) で構造的に不到達のため除外ガードは持たない
24
+ * (§8.1)。isDragging / getDragMode / abortDrag をキーボード Escape 分担 (§6.6.2) へ供給する。
25
+ * リスナーは 1 回だけ結線し、最新パラメータは ref 経由で読む (effect 再実行はドラッグ中の
26
+ * cleanupDrag = ドラッグ即死を招くため)。v0.8 (計画 §4.2.8): 数式ポインティングは委譲 —
27
+ * 主ボタンのセル押下は commit ゲートより**先**に独立所有者 (usePointDrag、`pointDragRef` 経由)
28
+ * へ諮り、消費された押下は選択 / anchor / active を凍結したまま。ポインティング中の dblclick と
29
+ * バー帯押下は不活性 (consume + focusSink、commit なし)。v0.8 D-53 (分冊 08 §11): Ctrl / Meta
30
+ * 主ボタン押下 (Shift なし) は append / 減算判定より先にリンク開通能力 (`openHyperlinkAt`) へ諮り、
31
+ * 消費された押下は選択凍結 + 派生 dblclick 不活性。いずれも能力 param: 不在 = v0.7 の
32
+ * 押下経路と byte 同一。
33
+ */
34
+
35
+ import type { VirtualGridHandle } from "@aiquants/virtualscroll"
36
+ import { type RefObject, useCallback, useEffect, useMemo, useRef } from "react"
37
+ import type { AxisModel } from "../model/axisModel.ts"
38
+ import { type CellCoord, type CellRange, clampCoord, coordEquals, makeRange, rangeContains } from "../model/cellCoord.ts"
39
+ import { viewportXToCol, viewportYToRow } from "../model/frozenPartition.ts"
40
+ import { reduceSelectionExtend, reduceSelectionPress, reduceSelectionSubtract, type SelectionState } from "./selectionState.ts"
41
+ import { type AutoScroller, SCROLLER_ARM_THRESHOLD } from "./useAutoScroller.ts"
42
+
43
+ /**
44
+ * Click-point caret resolution for the dblclick edit entry (ED-1 §6). Feature detection (NOT
45
+ * a value fallback): caretPositionFromPoint first, caretRangeFromPoint second. Adoption needs
46
+ * a text-node hit whose ancestor cell matches the hit-tested coordinate — pointer capture
47
+ * retargets dblclick to the body, so event.target is useless here. undefined = caret-at-end
48
+ * (the v0.1 behavior).
49
+ * ダブルクリック編集のクリック位置キャレット解決 (ED-1 §6)。機能検出 (値フォールバックでは
50
+ * ない): caretPositionFromPoint → caretRangeFromPoint の順。採用条件はテキストノード命中 +
51
+ * 祖先セルのヒットテスト座標一致 — capture 再ターゲットで dblclick の event.target は body に
52
+ * なるため使えない。undefined = キャレット末尾 (v0.1 挙動)。
53
+ */
54
+ const caretIndexFromPoint = (clientX: number, clientY: number, cell: CellCoord): number | undefined => {
55
+ let offsetNode: Node | null = null
56
+ let offset = 0
57
+ if (typeof document.caretPositionFromPoint === "function") {
58
+ const position = document.caretPositionFromPoint(clientX, clientY)
59
+ if (position === null) {
60
+ return undefined
61
+ }
62
+ offsetNode = position.offsetNode
63
+ offset = position.offset
64
+ } else if (typeof document.caretRangeFromPoint === "function") {
65
+ const range = document.caretRangeFromPoint(clientX, clientY)
66
+ if (range === null) {
67
+ return undefined
68
+ }
69
+ offsetNode = range.startContainer
70
+ offset = range.startOffset
71
+ } else {
72
+ return undefined // API なし (jsdom 等) — キャレット末尾へ
73
+ }
74
+ if (offsetNode === null || offsetNode.nodeType !== Node.TEXT_NODE) {
75
+ return undefined
76
+ }
77
+ // 命中ノードの祖先セルとヒットテスト座標を照合 (隣接セルのテキストノード命中を採用しない)
78
+ const cellElement = offsetNode.parentElement?.closest("[data-aqsp-cell]") ?? null
79
+ if (cellElement === null || Number(cellElement.getAttribute("data-row")) !== cell.row || Number(cellElement.getAttribute("data-col")) !== cell.col) {
80
+ return undefined
81
+ }
82
+ return offset
83
+ }
84
+
85
+ /**
86
+ * Body-relative pointer → cell hit test with the §7-4 freshness hierarchy: the handle's
87
+ * synchronous scroll position, the press-cached body rect, one zoom division at the boundary
88
+ * and the frozen-partition inverse mapping on both axes, clamped into the grid. ONE mapping
89
+ * shared by the cell engine and the reference-drag owner (usePointDrag — plan §4.1.18) so an
90
+ * identical pointer resolves to an identical cell in both. null = unmeasurable (no handle /
91
+ * rect, detached scroll sentinel, empty axis).
92
+ * body 基準のポインタ → セルのヒットテスト (§7-4 の鮮度序列): ハンドルの同期スクロール位置・
93
+ * 押下時計測の body rect・境界で 1 回だけのズーム除算・両軸の凍結パーティション逆写像、グリッド内
94
+ * へクランプ。セルエンジンと参照ドラッグ所有者 (usePointDrag — 計画 §4.1.18) が共有する単一写像
95
+ * (同じポインタは両者で同じセルに解決する)。null = 計測不能 (ハンドル / rect 不在、detached 番兵、
96
+ * 空軸)。
97
+ */
98
+ export const hitTestCell = (handle: VirtualGridHandle | null, rect: DOMRect | null, rowAxis: AxisModel, colAxis: AxisModel, zoom: number, clientX: number, clientY: number): CellCoord | null => {
99
+ if (handle === null || rect === null) {
100
+ return null
101
+ }
102
+ // 両軸ともハンドルの getScrollPosition (同期・最新の内部 ref 読み — §7-4 の鮮度序列)。
103
+ // ストアは v0.4 から位置を運ばない (rAF 写像の複製は押下が古い行 / 列へ着地する)
104
+ const { x: hx, y: vy } = handle.getScrollPosition()
105
+ if (vy < 0 || hx < 0) {
106
+ return null
107
+ }
108
+ if (rowAxis.totalSize() === 0 || colAxis.totalSize() === 0) {
109
+ return null
110
+ }
111
+ // ズーム境界 (v0.3): rect / client は視覚 px、軸は論理 px — 差分を zoom で 1 回だけ除算。
112
+ // 行 / 列とも凍結パーティション逆写像 (v0.6 / v0.5 計画 §2 — 帯サイズ未満は静的凍結帯、以上は + スクロール)
113
+ const frozen = handle.getFrozenSize()
114
+ return clampCoord({ row: viewportYToRow(rowAxis, (clientY - rect.top) / zoom, vy, frozen.height), col: viewportXToCol(colAxis, (clientX - rect.left) / zoom, hx, frozen.width) }, rowAxis.count, colAxis.count)
115
+ }
116
+
117
+ type UseSelectionEngineParams = {
118
+ /** Zoom scale reader (v0.3): client px = logical px × zoom — divide client deltas once at this boundary. / ズーム倍率 reader (v0.3)。client px = 論理 px × zoom — client 差分はこの境界で 1 回だけ除算する。 */
119
+ zoomRef: RefObject<number>
120
+ /** Rewire gate: wiring sleeps while the grid is empty and re-attaches on the non-empty transition (§6.9-1 empty branch). / 空グリッド間は結線を眠らせ、非空遷移で再結線するゲート。 */
121
+ enabled: boolean
122
+ rowAxis: AxisModel
123
+ colAxis: AxisModel
124
+ handleRef: RefObject<VirtualGridHandle | null>
125
+ bodyRef: RefObject<HTMLElement | null>
126
+ isEditing: () => boolean
127
+ requestCommit: () => boolean
128
+ /** Imperative selection read via ref (direct state reads are the stale-closure defect class — §6.4.3-3). / ref 経由の命令読み。 */
129
+ getSelection: () => SelectionState
130
+ /** The §6.9-1 Spreadsheet wrapper (raw setState is forbidden). / Spreadsheet のラッパー (生 setState 禁止)。 */
131
+ setSelection: (next: SelectionState) => void
132
+ focusSink: () => void
133
+ /** Drag-rect direct writer (null hides the resident node). / ドラッグ矩形の直書き (null = 非表示 — 常設ノードの表示制御)。 */
134
+ onDragFrame: (range: CellRange | null) => void
135
+ /** The sole contextmenu delivery channel, relayed to onCellContextMenu (kind: "pointer"). / onCellContextMenu へ横流しする唯一の配送チャネル。 */
136
+ onContextMenuSelect: (cell: CellCoord, event: MouseEvent) => void
137
+ /** The sole dblclick-to-edit channel (§6.4.3-7); caretIndex = the ED-1 click-point caret (undefined = caret-at-end). / ダブルクリック → editing(edit) 開始の唯一のチャネル。caretIndex は ED-1 のクリック位置キャレット (undefined = 末尾)。 */
138
+ onRequestEdit: (cell: CellCoord, caretIndex?: number) => void
139
+ /** ED-1 adoption gate (condition (c)) — PER-CELL: true only when the cell's DISPLAY text equals its raw canonical text (renderCell absent AND the cell's format leaves the string unchanged), so the click-point offset maps 1:1; false skips the caret computation entirely. / ED-1 採用ゲート (条件 (c)) — **セル単位**: 表示テキスト === 素の正準文字列のセルだけ true (クリック offset が 1:1 で写像可能)。false は計算自体をスキップ。 */
140
+ caretFromPointEnabled: (cell: CellCoord) => boolean
141
+ /**
142
+ * Composite drag probe across ALL machines (cell + both headers) for the contextmenu
143
+ * freeze gate — the engine's own draggingRef misses header drags, and a body right-click
144
+ * mid-header-drag would break the frozen-state contract (contextmenu is not retargeted by
145
+ * pointer capture).
146
+ * 全機械 (セル + 両ヘッダー) 合成のドラッグ判定 — contextmenu 凍結ゲート用。自機の
147
+ * draggingRef だけではヘッダードラッグ中の body 右クリックが凍結契約を破る。
148
+ */
149
+ isAnyDragging: () => boolean
150
+ autoScroller: AutoScroller
151
+ /** §8.2 repaint edges: press-after-flag + cleanup-end (fill-handle visibility re-evaluation). / §8.2 の repaint エッジ (press 直後 + cleanup 末尾 — fill ハンドル可視の再評価)。 */
152
+ repaintOverlay: () => void
153
+ /**
154
+ * v0.8 capability (plan §4.2.8): the reference-drag owner consulted BEFORE the commit gate
155
+ * on a primary cell press — a ref because usePointDrag is built after the engine (the root
156
+ * assigns it at render). Undefined / null current = no pointing press path (v0.7 identity).
157
+ * v0.8 能力 (計画 §4.2.8): 主ボタンのセル押下で commit ゲートより先に諮る参照ドラッグ所有者
158
+ * — usePointDrag はエンジンより後に構築されるため ref (root が render 時に代入)。undefined /
159
+ * current null = ポインティング押下経路なし (v0.7 恒等)。
160
+ */
161
+ pointDragRef?: RefObject<{ onPointerDown: (event: PointerEvent) => boolean } | null>
162
+ /**
163
+ * v0.8 capability: the editor's live pointing predicate (evaluated at the decision point —
164
+ * never cached). While true, dblclick does not open an editor and a bar-band / dead-space
165
+ * press only re-focuses the sink (no commit). Undefined = v0.7 identity.
166
+ * v0.8 能力: エディタの live ポインティング述語 (判断点でその場評価 — キャッシュしない)。true
167
+ * の間 dblclick はエディタを開かず、バー帯 / 死空間の押下はシンクの再フォーカスだけ (commit
168
+ * なし)。undefined = v0.7 恒等。
169
+ */
170
+ getPointing?: () => boolean
171
+ /**
172
+ * v0.8 capability (spec 08 §11 D-53): pointer hyperlink opening, consulted on a Ctrl / Meta
173
+ * primary cell press (Shift excluded — Ctrl+Shift stays extend) BEFORE the append / subtract
174
+ * semantics. true = the pressed cell carried a hyperlink and the host navigation fired — the
175
+ * press is consumed with selection / anchor / active frozen and the derived dblclick opens no
176
+ * editor; false = no link, the press continues as append / subtract. Undefined (host without
177
+ * `onHyperlinkOpen`) = the append / subtract press path byte-for-byte.
178
+ * v0.8 能力 (分冊 08 §11 D-53): Ctrl / Meta 主ボタンのセル押下 (Shift なし — Ctrl+Shift は extend
179
+ * のまま) で append / 減算判定より**先**に諮るポインタのリンク開通。true = 押下セルにリンクがあり
180
+ * ホスト遷移が発火 — 押下は消費され選択 / anchor / active は凍結、派生 dblclick もエディタを開か
181
+ * ない。false = リンク無し (押下は append / 減算へ続行)。undefined (`onHyperlinkOpen` 未宣言の
182
+ * ホスト) = append / 減算の押下経路と byte 同一。
183
+ */
184
+ openHyperlinkAt?: (cell: CellCoord) => boolean
185
+ }
186
+
187
+ /** Producer side of the Escape split (§6.6.2). / Escape 分担の生産側。 */
188
+ export type SelectionEngine = {
189
+ isDragging: () => boolean
190
+ /**
191
+ * Live styling mode of the in-flight drag rect (§3.2 mode reader-ref relay): "subtract"
192
+ * only during a cell-machine subtract drag; "point" is reported by the reference-drag owner
193
+ * (usePointDrag — this engine never returns it; the union is shared so every owner has the
194
+ * same shape). Read per onDragFrame call by the Spreadsheet relay — the autoscroller's
195
+ * construction-time-fixed writer cannot carry it per call.
196
+ * 飛行中ドラッグ矩形のライブスタイルモード (§3.2 の mode reader ref 方式)。"subtract" は
197
+ * セル機械の減算ドラッグ中のみ、"point" は参照ドラッグ所有者 (usePointDrag) だけが返す
198
+ * (本エンジンは返さない — 全所有者が同形になるよう union を共有)。Spreadsheet のリレーが
199
+ * onDragFrame 呼びごとに読む — autoscroller の構築時固定 writer は per-call で運べないため。
200
+ */
201
+ getDragMode: () => "add" | "subtract" | "point"
202
+ /** Aborts with pre-press restore (Escape / capture loss). / 押下前復元つき中断。 */
203
+ abortDrag: () => void
204
+ /**
205
+ * Cancels WITHOUT restore — for sheet switch, where dragBeforeRef holds the OLD sheet's
206
+ * coordinates and restoring would leak an out-of-bounds selection into the new axes.
207
+ * 復元なしの取り消し — シート切替用 (dragBeforeRef は旧シート座標で、復元すると新軸へ
208
+ * 範囲外選択が漏れる)。
209
+ */
210
+ cancelDrag: () => void
211
+ }
212
+
213
+ /**
214
+ * Wires the pointer machine onto the body element.
215
+ * body 要素へ pointer 機械を結線する。
216
+ */
217
+ export const useSelectionEngine = (params: UseSelectionEngineParams): SelectionEngine => {
218
+ // paramsRef パターン: リスナーは結線 1 回、最新パラメータはレンダー毎にここへ写す
219
+ const paramsRef = useRef(params)
220
+ paramsRef.current = params
221
+
222
+ const draggingRef = useRef(false)
223
+ const dragBeforeRef = useRef<SelectionState | null>(null)
224
+ const bodyRectRef = useRef<DOMRect | null>(null)
225
+ const pointerRef = useRef({ clientX: 0, clientY: 0 })
226
+ const dragRafRef = useRef<number | null>(null)
227
+ const capturedPointerRef = useRef<number | null>(null)
228
+ /** `buttons` reported by the press — the primary bit vanishing on a captured move is the chorded primary release (R4). / 押下時の `buttons` (主ビットの消失 = chord 中の主ボタン解放)。 */
229
+ const pressButtonsRef = useRef(0)
230
+ /** Per-drag range-building closure shared by the move rAF and the autoscroller tick (single construction point). / ドラッグ毎の矩形構築クロージャ (move rAF と tick の共有 — 単一構築点)。 */
231
+ const buildRangeRef = useRef<((hover: CellCoord) => CellRange) | null>(null)
232
+ /** Subtract-mode flag for the in-flight drag (§3.2 — cleared on EVERY primary-mouse pointerdown DELIVERED TO THIS ELEMENT incl. refused presses, re-assigned by the accepted press, reset by cleanupDrag: as long as the press stream reaches this element, no stale true survives into a later confirm; an element-bypassing release belongs to the accepted self-healing dangling class). / 飛行中ドラッグの減算モードフラグ (§3.2 — **この要素へ配送される**主ボタン押下ごとに必ず掃除 (拒否押下含む) + 受理 press で再代入 + cleanupDrag でもリセット。押下列がこの要素へ届く限り stale true は後続確定まで生存できない — 要素を迂回するリリース列は許容済みの宙吊り自己修復クラス)。 */
233
+ const subtractModeRef = useRef(false)
234
+ /** Subtract-drag rect origin (§3.2 — press writes nothing, so selection.anchor would be the STALE previous anchor). / 減算ドラッグの矩形起点 (§3.2 — press 非書込のため selection.anchor は旧値 = stale anchor 事故の遮断)。 */
235
+ const subtractPressCellRef = useRef<CellCoord | null>(null)
236
+ /** Last pointerdown's pointer type (for the dblclick mouse gate — dblclick carries no pointerType). / 直近 pointerdown の種別 (dblclick の mouse 判定用)。 */
237
+ const lastPointerTypeRef = useRef<string>("")
238
+ /**
239
+ * Whether the last pointerdown originated in the cells area — the dblclick gate. dblclick
240
+ * CANNOT be gated on its own target: pointer capture retargets pointerup AND the derived
241
+ * dblclick to the capture element (the body), so a real-browser cell double-click arrives
242
+ * with target = body (empirically confirmed) while a scrollbar double-click still needs
243
+ * rejecting.
244
+ * 直近 pointerdown がセル領域由来か (dblclick 用ゲート)。dblclick は自身の target で判定
245
+ * できない — pointer capture が pointerup と派生 dblclick を capture 要素 (body) へ再ターゲット
246
+ * するため、実ブラウザのセルダブルクリックは target=body で届く (実測)。スクロールバーの
247
+ * ダブルクリックは引き続き拒否が必要。
248
+ */
249
+ const lastPressInCellsRef = useRef(false)
250
+ /**
251
+ * Whether the last primary press was consumed by the pointer link opening (D-53) — the
252
+ * derived dblclick must not open an editor (the press was not a selection gesture). Cleared
253
+ * on every delivered primary mouse press like `subtractModeRef`.
254
+ * 直近の主ボタン押下がリンク開通で消費されたか (D-53) — 派生 dblclick はエディタを開かない
255
+ * (押下は選択ジェスチャではない)。`subtractModeRef` と同じく配送される主ボタン押下ごとに掃除。
256
+ */
257
+ const lastPressOpenedLinkRef = useRef(false)
258
+
259
+ /** Hit test with the freshness hierarchy (§6.4.3-3) over the press-cached body rect — the shared mapping. / 鮮度序列準拠のヒットテスト (押下時計測の body rect 上) — 共有写像。 */
260
+ const hitTest = useCallback((clientX: number, clientY: number): CellCoord | null => {
261
+ const { handleRef, rowAxis, colAxis, zoomRef } = paramsRef.current
262
+ return hitTestCell(handleRef.current, bodyRectRef.current, rowAxis, colAxis, zoomRef.current, clientX, clientY)
263
+ }, [])
264
+
265
+ /** Scroller armed flag (armed after >= SCROLLER_ARM_THRESHOLD px of real movement — not at press, not on jitter; press-time self-heal resets it like subtractModeRef). / スクローラー武装フラグ (押下でなく累計 SCROLLER_ARM_THRESHOLD px 以上の実移動で武装。subtractModeRef と同じ押下時自己修復でリセット)。 */
266
+ const scrollerArmedRef = useRef(false)
267
+ /** Drag-anchor column for the frozen home-territory rule. / 凍結ホームテリトリー規則用のアンカー列。 */
268
+ const dragAnchorColRef = useRef<number | null>(null)
269
+ /** Drag-anchor row for the frozen home-territory rule (row twin). / 凍結ホームテリトリー規則用のアンカー行 (縦双子)。 */
270
+ const dragAnchorRowRef = useRef<number | null>(null)
271
+ /** Press point for the arming distance gate (the fill machine's FILL_ARM_THRESHOLD twin). / 武装距離ゲート用の押下点 (fill 機械の FILL_ARM_THRESHOLD 双子)。 */
272
+ const scrollerPressPointRef = useRef({ clientX: 0, clientY: 0 })
273
+
274
+ /** Idempotent drag cleanup (§6.4.3-8). / 冪等な後始末。 */
275
+ const cleanupDrag = useCallback(() => {
276
+ const { autoScroller, bodyRef, onDragFrame } = paramsRef.current
277
+ draggingRef.current = false
278
+ dragBeforeRef.current = null
279
+ buildRangeRef.current = null
280
+ scrollerArmedRef.current = false
281
+ dragAnchorColRef.current = null
282
+ dragAnchorRowRef.current = null
283
+ subtractModeRef.current = false
284
+ subtractPressCellRef.current = null
285
+ if (dragRafRef.current !== null) {
286
+ cancelAnimationFrame(dragRafRef.current)
287
+ dragRafRef.current = null
288
+ }
289
+ autoScroller.stop()
290
+ const body = bodyRef.current
291
+ if (body !== null && capturedPointerRef.current !== null) {
292
+ if (body.hasPointerCapture?.(capturedPointerRef.current)) {
293
+ body.releasePointerCapture(capturedPointerRef.current)
294
+ }
295
+ }
296
+ capturedPointerRef.current = null
297
+ // ドラッグ矩形を hidden へ (常設ノード — §6.7.2-3)
298
+ onDragFrame(null)
299
+ // cleanup 末尾の repaint エッジ (§8.2 — クリック航行でハンドルが恒久不可視になる回帰の閉塞)
300
+ paramsRef.current.repaintOverlay()
301
+ }, [])
302
+
303
+ /** Aborts an in-flight drag, restoring the pre-press state (Escape). / 押下前状態への中断復元。 */
304
+ const abortDrag = useCallback(() => {
305
+ if (!draggingRef.current) {
306
+ return
307
+ }
308
+ const before = dragBeforeRef.current
309
+ if (before !== null) {
310
+ paramsRef.current.setSelection(before)
311
+ }
312
+ cleanupDrag()
313
+ }, [cleanupDrag])
314
+
315
+ const { enabled, bodyRef } = params
316
+ useEffect(() => {
317
+ const body = bodyRef.current
318
+ // enabled は空グリッドの間 false — 非空遷移で deps が変わり、実 DOM が揃った後に再結線される
319
+ // (ref identity は不変のため、これ無しでは空マウント時の null bail が永久に固定される)
320
+ if (!enabled || body === null) {
321
+ return
322
+ }
323
+
324
+ // §6.4.3-1 のシンク除外ガードは置かない: シンクは overlay 内 (= body の兄弟) にあり、
325
+ // シンク上のポインタイベントは構造的に body リスナーへ到達しない (§8.1 — 観測不能ガード禁止)。
326
+ // トポロジが変わって到達するようになった場合は統合テストの番兵 (シンク標的 press) が落ちる
327
+
328
+ /**
329
+ * Cell-area target gate: the pane also hosts virtualscroll's vertical scrollbar INSIDE
330
+ * this body element, and its stopPropagation is React-synthetic (dispatched above the
331
+ * body) so it cannot shield these NATIVE listeners — a thumb press would replace the
332
+ * selection with a bogus cell under the bar. Only events originating in our own cells
333
+ * container enter the machine.
334
+ * セル領域標的ゲート。body 内には virtualscroll の縦スクロールバーが同居し、その
335
+ * stopPropagation は合成 (body より上で発火) のため native リスナーを遮れない —
336
+ * サム押下がバー下の偽セルへ選択を置換してしまう。自前のセルコンテナ由来のイベント
337
+ * だけを機械へ通す。
338
+ */
339
+ const isCellAreaTarget = (target: EventTarget | null): boolean => target instanceof Element && target.closest("[data-aqsp-cells]") !== null
340
+
341
+ const onPointerDown = (event: PointerEvent) => {
342
+ const p = paramsRef.current
343
+ // 主ボタン mouse 押下は領域を問わず最上流で stale 減算フラグを掃除する (§3.2 の
344
+ // 二重遮断の完全形 — 領域外 / 編集ゲート拒否 / hitTest null で中断される押下でも
345
+ // 必ず走る。宙吊りジェスチャ (capture 失敗 + 領域外リリース) が残した旧フラグで
346
+ // 後続の up が減算確定する経路の閉塞)
347
+ if (event.pointerType === "mouse" && event.button === 0) {
348
+ subtractModeRef.current = false
349
+ subtractPressCellRef.current = null
350
+ lastPressOpenedLinkRef.current = false
351
+ }
352
+ lastPressInCellsRef.current = isCellAreaTarget(event.target)
353
+ if (!lastPressInCellsRef.current) {
354
+ // セル領域外 (縦バー / ペイン死空間) の mouse 主ボタン押下: 選択機械には入れないが、
355
+ // preventDefault + focusSink でフォーカスはシンクに留める (§6.8-3 の第 4 サイト)。
356
+ // preventDefault を怠ると mousedown 既定が最近傍 focusable (root) へフォーカスを移し、
357
+ // onRootFocus は relatedTarget = シンク (in-root) で抑制 → 選択が armed に見えたまま
358
+ // キーボードが全滅する無音状態になる。v0.4 からは GridBody の capture 統合ゲート
359
+ // (§6.8-3 — 両バー内在化) が同一要素で先行するため、本分岐は同型の第 2 層 (多層防御)。
360
+ // touch / ペンは素通し (スクロールジェスチャを守る)。mouse 非主ボタンは既定のフォーカス移動
361
+ // だけ止めて素通し (ネイティブメニューは preventDefault でも届く — R2 是正)。
362
+ if (event.pointerType === "mouse") {
363
+ event.preventDefault()
364
+ if (event.button !== 0) {
365
+ return
366
+ }
367
+ // ポインティング中 (v0.8 §4.2.8 / 設計 v4 §7): バー帯 / 死空間は consume + focusSink
368
+ // だけ — 数式を確定せず参照も挿入しない (能力分岐: 未宣言なら従来の commit ゲートへ)
369
+ if (p.getPointing?.()) {
370
+ p.focusSink()
371
+ return
372
+ }
373
+ // 編集中の requestCommit ゲート (§6.8-3): 縦バー / 死空間もフォーカス不動で
374
+ // focusout が発火しない (コーナー / ヘッダーと同型)。v0.4 では GridBody capture が
375
+ // 先行確定するため通常ここでは既に非編集 — 拒否経路も capture 側の stopPropagation
376
+ // が本リスナーごと止める。以下は capture 層が外れた場合の自衛 (同型に維持する)
377
+ if (p.isEditing() && !p.requestCommit()) {
378
+ event.stopPropagation()
379
+ p.focusSink()
380
+ return
381
+ }
382
+ p.focusSink()
383
+ }
384
+ return
385
+ }
386
+ // dblclick (MouseEvent — pointerType を持たない) の mouse 判定用に直近の押下種別を記録
387
+ // (mouse ゲートより先 — touch 押下も記録してから弾く)
388
+ lastPointerTypeRef.current = event.pointerType
389
+ if (event.pointerType !== "mouse") {
390
+ return // タッチ / ペンは virtualscroll のドラッグスクロールへ
391
+ }
392
+ if (event.button !== 0) {
393
+ // 右クリックは contextmenu ハンドラ (§6.4.3-4)。押下の既定動作 (mousedown のフォーカス移動)
394
+ // だけは止める — 止めないと最近傍 focusable の root group へフォーカスが逃げ、onRootFocus は
395
+ // in-root 移動として抑制するため右クリック後のキーボードが全滅する。preventDefault しても
396
+ // contextmenu イベントとネイティブメニューは届く (実ブラウザ実証 — R2 是正)
397
+ event.preventDefault()
398
+ return
399
+ }
400
+ // --- ポインティング (数式参照挿入 — v0.8 §4.2.8): 独立所有者 usePointDrag に commit ゲート
401
+ // より**先**に諮る。消費されたら選択 / active / anchor は凍結のまま (§4.1.18)。能力分岐:
402
+ // ref 未配線 / 所有者未充填 / 述語不成立は従来の押下経路へ素通し (v0.7 恒等) ---
403
+ const pointDrag = p.pointDragRef === undefined ? null : p.pointDragRef.current
404
+ if (pointDrag?.onPointerDown(event)) {
405
+ return
406
+ }
407
+ // 編集中の外クリック: commit 成功後に押下処理 (失敗なら中断 — §6.4.3-2)。拒否時は
408
+ // preventDefault が必須: 押下の既定動作 (mousedown のフォーカス移動) が root group へ
409
+ // フォーカスを逃がし、セッション生存中は onRootFocus が再進入 rearm を行わないため
410
+ // 「セッションは維持されているのに Escape も打鍵もシンクへ届かない」宙吊りになる
411
+ if (p.isEditing() && !p.requestCommit()) {
412
+ event.preventDefault()
413
+ p.focusSink()
414
+ return
415
+ }
416
+ // bodyRect は押下で 1 回計測しドラッグ中はキャッシュ (§6.4.3-3 のレイアウト読み規律)
417
+ bodyRectRef.current = body.getBoundingClientRect()
418
+ const cell = hitTest(event.clientX, event.clientY)
419
+ if (cell === null) {
420
+ return
421
+ }
422
+ // 押下時 preventDefault (テキスト選択 / フォーカス移動の抑止) + フォーカス回収 (§6.4.3-6)
423
+ event.preventDefault()
424
+ p.focusSink()
425
+ // リンク開通 (v0.8 分冊 08 §11 D-53): append / 減算と同じチョード形 (Ctrl / Meta、Shift なし)
426
+ // はまずリンク開通に諮る — 押下セルにリンクがあれば選択 / anchor / active を凍結したまま
427
+ // 消費する。Ctrl+Shift は extend 優先 (減算判定の append && !extend と同型)、能力未宣言は
428
+ // 述語ごと不在で従来経路 (能力分岐 — 値フォールバックではない)
429
+ if ((event.ctrlKey || event.metaKey) && !event.shiftKey && p.openHyperlinkAt?.(cell) === true) {
430
+ lastPressOpenedLinkRef.current = true
431
+ return
432
+ }
433
+ const before = p.getSelection()
434
+ dragBeforeRef.current = before
435
+ const extend = event.shiftKey
436
+ const append = event.ctrlKey || event.metaKey
437
+ // 減算モード判定 (§3.2): Ctrl のみ (append && !extend — Ctrl+Shift は extend 優先で
438
+ // Excel 同等) + 押下セルが選択済み。減算は press では書き込まない (up 確定 1 回)。
439
+ // フラグは無条件代入 (通常押下が false へ戻す — cleanupDrag と合わせた二重遮断)
440
+ const subtract = append && !extend && before.ranges.some((range) => rangeContains(range, cell))
441
+ subtractModeRef.current = subtract
442
+ subtractPressCellRef.current = subtract ? cell : null
443
+ if (!subtract) {
444
+ // 押下は reduceSelectionPress をその場で 1 回 setState (Excel は押下即座に選択が変わる)
445
+ p.setSelection(reduceSelectionPress(before, cell, { extend, append }))
446
+ }
447
+ draggingRef.current = true
448
+ // press 直後の repaint エッジ (§8.2 — フラグ成立後にハンドル可視条件を再評価させる)
449
+ p.repaintOverlay()
450
+ pointerRef.current = { clientX: event.clientX, clientY: event.clientY }
451
+ capturedPointerRef.current = event.pointerId
452
+ pressButtonsRef.current = event.buttons
453
+ try {
454
+ // jsdom は合成イベントの pointerId で InvalidPointerId を投げうる — 実ブラウザでは成功。
455
+ // capture 失敗時も pointermove/up は body リスナーで受かるため機能劣化は端外 drop のみ
456
+ body.setPointerCapture(event.pointerId)
457
+ } catch {
458
+ capturedPointerRef.current = null
459
+ }
460
+ // buildRange はドラッグ開始時の起点を閉じ込め、ref にも保持する — pointermove rAF と
461
+ // オートスクローラー tick の両書き手がこの 1 クロージャを共有する (単一構築点。二重に
462
+ // 別実装を持つとヘッダー側のスパン昇格のような差分が将来ドリフトする)。減算モードの
463
+ // 起点は押下セル (press 非書込のため selection.anchor は旧値 — §3.2)
464
+ const dragAnchor = subtract ? cell : p.getSelection().anchor
465
+ buildRangeRef.current = (hover) => makeRange(dragAnchor, hover)
466
+ // スクローラーは押下では起動しない (初回の実 pointermove で武装 — fill 機械と同
467
+ // ポリシーへ統一。押下起動は左ゾーン内の press だけで毎フレームのドレインになり、
468
+ // v0.5 の帯基準左ゾーンでは凍結セルのクリックが可動帯の勝手な左スクロールに化ける
469
+ // — R5 実証是正)。anchor 列はホームテリトリー規則のため武装時に渡す
470
+ dragAnchorColRef.current = dragAnchor.col
471
+ dragAnchorRowRef.current = dragAnchor.row
472
+ // 押下時自己修復 (subtractModeRef と同じ規約 — R5 是正): 宙吊りジェスチャ (capture
473
+ // 不能 + 要素外解放) が残した旧スクローラーと stale 武装フラグを毎押下で必ず掃除する。
474
+ // 放置すると旧コンテキスト (旧 anchorCol) が次ジェスチャを駆動し続ける (実証 −128px)
475
+ p.autoScroller.stop()
476
+ scrollerArmedRef.current = false
477
+ scrollerPressPointRef.current = { clientX: event.clientX, clientY: event.clientY }
478
+ }
479
+
480
+ /**
481
+ * Whether the event belongs to the pointer that started the drag (mouse-only contract
482
+ * §6.4.1 holds on ALL drag phases — touch/pen events run CONCURRENTLY with a mouse drag
483
+ * because they pass through to virtualscroll's drag scroll, and an ungated touch
484
+ * pointerup would confirm the mouse drag at the finger's position).
485
+ * ドラッグを開始したポインタ本人か (mouse 限定契約は全フェーズで保つ — touch / pen は
486
+ * virtualscroll のドラッグスクロールへ素通しされ mouse ドラッグと並行に流れるため、
487
+ * ゲート無しの touch pointerup が指の位置でドラッグを確定してしまう)。
488
+ */
489
+ const isOwnPointer = (event: PointerEvent): boolean => (capturedPointerRef.current !== null ? event.pointerId === capturedPointerRef.current : event.pointerType === "mouse")
490
+
491
+ const applyPointerMove = (event: PointerEvent) => {
492
+ if (!(draggingRef.current && isOwnPointer(event))) {
493
+ return
494
+ }
495
+ pointerRef.current = { clientX: event.clientX, clientY: event.clientY }
496
+ // 距離ゲートつき武装 (押下起動の廃止 + fill 機械の FILL_ARM_THRESHOLD 双子 — R5 是正):
497
+ // 実クリックは押下〜解放間に 1〜3px のジッタや座標同値のキャプチャエコーを含むため、
498
+ // 「move 1 発」では武装させない (右端ゾーンのクリックが +32px/frame のドレインに化ける
499
+ // — 実証 +96px)。押下点から累計 4px 以上の実移動のみが武装させる
500
+ if (!scrollerArmedRef.current && buildRangeRef.current !== null) {
501
+ const armDx = Math.abs(event.clientX - scrollerPressPointRef.current.clientX)
502
+ const armDy = Math.abs(event.clientY - scrollerPressPointRef.current.clientY)
503
+ if (Math.max(armDx, armDy) >= SCROLLER_ARM_THRESHOLD) {
504
+ scrollerArmedRef.current = true
505
+ paramsRef.current.autoScroller.start({ getPointerPosition: () => pointerRef.current, buildRange: buildRangeRef.current, anchorCol: dragAnchorColRef.current ?? undefined, anchorRow: dragAnchorRowRef.current ?? undefined })
506
+ }
507
+ }
508
+ // rAF 合流でドラッグ矩形を直書き (React 未介在 — §6.4.3-5)
509
+ if (dragRafRef.current === null) {
510
+ dragRafRef.current = requestAnimationFrame(() => {
511
+ dragRafRef.current = null
512
+ const buildRange = buildRangeRef.current
513
+ if (!draggingRef.current || buildRange === null) {
514
+ return
515
+ }
516
+ const p = paramsRef.current
517
+ const hover = hitTest(pointerRef.current.clientX, pointerRef.current.clientY)
518
+ if (hover !== null) {
519
+ p.onDragFrame(buildRange(hover))
520
+ }
521
+ })
522
+ }
523
+ }
524
+
525
+ const onPointerMove = (event: PointerEvent) => {
526
+ applyPointerMove(event)
527
+ // chord 中の主ボタン解放 (R4 是正): 主ボタン保持中に 2 本目を押した状態で主ボタンを離すと、
528
+ // その解放は pointerup でなく pointermove (buttons から主ビットが消える) で届き、最後の
529
+ // pointerup は非主ボタンのため確定ゲートを通らず、暗黙 capture 解放 → lostpointercapture
530
+ // で押下前状態へ巻き戻されていた。押下時に主ビットを報告したポインタが主ビットを失った
531
+ // move は主ボタンの解放 = ここで確定する (jsdom の合成 move は buttons 0 だが押下も 0)
532
+ if (draggingRef.current && isOwnPointer(event) && (pressButtonsRef.current & 1) !== 0 && (event.buttons & 1) === 0) {
533
+ confirmAt(event)
534
+ }
535
+ }
536
+
537
+ const onPointerUp = (event: PointerEvent) => {
538
+ // 主ボタンのリリースのみ確定する (非主ボタンの up は同一 pointerId のため isOwnPointer を
539
+ // 通過してしまう — ドラッグ中の右ボタンリリースでの早期確定と、宙吊りジェスチャを
540
+ // 右クリックが旧フラグのまま確定する事故の両方を閉塞。Excel も左リリースのみ確定)
541
+ if (event.button !== 0 || !(draggingRef.current && isOwnPointer(event))) {
542
+ return
543
+ }
544
+ confirmAt(event)
545
+ }
546
+
547
+ /** Confirms the in-flight gesture at the event position (primary release — pointerup or chorded move). / 飛行中ジェスチャの確定 (主ボタン解放 — pointerup または chord 中の move)。 */
548
+ const confirmAt = (event: PointerEvent) => {
549
+ const p = paramsRef.current
550
+ const hover = hitTest(event.clientX, event.clientY)
551
+ // hitTest null (軸空化等の縮退) は書込スキップ + cleanup でジェスチャ全体が無音に落ちる
552
+ if (hover !== null) {
553
+ const pressCell = subtractPressCellRef.current
554
+ if (subtractModeRef.current && pressCell !== null) {
555
+ // 減算確定は setSelection 1 回 (§3.2 — press 非書込と対の up 単一書込)
556
+ p.setSelection(reduceSelectionSubtract(p.getSelection(), makeRange(pressCell, hover), pressCell))
557
+ } else {
558
+ // pointerup で reduceSelectionExtend を setState (等価なら identity bail — 二重発火なし)
559
+ p.setSelection(reduceSelectionExtend(p.getSelection(), hover))
560
+ }
561
+ }
562
+ cleanupDrag()
563
+ }
564
+
565
+ const onLostPointerCapture = (event: PointerEvent) => {
566
+ // capture 喪失 (ウィンドウ外 drop 等) は押下前状態へ復元 (§6.4.3-8)。所有 pointerId
567
+ // 限定 — ScrollPane の touch capture 解放 (lostpointercapture は bubbles) が body 内から
568
+ // 浮上して mouse ドラッグを無関係に巻き戻すのを防ぐ。capture 未成立 (null) の自分の
569
+ // 喪失イベントは存在しないため null は常に無視
570
+ if (!draggingRef.current || capturedPointerRef.current === null || event.pointerId !== capturedPointerRef.current) {
571
+ return
572
+ }
573
+ abortDrag()
574
+ }
575
+
576
+ const onContextMenu = (event: MouseEvent) => {
577
+ const p = paramsRef.current
578
+ if (!isCellAreaTarget(event.target)) {
579
+ return // スクロールバー右クリック等はネイティブメニューへ素通し (選択も通知も不変)
580
+ }
581
+ // mouse 限定 (§6.4.1 — touch / pen の長押し contextmenu で armed 選択を破壊しない)。
582
+ // contextmenu は MouseEvent で pointerType を持たない — dblclick と同じく直近
583
+ // pointerdown の種別で判定する (実ブラウザは mouse 右クリックの前に必ず pointerdown
584
+ // (button 2) を配送し、その記録は button ゲートより前で行われる)
585
+ if (lastPointerTypeRef.current !== "mouse") {
586
+ return
587
+ }
588
+ // ドラッグ中の同時右クリックは無視 (Excel 同等) — 素通しするとドラッグ中に選択が置換され
589
+ // 「ドラッグ中は state 凍結・確定は pointerup の 1 回」の契約が破れる。判定は合成面
590
+ // (ヘッダードラッグ中の body 右クリックも凍結対象 — contextmenu は capture 非追従)
591
+ if (p.isAnyDragging()) {
592
+ return
593
+ }
594
+ // 編集中のエディタ右クリックは textarea ネイティブメニューへ素通し (シンクは body の
595
+ // 兄弟のためここへは到達しない)。編集中の body 右クリックは requestCommit ゲート
596
+ // (確定後に続行・失敗なら中断)
597
+ if (p.isEditing() && !p.requestCommit()) {
598
+ p.focusSink()
599
+ return
600
+ }
601
+ // フォーカス回収 (§6.8-3 の contextmenu 面): 右ボタン押下は preventDefault 済みでフォーカスは
602
+ // 動かないが、グリッド外にフォーカスがある状態からの右クリック (選択の置換が起きる) でも
603
+ // キーボードをグリッドへ呼び込む — v0.7 まで rearmSink の select() 副作用が偶然担っていた
604
+ // 回収の明示化 (R2 是正)。consumer のメニューが後段で自前要素へ focus すればそちらが勝つ
605
+ p.focusSink()
606
+ bodyRectRef.current = body.getBoundingClientRect()
607
+ const cell = hitTest(event.clientX, event.clientY)
608
+ if (cell === null) {
609
+ return
610
+ }
611
+ // 順序は置換 → 通知 (Excel 意味論 — メニューは置換後の新選択に作用する)。
612
+ // 選択の置換だけが条件付き: 選択外なら置換、選択内なら不変 (§6.4.1)
613
+ const selection = p.getSelection()
614
+ const inSelection = selection.ranges.some((range) => rangeContains(range, cell))
615
+ if (!inSelection) {
616
+ p.setSelection(reduceSelectionPress(selection, cell, { extend: false, append: false }))
617
+ }
618
+ // 無条件通知 (唯一の配送チャネル — 選択内右クリック = consumer メニューの最頻フローでも発火)
619
+ p.onContextMenuSelect(cell, event)
620
+ // preventDefault しない (ネイティブメニューは妨げない — §6.4.1)
621
+ }
622
+
623
+ const onDblClick = (event: MouseEvent) => {
624
+ const p = paramsRef.current
625
+ // target でなく直近押下の由来で判定 (capture 再ターゲットで target=body になる — 上記 ref 参照)
626
+ if (!lastPressInCellsRef.current) {
627
+ return
628
+ }
629
+ // dblclick は MouseEvent で pointerType を持たない — 直近 pointerdown の種別で mouse 限定を
630
+ // 貫く (§6.4.1「mouse のみ」/ §6.4.3-7「dblclick (mouse 左)」。タッチのダブルタップで
631
+ // 編集開始しない — タッチはスクロール専用)。既知特性: 2 回目 click と dblclick の間に
632
+ // 割り込んだ touch tap が記録を上書きすると正当な mouse ダブルクリックを 1 回落とす
633
+ // (安全方向 — 誤って開くことはない)。Phase 3 で実害が出る場合は pointerId 照合へ
634
+ if (lastPointerTypeRef.current !== "mouse") {
635
+ return
636
+ }
637
+ // ポインティング中の dblclick は無視 (v0.8 §4.2.8 — 1 回目の押下が参照を挿入済みで、
638
+ // 2 回目のクリックはトークン置換。セッションを二重に開かない。能力分岐: 未宣言は従来どおり)
639
+ if (p.getPointing?.()) {
640
+ return
641
+ }
642
+ // リンク開通で消費された押下の派生 dblclick は編集を開かない (D-53 — 押下は選択ジェスチャ
643
+ // ではなく、active も動いていない。修飾なしの次の押下がフラグを掃除して従来へ戻す)
644
+ if (lastPressOpenedLinkRef.current) {
645
+ return
646
+ }
647
+ bodyRectRef.current = body.getBoundingClientRect()
648
+ const cell = hitTest(event.clientX, event.clientY)
649
+ if (cell === null) {
650
+ return
651
+ }
652
+ // 1 回目の click は押下処理で単独選択 + active 化済み — active セルのみ編集開始 (§6.4.3-7)。
653
+ // ED-1: クリック位置キャレットを startEdit 前のこの時点で解決する (シンクは 1px 待機で
654
+ // セル DOM が最前面 — 編集開始後はシンクがセルを覆い caretPositionFromPoint が届かない)
655
+ if (coordEquals(p.getSelection().active, cell)) {
656
+ p.onRequestEdit(cell, p.caretFromPointEnabled(cell) ? caretIndexFromPoint(event.clientX, event.clientY, cell) : undefined)
657
+ }
658
+ }
659
+
660
+ body.addEventListener("pointerdown", onPointerDown)
661
+ body.addEventListener("pointermove", onPointerMove)
662
+ body.addEventListener("pointerup", onPointerUp)
663
+ body.addEventListener("lostpointercapture", onLostPointerCapture)
664
+ body.addEventListener("contextmenu", onContextMenu)
665
+ body.addEventListener("dblclick", onDblClick)
666
+ return () => {
667
+ body.removeEventListener("pointerdown", onPointerDown)
668
+ body.removeEventListener("pointermove", onPointerMove)
669
+ body.removeEventListener("pointerup", onPointerUp)
670
+ body.removeEventListener("lostpointercapture", onLostPointerCapture)
671
+ body.removeEventListener("contextmenu", onContextMenu)
672
+ body.removeEventListener("dblclick", onDblClick)
673
+ cleanupDrag()
674
+ }
675
+ }, [enabled, bodyRef, hitTest, cleanupDrag, abortDrag])
676
+
677
+ return useMemo(
678
+ () => ({
679
+ isDragging: () => draggingRef.current,
680
+ // セル機械は add / subtract だけ ("point" は usePointDrag が返し、root の合成面が配る)
681
+ getDragMode: (): "add" | "subtract" | "point" => (subtractModeRef.current ? "subtract" : "add"),
682
+ abortDrag,
683
+ cancelDrag: cleanupDrag,
684
+ }),
685
+ [abortDrag, cleanupDrag],
686
+ )
687
+ }