@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,708 @@
1
+ /**
2
+ * @fileoverview Cell editing session state machine (§6.7.1): idle / editing(edit) /
3
+ * editing(replace). Owns the session state, the composition tracking, the grid-exit focusout
4
+ * commit and the suppressFocusoutCommit double-commit guard. All terminal transitions funnel
5
+ * through the idempotent finishEdit({ rearm }). The EDITING keymap columns are dispatched by
6
+ * useGridKeyboard through the seams returned here (single keydown owner = the §6.6.1 table
7
+ * stays one auditable dispatch — the plan's "編集中は編集セッションへ委譲" is realized as
8
+ * decision seams, not a second keydown listener). v0.8 (plan §4.2.6) adds four capability
9
+ * branches, each inert when the root does not declare it: the formula seed (resolveEditText —
10
+ * getCellFormula), the edit paint (resolveEditPaint / editPaintRef — inline text-level styles
11
+ * on the sink, retired at close), the validation gate (validateCommit — a rejected verdict
12
+ * follows the applyEdits-rejection path without reaching the adapter) and formula POINTING
13
+ * (§2.7): the predicate is evaluated LAZILY on the live sink at every decision point
14
+ * (`isPointingPrefix(sink.value, sink.selectionStart)`), the state is the arm flag —
15
+ * off at every session start (an F2 / dblclick session on an existing formula is Excel Edit
16
+ * mode: the first Arrow moves the caret), armed by an input that leaves the caret right after
17
+ * a trigger (`=`, `(`, an operator… — Sheets' operator rule / Excel Enter mode), toggled by
18
+ * F2 — plus the extension anchor of the pointed token (the corner a Shift+Arrow chain is
19
+ * pinned to — Excel / Sheets keyboard pointing), and `data-aqsp-pointing` is a progressive
20
+ * visual hint written after input / selectionchange and after every mutator here — never a
21
+ * decision input.
22
+ *
23
+ * セル編集セッション状態機械 (§6.7.1)。idle / editing(edit) / editing(replace) のセッション
24
+ * 状態・composition 追跡・グリッド外 focusout commit・suppressFocusoutCommit を所有する。
25
+ * 終端遷移は冪等な finishEdit({ rearm }) 1 本。編集列のキーディスパッチは useGridKeyboard が
26
+ * ここの返すシーム経由で行う (§6.6.1 の表を 1 枚の監査可能 dispatch に保つ)。v0.8 (計画
27
+ * §4.2.6) は root が宣言しない限り不活性な 4 つの能力分岐を追加: 数式シード (resolveEditText —
28
+ * getCellFormula)、エディタ塗り (resolveEditPaint / editPaintRef — シンクへのインライン文字
29
+ * 装飾、終了で退役)、検証ゲート (validateCommit — 拒否判定はアダプタへ届かず applyEdits 拒否と
30
+ * 同じ経路)、数式ポインティング (§2.7): 述語は判断点ごとに live シンク上でその場評価
31
+ * (`isPointingPrefix(sink.value, sink.selectionStart)`)、状態は武装フラグ — セッション
32
+ * 開始で非武装 (F2 / dblclick で開いた既存数式は Excel の Edit モード: 最初の矢印はキャレット
33
+ * 移動)、キャレットを契機 (`=` `(` 演算子…) の直後に置く入力で武装 (Sheets の演算子契機 /
34
+ * Excel の Enter モード)、F2 でトグル — と指しトークンの伸長アンカー (Shift+Arrow 連鎖が固定
35
+ * する角 — Excel / Sheets のキーボードポインティング)、`data-aqsp-pointing` は input /
36
+ * selectionchange 後と当フックの各変更操作後に書く漸進的な視覚ヒントであり判断根拠にはしない。
37
+ */
38
+
39
+ import { type RefObject, useCallback, useEffect, useMemo, useRef, useState } from "react"
40
+ import type { CellCoord, CellRange } from "../model/cellCoord.ts"
41
+ import type { CellAlign } from "../model/cellFormat.ts"
42
+ import { type EditPaint, IDLE_EDIT_PAINT } from "../model/cellPaint.ts"
43
+ import { type CellEdit, type EditRejection, resolveEditText, type SpreadsheetDataAdapter } from "../model/dataAdapter.ts"
44
+ import type { ValidationVerdict } from "../model/dataValidation.ts"
45
+ import { cycleAnchorAt, findReferenceAt, formatRangeReference, isPointingPrefix, type PointingTarget, type ReferenceAxis } from "../model/formulaRefs.ts"
46
+
47
+ /** Editing mode (§6.7.1 state diagram). / 編集モード。 */
48
+ export type EditingMode = "edit" | "replace"
49
+
50
+ /** Sink snapshot for the reference-drag Escape restore (value + caret range before the drag). / 参照ドラッグの Escape 復元用シンク断面 (ドラッグ前の value とキャレット区間)。 */
51
+ export type SinkSnapshot = { value: string; selectionStart: number; selectionEnd: number }
52
+
53
+ /**
54
+ * The reference cursor over the token under the caret: `origin` is the extension anchor —
55
+ * the cell of the last coordinate (non-Shift) insertion when it lies on a corner of the
56
+ * token, else the token's start (Excel's typed-token rule) —, `cursor` the corner diagonal
57
+ * to it (the end arrow keys move — `makeRange(origin, step(cursor))` is exactly Excel /
58
+ * Sheets Shift+Arrow pointing), `axis` the token's kind (cell / rectangle, column band, row
59
+ * band). An insertion point (no token) yields the editing cell for both ends.
60
+ * キャレット下トークン上の参照カーソル: `origin` は伸長アンカー — 最後の座標 (非 Shift) 挿入
61
+ * のセルがトークンの角に乗るならその角、乗らなければトークン始端 (Excel の手打ちトークン規則)
62
+ * —、`cursor` はその対角 (矢印で動く終端 — `makeRange(origin, step(cursor))` が Excel /
63
+ * Sheets の Shift+Arrow ポインティングそのもの)、`axis` はトークン種 (セル / 矩形・列帯・
64
+ * 行帯)。挿入点 (トークンなし) は両端とも編集中セル。
65
+ */
66
+ export type ReferenceCursor = { origin: CellCoord; cursor: CellCoord; axis: ReferenceAxis }
67
+
68
+ /**
69
+ * The pointing seams consumed by the pointer machines and the keymap (a structural subset of
70
+ * {@link CellEditor}; the root wires them to machines built before the editor through refs).
71
+ * ポインタ機械とキーマップが消費するポインティングシーム ({@link CellEditor} の構造的部分集合。
72
+ * エディタより先に構築される機械へは root が ref 経由で結線する)。
73
+ */
74
+ export type PointingSeams = {
75
+ /** Live predicate (§2.7): getCellFormula declared ∧ session open ∧ armed ∧ `isPointingPrefix(sink.value, sink.selectionStart) !== null` — evaluated on the sink at call time, never cached. / live 述語 (§2.7): getCellFormula 宣言 ∧ セッション中 ∧ 武装 ∧ 述語成立 — 呼ばれた時点のシンクで評価、キャッシュしない。 */
76
+ getPointing: () => boolean
77
+ /** The reference cursor under the caret (origin = the extension anchor it re-pins onto the token), or null when not pointing. / キャレット下の参照カーソル (origin = トークン上へ再ピンする伸長アンカー。非ポインティング時は null)。 */
78
+ getReferenceCursor: () => ReferenceCursor | null
79
+ /** Inserts (`{ insertAt }`) or replaces (`{ replace }`) the pointed reference text at the predicate's target and parks the caret right after it; a coordinate target also becomes the extension anchor (a range target preserves it); a no-op when not pointing. / 述語の行き先へ参照文字列を挿入 / 置換しキャレットをその直後へ (座標の指しは伸長アンカーにもなり、レンジ置換はそれを保つ。非ポインティング時は no-op)。 */
80
+ insertReference: (target: CellCoord | CellRange, axis: ReferenceAxis) => void
81
+ /** F2 arm toggle (Excel's Edit ⇄ Enter — a session opens disarmed, a typed trigger arms it) — false (nothing toggled, key falls through) without the capability or a session. / F2 武装トグル (Excel の Edit ⇄ Enter — セッションは非武装で開き、契機の入力で武装) — 能力 / セッションが無ければ false (キーは素通し)。 */
82
+ togglePointingArm: () => boolean
83
+ /** F4: cycles the reference under the caret; false when nothing was cycled (key falls through). / F4: キャレット下参照の $ 循環。循環対象なしは false (素通し)。 */
84
+ cycleAnchorAtCaret: () => boolean
85
+ }
86
+
87
+ type UseCellEditorParams = {
88
+ /** Rewire gate (§6.9-1 empty branch — same as the other wiring hooks). / 再結線ゲート。 */
89
+ enabled: boolean
90
+ sinkRef: RefObject<HTMLTextAreaElement | null>
91
+ rootRef: RefObject<HTMLElement | null>
92
+ adapter: SpreadsheetDataAdapter
93
+ /** The §6.9-2 write funnel: the transaction id on success (the edit version is bumped and the clip commit-discarded), null when the adapter rejected (or the transaction was empty); the caller declares the rejection context it relays to onEditRejected. / §6.9-2 の書込漏斗: 成功はトランザクション id (bumpEdit + クリップの commit 破棄)、アダプタ拒否 (または空トランザクション) は null。呼び手が onEditRejected へ中継される拒否文脈を宣言する。 */
94
+ commitEdits: (edits: readonly CellEdit[], rejection: EditRejection) => number | null
95
+ getActive: () => CellCoord
96
+ ensureCellVisible: (cell: CellCoord) => void
97
+ focusSink: () => void
98
+ rearmSink: () => void
99
+ /** Discards the armed clip (copy / cut ants) on edit start (§6.5.4-4 — Excel: typing dismisses the ants). / 編集開始で装填クリップ (copy / cut アンツ) を破棄する。 */
100
+ discardClip: () => void
101
+ /** Drag/edit mutual exclusion probe (§6.7.1 entry guard). / ドラッグと編集セッションの併存禁止 (冒頭ガード)。 */
102
+ isDragging: () => boolean
103
+ /**
104
+ * The cell's effective alignment for the editing textarea (Excel keeps the cell alignment
105
+ * while editing in place). null = the adapter has no getCellFormat capability — the sink's
106
+ * text-align is left untouched (v0.1 identity, distinct from an explicit "left").
107
+ * 編集中 textarea へ適用するセルの実効揃え (Excel はその場編集でもセル揃えを保つ)。null =
108
+ * getCellFormat 能力なし — text-align に触れない (v0.1 恒等・明示 "left" とは別物)。
109
+ */
110
+ resolveEditAlign: (cell: CellCoord) => CellAlign | null
111
+ /**
112
+ * v0.8 capability: the text-level paint for the sink (plan §4.2.6 / §2.3). Undefined =
113
+ * the root does not declare it — no inline style is written (v0.7 identity); null from the
114
+ * resolver = getCellFormat undeclared (same identity). The paint's style keys are applied
115
+ * to the sink at session start and cleared at close.
116
+ * v0.8 能力: シンクの文字装飾塗り (計画 §4.2.6 / §2.3)。undefined = root 未宣言 — インライン
117
+ * スタイルを書かない (v0.7 恒等)。resolver の null = getCellFormat 未宣言 (同じ恒等)。塗りの
118
+ * style キーはセッション開始でシンクへ書き、終了で消す。
119
+ */
120
+ resolveEditPaint?: (cell: CellCoord) => EditPaint | null
121
+ /** Root-owned paint channel read by the overlay (lineBoxPx / verticalAlign for the sink's padding); written on start / refresh, reset to IDLE_EDIT_PAINT at close. / root 所有の塗りチャネル (overlay が lineBoxPx / verticalAlign を読む)。開始 / 再計算で書き、終了で IDLE_EDIT_PAINT へ戻す。 */
122
+ editPaintRef?: RefObject<EditPaint>
123
+ /** Overlay repaint after an editing-column format toggle recomputed the paint. / 編集列の書式トグルで塗りを再計算した後の overlay 再描画。 */
124
+ repaintOverlay?: () => void
125
+ /**
126
+ * v0.8 capability: the commit-time validation gate (getCellValidation strict rules — the
127
+ * root resolves the rule, gates on `strict` and notifies onValidationRejected). A rejected
128
+ * verdict never reaches commitEdits and follows the same keep / discard path as an
129
+ * applyEdits rejection. Undefined = no validation (v0.7 identity).
130
+ * v0.8 能力: 確定時の検証ゲート (getCellValidation の strict 規則 — 規則解決・strict 判定・
131
+ * onValidationRejected 通知は root)。拒否判定は commitEdits へ進まず applyEdits 拒否と同じ
132
+ * keep / discard 経路。undefined = 検証なし (v0.7 恒等)。
133
+ */
134
+ validateCommit?: (cell: CellCoord, text: string) => ValidationVerdict
135
+ }
136
+
137
+ /** Seams consumed by the keymap and the pointer machines. / キーマップ / ポインタ機械が消費するシーム。 */
138
+ export type CellEditor = PointingSeams & {
139
+ isEditing: () => boolean
140
+ /** Editing mode probe — null = idle; drives the editing Arrow split (replace = commit + move, edit = native). / null = idle。編集列の Arrow 分岐用。 */
141
+ getEditingMode: () => EditingMode | null
142
+ /** True only inside requestCommit's composition-confirming blur — an internal focusout (focus returns to the sink right after), not a grid exit. / requestCommit の composition 確定 blur の間だけ true (内部 focusout — 直後にシンクへ戻る。グリッド脱出ではない)。 */
143
+ isFocusoutSuppressed: () => boolean
144
+ /**
145
+ * Commits the in-flight session (composition force-confirmed via blur when needed) and
146
+ * re-arms. Returns false when applyEdits rejected — the session stays alive and the
147
+ * caller aborts its own action (§6.4.3-2 order contract).
148
+ * 飛行中セッションを確定して再武装 (composition 中は blur で強制確定)。applyEdits 拒否時は
149
+ * false を返しセッション維持 — 呼び出し側が自分の動作を中断する (§6.4.3-2)。
150
+ */
151
+ requestCommit: () => boolean
152
+ /** Cancels the session discarding the value (editing-Escape row). / 値破棄キャンセル。 */
153
+ cancelEdit: () => void
154
+ /** Opens a session on the active cell (edit = existing value + caret end, replace = ""); caretIndex places the ED-1 click-point caret (geometrically clamped to the text length; a formula seed always parks the caret at the end). / active セルでセッションを開く (edit = 既存値 + キャレット末尾、replace = "")。caretIndex は ED-1 のクリック位置キャレット (テキスト長へ幾何 clamp。数式シードは常に末尾)。 */
155
+ startEdit: (mode: EditingMode, caretIndex?: number) => void
156
+ /** Sink snapshot for the reference drag (null without a sink). / 参照ドラッグ用のシンク断面 (シンク不在は null)。 */
157
+ captureSink: () => SinkSnapshot | null
158
+ /** Restores a snapshot (reference-drag Escape: the token reverts, the session stays). / 断面の復元 (参照ドラッグの Escape: トークンは戻りセッションは残る)。 */
159
+ restoreSink: (snapshot: SinkSnapshot) => void
160
+ /** Replaces the sink's selection range with `text` and parks the caret after it (editing-column insertDate / insertTime). / シンクの選択区間を `text` で置換しキャレットを直後へ (編集列の insertDate / insertTime)。 */
161
+ insertAtCaret: (text: string) => void
162
+ /** The session's live text, or null when idle (editing-column fillSelection). / セッションの現在文字列 (idle は null — 編集列の fillSelection)。 */
163
+ getSessionText: () => string | null
164
+ /** Recomputes and re-applies the edit paint after an editing-column format toggle, then repaints the overlay. / 編集列の書式トグル後に塗りを再計算・再適用し overlay を再描画。 */
165
+ refreshPaint: () => void
166
+ /** React state mirror for the overlay's editing prop (§6.7.2-4). / overlay の editing prop 用ミラー。 */
167
+ editing: boolean
168
+ }
169
+
170
+ /** Progressive pointing hint attribute on the sink (visual only — never a decision input). / シンクのポインティング視覚ヒント属性 (判断根拠にはしない)。 */
171
+ const POINTING_ATTRIBUTE = "data-aqsp-pointing"
172
+
173
+ /**
174
+ * Whether an input that left the caret at `caret` in `value` arms pointing: the prefix ends
175
+ * in a trigger (`{ insertAt }` — `=`, `=SUM(`, `=A1+`), i.e. the user just typed the trigger
176
+ * or deleted back to it (Sheets: range selection starts right after an operator; Excel: Enter
177
+ * mode). A caret on / right after a reference (`{ replace }` — `=A1+B1|`, `=A1+B1|2`) is
178
+ * mid-spelling and never arms, so the reference under the caret is not rewritten by an Arrow.
179
+ * 入力でキャレットが `value` の `caret` に置かれたときポインティングを武装するか: 接頭辞が契機で
180
+ * 終わる (`{ insertAt }` — `=` / `=SUM(` / `=A1+`) = 契機を打った / 契機まで削った直後 (Sheets の
181
+ * 「演算子の直後で範囲選択が始まる」規則、Excel の Enter モード)。参照の上 / 直後のキャレット
182
+ * (`{ replace }` — `=A1+B1|` / `=A1+B1|2`) は綴りの途中なので武装せず、矢印がキャレット下の参照を
183
+ * 書き換えることはない。
184
+ */
185
+ const isTriggerInput = (value: string, caret: number): boolean => {
186
+ const target = isPointingPrefix(value, caret)
187
+ return target !== null && "insertAt" in target
188
+ }
189
+
190
+ /** Clears the given camelCase style keys on the sink (an empty assignment removes the declaration). / 指定の camelCase style キーをシンクから消す (空文字代入で宣言が消える)。 */
191
+ const clearStyleKeys = (sink: HTMLTextAreaElement, keys: readonly string[]): void => {
192
+ // Object.assign は CSSOM の camelCase プロパティ代入 — "" は宣言の削除
193
+ Object.assign(sink.style, Object.fromEntries(keys.map((key) => [key, ""])))
194
+ }
195
+
196
+ /**
197
+ * Builds the editing session machine wired to the sink.
198
+ * シンクへ結線した編集セッション機械を構築する。
199
+ */
200
+ export const useCellEditor = (params: UseCellEditorParams): CellEditor => {
201
+ // paramsRef パターン: リスナーは結線 1 回、最新パラメータはレンダー毎にここへ写す
202
+ const paramsRef = useRef(params)
203
+ paramsRef.current = params
204
+
205
+ /** In-flight session (null = idle); commit writes to the cell captured at start. / 飛行中セッション。commit は開始時に捕捉したセルへ書く。 */
206
+ const sessionRef = useRef<{ cell: CellCoord; mode: EditingMode } | null>(null)
207
+ const [editing, setEditing] = useState(false)
208
+ const isComposingRef = useRef(false)
209
+ const suppressFocusoutCommitRef = useRef(false)
210
+ /** The arming half of the pointing state (§2.7): false at every session start (the seed never arms: an F2 / dblclick session on an existing formula is Excel Edit mode), set by a trigger input (`isTriggerInput`), toggled by F2. / ポインティング状態の武装側 (§2.7): セッション開始で false (シードは武装しない: F2 / dblclick の既存数式は Excel の Edit モード)、契機の入力 (`isTriggerInput`) で true、F2 でトグル。 */
211
+ const pointingArmedRef = useRef(false)
212
+ /** The anchor half of the pointing state (§2.7): the extension anchor of the pointed token — set by every coordinate (non-Shift) insertion (plain Arrow / cell press / header press / drag press), preserved across range replacements (Shift+Arrow / drag move), re-pinned onto the token by getReferenceCursor (typed tokens anchor at their start), collapsed by typing input / insertAtCaret / restoreSink and at session boundaries. / ポインティング状態のアンカー側 (§2.7): 指しトークンの伸長アンカー — 座標 (非 Shift) 挿入 (素の Arrow / セル押下 / ヘッダー押下 / ドラッグ押下) のたびに設定、レンジ置換 (Shift+Arrow / ドラッグ move) では保持、getReferenceCursor がトークン上へ再ピン (手打ちトークンは始端)、印字入力 / insertAtCaret / restoreSink とセッション境界で畳む。 */
213
+ const pointingAnchorRef = useRef<CellCoord | null>(null)
214
+ /** The paint currently written on the sink (its style keys are what close / refresh clear). / 現在シンクへ書かれている塗り (close / refresh が消すのはこのキー集合)。 */
215
+ const sessionPaintRef = useRef<EditPaint>(IDLE_EDIT_PAINT)
216
+
217
+ /**
218
+ * The pointing decision (§2.7), evaluated on the live sink at the moment of the call:
219
+ * capability ∧ session ∧ armed ∧ predicate. Returns the sink together with the predicate's
220
+ * target so the mutators apply it without a second evaluation; null = not pointing.
221
+ * ポインティング判断 (§2.7) — 呼ばれた瞬間の live シンクで評価: 能力 ∧ セッション ∧ 武装 ∧
222
+ * 述語。変更操作が二重評価せず適用できるようシンクと行き先を組で返す。null = 非ポインティング。
223
+ */
224
+ const pointingTarget = useCallback((): { sink: HTMLTextAreaElement; target: PointingTarget } | null => {
225
+ const p = paramsRef.current
226
+ const sink = p.sinkRef.current
227
+ // 能力分岐 (getCellFormula 未宣言 = 数式という概念が無い — v0.7 恒等) + セッション + 武装 + シンク
228
+ if (p.adapter.getCellFormula === undefined || sessionRef.current === null || !pointingArmedRef.current || sink === null) {
229
+ return null
230
+ }
231
+ // 純述語をその場評価 (キャッシュしたフラグを判断根拠にしない — §0 規律)
232
+ const target = isPointingPrefix(sink.value, sink.selectionStart)
233
+ return target === null ? null : { sink, target }
234
+ }, [])
235
+
236
+ /** Writes the progressive `data-aqsp-pointing` hint with same-value suppression. / 漸進的な `data-aqsp-pointing` ヒントを同値抑止で書く。 */
237
+ const syncPointingHint = useCallback((): void => {
238
+ const sink = paramsRef.current.sinkRef.current
239
+ if (sink === null) {
240
+ return
241
+ }
242
+ const pointing = pointingTarget() !== null
243
+ // 同値抑止 (属性の毎回書換は MutationObserver 系の消費者を無駄に起こす)
244
+ if (pointing !== sink.hasAttribute(POINTING_ATTRIBUTE)) {
245
+ if (pointing) {
246
+ sink.setAttribute(POINTING_ATTRIBUTE, "")
247
+ } else {
248
+ sink.removeAttribute(POINTING_ATTRIBUTE)
249
+ }
250
+ }
251
+ }, [pointingTarget])
252
+
253
+ /**
254
+ * Applies the edit paint for `cell` to the sink (capability branch: the resolver absent or
255
+ * returning null writes nothing and records IDLE) and mirrors it into the root's channel.
256
+ * `cell` の塗りをシンクへ適用 (能力分岐: resolver 不在 / null は何も書かず IDLE を記録) し
257
+ * root のチャネルへ写す。
258
+ */
259
+ const applyPaint = useCallback((sink: HTMLTextAreaElement, cell: CellCoord): void => {
260
+ const p = paramsRef.current
261
+ // 能力分岐: root が塗りを宣言しない限りインライン style には一切触れない (v0.7 恒等)
262
+ if (p.resolveEditPaint === undefined) {
263
+ return
264
+ }
265
+ const resolved = p.resolveEditPaint(cell)
266
+ // null = getCellFormat 未宣言 (同じ恒等)。IDLE は identity 比較で「書くものなし」
267
+ const paint = resolved === null ? IDLE_EDIT_PAINT : resolved
268
+ if (paint !== IDLE_EDIT_PAINT) {
269
+ Object.assign(sink.style, paint.style)
270
+ }
271
+ sessionPaintRef.current = paint
272
+ if (p.editPaintRef !== undefined) {
273
+ p.editPaintRef.current = paint
274
+ }
275
+ }, [])
276
+
277
+ /** Retires the paint written by applyPaint (style keys cleared, channels reset to IDLE). / applyPaint が書いた塗りの退役 (style キー消去、チャネルは IDLE へ)。 */
278
+ const retirePaint = useCallback((sink: HTMLTextAreaElement): void => {
279
+ clearStyleKeys(sink, Object.keys(sessionPaintRef.current.style))
280
+ sessionPaintRef.current = IDLE_EDIT_PAINT
281
+ const editPaintRef = paramsRef.current.editPaintRef
282
+ if (editPaintRef !== undefined) {
283
+ editPaintRef.current = IDLE_EDIT_PAINT
284
+ }
285
+ }, [])
286
+
287
+ /**
288
+ * The single terminal transition (idempotent — no-op when idle). commit=true attempts
289
+ * the validation gate then applyEdits; on rejection `onReject` decides the session's fate
290
+ * and is DECLARED up front so the wrapper can relay it to onEditRejected: "keep" (in-grid
291
+ * commits — the session stays open with the typed value) or "discard" (grid-exit focusout
292
+ * — the session closes, the text-align / paint retire and the sink is re-armed immediately
293
+ * without touching focus); both return false. An UNCHANGED commit (sink text equals the
294
+ * cell's canonical text — the formula source for a formula cell) never reaches the gate or
295
+ * applyEdits: the session just ends (Sheets-like — opening an editor and leaving without a
296
+ * change is not a write, so read-only hosts get a smooth focus-out instead of a rejection
297
+ * loop).
298
+ * 終端遷移の一本化 (冪等 — idle なら no-op)。commit=true は検証ゲート → applyEdits の順に
299
+ * 試み、拒否時のセッションの行方は `onReject` が**事前に宣言**する (ラッパーが onEditRejected
300
+ * へ中継できるように): "keep" (グリッド内確定 — 入力値ごと維持) / "discard" (グリッド外
301
+ * focusout — セッションを閉じ、text-align / 塗りを退役し、フォーカスに触れずシンクを即再武装)。
302
+ * いずれも false。**無変更 commit** (sink 値 = セルの正準テキスト — 数式セルは数式ソース) は
303
+ * ゲートにも applyEdits にも進めずセッション終了のみ (Sheets 同型 — 開いて閉じただけは書込
304
+ * でない。読み取り専用ホストのフォーカスアウトが拒否ループに化けない)。
305
+ */
306
+ const finishEdit = useCallback(
307
+ (options: { rearm: boolean } & ({ commit: false } | { commit: true; onReject: "keep" | "discard" })): boolean => {
308
+ const p = paramsRef.current
309
+ const session = sessionRef.current
310
+ if (session === null) {
311
+ return true
312
+ }
313
+ /** Closes the session unconditionally (shared by the success path and the discard path). / セッションを無条件に閉じる (成功経路と破棄経路の共有部)。 */
314
+ const closeSession = (): void => {
315
+ sessionRef.current = null
316
+ pointingAnchorRef.current = null
317
+ const sink = p.sinkRef.current
318
+ if (sink !== null) {
319
+ // 編集専用の text-align / 塗り / ポインティングヒントを退役 (次セッションの startEdit が改めて解決する)
320
+ sink.style.removeProperty("text-align")
321
+ retirePaint(sink)
322
+ sink.removeAttribute(POINTING_ATTRIBUTE)
323
+ }
324
+ setEditing(false)
325
+ }
326
+ if (options.commit) {
327
+ const sink = p.sinkRef.current
328
+ const text = sink === null ? "" : sink.value
329
+ // 無変更判定は正準テキストとの厳密比較 (SSOT — resolveEditText が唯一の比較先: 数式セルは
330
+ // 数式ソース、それ以外は getCellText)。比較前に正準側の改行だけ LF へ正規化する (textarea の
331
+ // API value は HTML 仕様で CR / CRLF → LF 正規化済み — CR を含むホストデータでは素の比較が
332
+ // 構造的に不成立になり、無操作 commit が正規化書込 + 読み取り専用ホストの拒否 UX に化ける。
333
+ // R4 是正)。replace モードで「打って全消し → 確定」は原文と異なるため従来どおり書込 (セルクリア)
334
+ const canonical = resolveEditText(p.adapter, session.cell.row, session.cell.col).text.replace(/\r\n?/g, "\n")
335
+ const unchanged = text === canonical
336
+ // 拒否文脈は呼び手が事前宣言する (onEditRejected はラッパーが発火 — ここで重ねて発火しない):
337
+ // keep = グリッド内確定 (セッション維持) / discard = グリッド外 focusout (破棄) の 2 値だけ
338
+ const rejection: EditRejection = { source: "edit", editSession: options.onReject === "keep" ? "kept" : "discarded" }
339
+ // 無変更は検証ゲートにも書込にも進まない (短絡)。検証ゲート (能力分岐) の拒否判定はアダプタへ
340
+ // 届けず applyEdits 拒否と同じ keep / discard 経路へ。commitEdits は成功でトランザクション id、
341
+ // 拒否で null (= 未確定)
342
+ const committed = unchanged || ((p.validateCommit === undefined || p.validateCommit(session.cell, text).ok) && p.commitEdits([{ row: session.cell.row, col: session.cell.col, text }], rejection) !== null)
343
+ if (!committed) {
344
+ if (options.onReject === "discard") {
345
+ // グリッド外 focusout の拒否 (§6.7.1): フォーカス無き宙吊りセッションを残さず、
346
+ // 入力値を破棄して idle へ。シンクも即座に再武装して入力残留を掃除する
347
+ // (rearmSink はフォーカスを動かさない — ユーザーが選んだ外部要素はそのまま)
348
+ closeSession()
349
+ p.rearmSink()
350
+ }
351
+ // keep: グリッド内経路では編集状態を維持 (bumpEdit されない) — Escape で取消 / 修正可
352
+ return false
353
+ }
354
+ }
355
+ closeSession()
356
+ if (options.rearm) {
357
+ p.rearmSink()
358
+ }
359
+ return true
360
+ },
361
+ [retirePaint],
362
+ )
363
+
364
+ /**
365
+ * Opens a session on the active cell after the entry guards (§6.7.1 — F2 / printable /
366
+ * composition / dblclick all funnel here).
367
+ * 冒頭ガード通過後に active セルでセッションを開く (§6.7.1 — F2 / 印字 / composition /
368
+ * dblclick の全経路がここへ合流)。
369
+ */
370
+ const startEdit = useCallback(
371
+ (mode: EditingMode, caretIndex?: number) => {
372
+ const p = paramsRef.current
373
+ // ドラッグと編集セッションの併存状態を作らない (§6.7.1 冒頭ガード — F2 / 印字 / composition 全経路)
374
+ if (p.isDragging() || sessionRef.current !== null) {
375
+ return
376
+ }
377
+ // シンク不在 (アンマウント途中) の bail を副作用より先に — クリップ破棄や
378
+ // ensure-visible だけ走ってセッションが開かない片肺状態を作らない
379
+ const sink = p.sinkRef.current
380
+ if (sink === null) {
381
+ return
382
+ }
383
+ // 編集開始でクリップを破棄 (§6.5.4-4 — copy アンツも Excel 同様ここで消灯)
384
+ p.discardClip()
385
+ const active = p.getActive()
386
+ // active はスクロールで画面外に出得る — 画面外セルの上にエディタ / IME 候補窓を開かない (§6.7.1)
387
+ p.ensureCellVisible(active)
388
+ if (mode === "edit") {
389
+ // シードは resolveEditText (能力分岐: getCellFormula 宣言 + 数式なら数式ソース、それ以外は正準文字列)
390
+ const { text, isFormula } = resolveEditText(p.adapter, active.row, active.col)
391
+ sink.value = text
392
+ // ED-1: クリック位置キャレット。caretIndex はセル単位ゲートが有効なとき (renderCell
393
+ // 未宣言かつ**そのセルの**表示テキスト === 素の正準文字列) のみ届くため offset は正準長を
394
+ // 超えない (Math.min は契約の保険)。caretIndex 未指定はキャレット末尾 (F2 / 非対応環境 /
395
+ // 表示が素値と乖離するセル — v0.1 挙動)。数式セルは表示 (計算結果) と綴りが乖離するため
396
+ // クリック位置は写像できず常に末尾 (caret-from-point off)
397
+ const caret = caretIndex === undefined || isFormula ? text.length : Math.min(caretIndex, text.length)
398
+ sink.setSelectionRange(caret, caret)
399
+ } else {
400
+ // replace: value="" の設定 1 点で打鍵 / composition を流入させる (§6.6.2-5)
401
+ sink.value = ""
402
+ }
403
+ // Excel 同様、編集中もセルの実効揃えを textarea に維持 (能力なし = null は触らない恒等)
404
+ const editAlign = p.resolveEditAlign(active)
405
+ if (editAlign !== null) {
406
+ sink.style.textAlign = editAlign
407
+ }
408
+ // 文字装飾の塗り (能力分岐 — resolver 不在 / null は何も書かない)
409
+ applyPaint(sink, active)
410
+ // 唯一のポインティング状態は非武装で開く (§2.7): シードは武装契機でない — F2 / dblclick で
411
+ // 開いた既存数式 (`=A1+B1`) の最初の矢印は Excel の Edit モードどおりキャレット移動で、末尾の
412
+ // 参照を書き換えない。武装は契機の入力 (onInput の isTriggerInput) か F2 (togglePointingArm)。
413
+ // 非武装のため述語は構造的に不成立で、ヒント属性は前セッションの closeSession が除去済み
414
+ pointingArmedRef.current = false
415
+ pointingAnchorRef.current = null
416
+ sessionRef.current = { cell: active, mode }
417
+ setEditing(true)
418
+ },
419
+ [applyPaint],
420
+ )
421
+
422
+ const cancelEdit = useCallback(() => {
423
+ // 値破棄 (rearmSink が武装状態 value = " " 全選択へ戻す)。commit しない経路は onReject を持たない (型で排除)
424
+ finishEdit({ rearm: true, commit: false })
425
+ }, [finishEdit])
426
+
427
+ const requestCommit = useCallback((): boolean => {
428
+ const p = paramsRef.current
429
+ if (sessionRef.current === null) {
430
+ return true
431
+ }
432
+ const sink = p.sinkRef.current
433
+ if (isComposingRef.current && sink !== null) {
434
+ // composition 進行中の外クリック等 (§6.6.1 注記): blur が composition を確定させ value に
435
+ // 反映される。blur は relatedTarget=null の focusout を同期発火させるため、二重 commit を
436
+ // 抑止フラグで防ぐ (解除は try/finally — 降ろし忘れは次の正当な focusout commit を殺す)
437
+ suppressFocusoutCommitRef.current = true
438
+ try {
439
+ sink.blur()
440
+ } finally {
441
+ suppressFocusoutCommitRef.current = false
442
+ }
443
+ // グリッド内経路 (押下 / ヘッダー / バー帯) の確定 — 拒否はセッション維持 (keep)
444
+ if (!finishEdit({ rearm: false, commit: true, onReject: "keep" })) {
445
+ p.focusSink()
446
+ return false
447
+ }
448
+ // blur でフォーカスが外れている — 回収 + 再武装して押下処理へ返す (§6.6.1 注記の順序)
449
+ p.focusSink()
450
+ p.rearmSink()
451
+ return true
452
+ }
453
+ return finishEdit({ rearm: true, commit: true, onReject: "keep" })
454
+ }, [finishEdit])
455
+
456
+ const getPointing = useCallback((): boolean => pointingTarget() !== null, [pointingTarget])
457
+
458
+ const togglePointingArm = useCallback((): boolean => {
459
+ const p = paramsRef.current
460
+ // 能力 / セッションが無ければ F2 に意味は無い — false でキーを素通しさせる (v0.7 恒等)
461
+ if (p.adapter.getCellFormula === undefined || sessionRef.current === null) {
462
+ return false
463
+ }
464
+ pointingArmedRef.current = !pointingArmedRef.current
465
+ syncPointingHint()
466
+ return true
467
+ }, [syncPointingHint])
468
+
469
+ const getReferenceCursor = useCallback((): ReferenceCursor | null => {
470
+ const hit = pointingTarget()
471
+ const session = sessionRef.current
472
+ if (hit === null || session === null) {
473
+ return null
474
+ }
475
+ // 挿入点 (トークンなし) はセッションセルが起点 = カーソル。最初の Shift+Arrow はここから
476
+ // レンジを張るため、伸長アンカーもセッションセルへピンしておく
477
+ if ("insertAt" in hit.target) {
478
+ pointingAnchorRef.current = session.cell
479
+ return { origin: session.cell, cursor: session.cell, axis: "cells" }
480
+ }
481
+ // 置換対象 = キャレット下トークン (述語と同じ走査 + 両端含む判定なので必ず見つかる — 型充足のみ)
482
+ const span = findReferenceAt(hit.sink.value, hit.sink.selectionStart)
483
+ if (span === null) {
484
+ return null
485
+ }
486
+ const token = span.token
487
+ // 伸長アンカー (§2.7 — Excel / Sheets のキーボードポインティング): 記憶したアンカーがトークンの
488
+ // 角に乗るなら origin はその角、cursor は対角 (Shift+Arrow が動かすのは常にアンカーの対角 —
489
+ // 始端固定の導出では上 / 左方向の伸長が 2 セル目で畳まれる)。乗らない (手打ちトークン /
490
+ // アンカー喪失) は Excel の手打ち規則どおり始端を採用。採用した角をここで再ピンし、以後の
491
+ // 伸長連鎖が同じ角に固定され続けることを構造的に保証する
492
+ const anchor = pointingAnchorRef.current
493
+ switch (token.kind) {
494
+ case "cell": {
495
+ const cell = { row: token.row, col: token.col }
496
+ pointingAnchorRef.current = cell
497
+ return { origin: cell, cursor: cell, axis: "cells" }
498
+ }
499
+ case "range": {
500
+ const origin = anchor !== null && (anchor.row === token.start.row || anchor.row === token.end.row) && (anchor.col === token.start.col || anchor.col === token.end.col) ? { row: anchor.row, col: anchor.col } : { row: token.start.row, col: token.start.col }
501
+ pointingAnchorRef.current = origin
502
+ return { origin, cursor: { row: origin.row === token.start.row ? token.end.row : token.start.row, col: origin.col === token.start.col ? token.end.col : token.start.col }, axis: "cells" }
503
+ }
504
+ case "column": {
505
+ // 列帯は行成分を持たない — セッションセルの行で座標化する (formatRangeReference は行を読まない)
506
+ const anchorCol = anchor !== null && (anchor.col === token.start.col || anchor.col === token.end.col) ? anchor.col : token.start.col
507
+ const origin = { row: session.cell.row, col: anchorCol }
508
+ pointingAnchorRef.current = origin
509
+ return { origin, cursor: { row: session.cell.row, col: anchorCol === token.start.col ? token.end.col : token.start.col }, axis: "columns" }
510
+ }
511
+ case "row": {
512
+ const anchorRow = anchor !== null && (anchor.row === token.start.row || anchor.row === token.end.row) ? anchor.row : token.start.row
513
+ const origin = { row: anchorRow, col: session.cell.col }
514
+ pointingAnchorRef.current = origin
515
+ return { origin, cursor: { row: anchorRow === token.start.row ? token.end.row : token.start.row, col: session.cell.col }, axis: "rows" }
516
+ }
517
+ }
518
+ }, [pointingTarget])
519
+
520
+ const insertReference = useCallback(
521
+ (target: CellCoord | CellRange, axis: ReferenceAxis): void => {
522
+ const hit = pointingTarget()
523
+ if (hit === null) {
524
+ return
525
+ }
526
+ const text = formatRangeReference(target, axis)
527
+ const { sink } = hit
528
+ // { replace } は区間置換、{ insertAt } は挿入 — どちらもキャレットはトークン末尾へ
529
+ // (次の Arrow / クリック / tick は isPointingPrefix がこのトークンを置換対象として見つける)
530
+ const [start, end] = "replace" in hit.target ? hit.target.replace : [hit.target.insertAt, hit.target.insertAt]
531
+ const value = sink.value
532
+ sink.value = `${value.slice(0, start)}${text}${value.slice(end)}`
533
+ const caret = start + text.length
534
+ sink.setSelectionRange(caret, caret)
535
+ // 座標 (非 Shift) の指しは伸長アンカーになる — レンジ置換 (Shift+Arrow / ドラッグ move) は
536
+ // 押下セル / 最後に指したセルを固定角のまま保つ (§2.7 — Excel / Sheets のキーボードポインティング)
537
+ if ("row" in target) {
538
+ pointingAnchorRef.current = { row: target.row, col: target.col }
539
+ }
540
+ syncPointingHint()
541
+ },
542
+ [pointingTarget, syncPointingHint],
543
+ )
544
+
545
+ const captureSink = useCallback((): SinkSnapshot | null => {
546
+ const sink = paramsRef.current.sinkRef.current
547
+ if (sink === null) {
548
+ return null
549
+ }
550
+ return { value: sink.value, selectionStart: sink.selectionStart, selectionEnd: sink.selectionEnd }
551
+ }, [])
552
+
553
+ const restoreSink = useCallback(
554
+ (snapshot: SinkSnapshot): void => {
555
+ const sink = paramsRef.current.sinkRef.current
556
+ if (sink === null) {
557
+ return
558
+ }
559
+ sink.value = snapshot.value
560
+ sink.setSelectionRange(snapshot.selectionStart, snapshot.selectionEnd)
561
+ // ドラッグ押下が設定したアンカーは復元した綴りには属さない — 手打ちトークン規則 (始端) へ畳む
562
+ pointingAnchorRef.current = null
563
+ syncPointingHint()
564
+ },
565
+ [syncPointingHint],
566
+ )
567
+
568
+ const cycleAnchorAtCaret = useCallback((): boolean => {
569
+ const p = paramsRef.current
570
+ const sink = p.sinkRef.current
571
+ // 能力分岐: getCellFormula 未宣言なら "=" 始まりの文字列も数式ではない (F4 は素通し — v0.7 恒等)
572
+ if (p.adapter.getCellFormula === undefined || sessionRef.current === null || sink === null) {
573
+ return false
574
+ }
575
+ const next = cycleAnchorAt(sink.value, sink.selectionStart)
576
+ if (next === null) {
577
+ return false
578
+ }
579
+ sink.value = next.text
580
+ sink.setSelectionRange(next.caret, next.caret)
581
+ syncPointingHint()
582
+ return true
583
+ }, [syncPointingHint])
584
+
585
+ const insertAtCaret = useCallback(
586
+ (text: string): void => {
587
+ const sink = paramsRef.current.sinkRef.current
588
+ if (sessionRef.current === null || sink === null) {
589
+ return
590
+ }
591
+ // 選択区間を置換しキャレットを挿入文字列の直後へ
592
+ const start = sink.selectionStart
593
+ const end = sink.selectionEnd
594
+ sink.value = `${sink.value.slice(0, start)}${text}${sink.value.slice(end)}`
595
+ const caret = start + text.length
596
+ sink.setSelectionRange(caret, caret)
597
+ // 挿入文字列はトークンを閉じる — 伸長アンカーも畳む (印字入力と同じ規則)
598
+ pointingAnchorRef.current = null
599
+ syncPointingHint()
600
+ },
601
+ [syncPointingHint],
602
+ )
603
+
604
+ const getSessionText = useCallback((): string | null => {
605
+ const sink = paramsRef.current.sinkRef.current
606
+ return sessionRef.current === null || sink === null ? null : sink.value
607
+ }, [])
608
+
609
+ const refreshPaint = useCallback((): void => {
610
+ const p = paramsRef.current
611
+ const session = sessionRef.current
612
+ const sink = p.sinkRef.current
613
+ if (session === null || sink === null) {
614
+ return
615
+ }
616
+ // 旧塗りを退役してから再解決 (書式トグルで消えたキーを残さない)
617
+ retirePaint(sink)
618
+ applyPaint(sink, session.cell)
619
+ p.repaintOverlay?.()
620
+ }, [retirePaint, applyPaint])
621
+
622
+ const { enabled, sinkRef } = params
623
+ useEffect(() => {
624
+ const sink = sinkRef.current
625
+ if (!enabled || sink === null) {
626
+ return
627
+ }
628
+ const onCompositionStart = () => {
629
+ isComposingRef.current = true
630
+ }
631
+ const onCompositionEnd = () => {
632
+ isComposingRef.current = false
633
+ }
634
+ const onFocusOut = (event: FocusEvent) => {
635
+ const p = paramsRef.current
636
+ // requestCommit の blur 経路と二重 commit しない (§6.7.1 suppressFocusoutCommit)
637
+ if (suppressFocusoutCommitRef.current || sessionRef.current === null) {
638
+ return
639
+ }
640
+ const root = p.rootRef.current
641
+ const related = event.relatedTarget
642
+ // root 内への移動 (シンク⇄root の Escape 脱出等) は無視 — グリッド外または null のみ発動
643
+ if (root !== null && related instanceof Node && root.contains(related)) {
644
+ return
645
+ }
646
+ // グリッド外 focusout: 現在値を確定して idle へ (Sheets 準拠 — §6.6.1 注記)。成功時は
647
+ // rearm しない — フォーカスはユーザーが選んだ外部要素に残し、次のグリッド再進入 (§10-4)
648
+ // が rearm する。拒否時 (applyEdits throw) は onReject: "discard" が finishEdit 内で
649
+ // セッション破棄 + フォーカス中立の再武装まで一括で担う (呼び手側の後始末は持たない)
650
+ finishEdit({ rearm: false, commit: true, onReject: "discard" })
651
+ }
652
+ // ポインティングヒント (§2.7): input と document の selectionchange の後に書く (漸進的 —
653
+ // 判断は各判断点の述語再評価が担うため、selectionchange 非対応環境でも動作は変わらない)
654
+ const onInput = () => {
655
+ const p = paramsRef.current
656
+ // 印字の到着はトークンを閉じる / 手で書き換える — 伸長アンカーも畳む (手打ちトークンは
657
+ // Excel の手打ち規則どおり始端アンカーへ)
658
+ pointingAnchorRef.current = null
659
+ // 契機の入力で武装 (§2.7 — Sheets の演算子契機 / Excel の Enter モード): 能力 + セッション中で、
660
+ // まだ非武装のとき、この入力がキャレットを契機の直後に置いたなら true (以後 F2 / 終了まで保持)
661
+ if (p.adapter.getCellFormula !== undefined && sessionRef.current !== null && !pointingArmedRef.current && isTriggerInput(sink.value, sink.selectionStart)) {
662
+ pointingArmedRef.current = true
663
+ }
664
+ syncPointingHint()
665
+ }
666
+ const onSelectionChange = () => {
667
+ if (sessionRef.current !== null) {
668
+ syncPointingHint()
669
+ }
670
+ }
671
+ sink.addEventListener("compositionstart", onCompositionStart)
672
+ sink.addEventListener("compositionend", onCompositionEnd)
673
+ sink.addEventListener("focusout", onFocusOut)
674
+ sink.addEventListener("input", onInput)
675
+ document.addEventListener("selectionchange", onSelectionChange)
676
+ return () => {
677
+ sink.removeEventListener("compositionstart", onCompositionStart)
678
+ sink.removeEventListener("compositionend", onCompositionEnd)
679
+ sink.removeEventListener("focusout", onFocusOut)
680
+ sink.removeEventListener("input", onInput)
681
+ document.removeEventListener("selectionchange", onSelectionChange)
682
+ }
683
+ }, [enabled, sinkRef, finishEdit, syncPointingHint])
684
+
685
+ return useMemo(
686
+ () => ({
687
+ isEditing: () => sessionRef.current !== null,
688
+ getEditingMode: () => sessionRef.current?.mode ?? null,
689
+ /** True only inside requestCommit's composition-confirming blur — that focusout is internal (focus returns to the sink right after), not a grid exit. / requestCommit の composition 確定 blur の間だけ true (内部 focusout — 直後にシンクへ戻る。グリッド脱出ではない)。 */
690
+ isFocusoutSuppressed: () => suppressFocusoutCommitRef.current,
691
+ requestCommit,
692
+ cancelEdit,
693
+ startEdit,
694
+ getPointing,
695
+ togglePointingArm,
696
+ getReferenceCursor,
697
+ insertReference,
698
+ captureSink,
699
+ restoreSink,
700
+ cycleAnchorAtCaret,
701
+ insertAtCaret,
702
+ getSessionText,
703
+ refreshPaint,
704
+ editing,
705
+ }),
706
+ [requestCommit, cancelEdit, startEdit, getPointing, togglePointingArm, getReferenceCursor, insertReference, captureSink, restoreSink, cycleAnchorAtCaret, insertAtCaret, getSessionText, refreshPaint, editing],
707
+ )
708
+ }