@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.
- package/CHANGELOG.md +1103 -0
- package/LICENSE.md +155 -0
- package/README.md +817 -0
- package/dist/index.cjs +21 -0
- package/dist/index.d.cts +1960 -0
- package/dist/index.d.ts +1960 -0
- package/dist/index.js +7711 -0
- package/dist/styles/spreadsheet.css +2 -0
- package/dist/styles/spreadsheet.standalone.css +3 -0
- package/package.json +94 -0
- package/src/Spreadsheet.tsx +2313 -0
- package/src/clipboard/clipPayload.ts +1242 -0
- package/src/clipboard/fixtures/excel.fixture.ts +301 -0
- package/src/clipboard/fixtures/html.fixture.ts +90 -0
- package/src/clipboard/fixtures/sheets.fixture.ts +83 -0
- package/src/clipboard/htmlInterop.ts +1694 -0
- package/src/clipboard/numberFormatCodec.ts +870 -0
- package/src/clipboard/pasteSpecial.ts +608 -0
- package/src/clipboard/tiling.ts +370 -0
- package/src/clipboard/tsv.ts +186 -0
- package/src/clipboard/useClipboard.ts +1154 -0
- package/src/commands/selfCalc.ts +279 -0
- package/src/commands/useCommands.ts +946 -0
- package/src/editor/useCellEditor.ts +708 -0
- package/src/grid/CellView.tsx +51 -0
- package/src/grid/GridBody.tsx +489 -0
- package/src/header/ColumnHeader.tsx +167 -0
- package/src/header/RowHeader.tsx +144 -0
- package/src/header/columnName.ts +31 -0
- package/src/header/resizeGates.ts +146 -0
- package/src/header/resizeStrips.ts +79 -0
- package/src/header/useHeaderResize.ts +598 -0
- package/src/header/useHeaderSelection.ts +454 -0
- package/src/index.ts +122 -0
- package/src/keyboard/compositionGuard.ts +53 -0
- package/src/keyboard/ensureCellVisible.ts +77 -0
- package/src/keyboard/shortcuts.ts +718 -0
- package/src/keyboard/useGridKeyboard.ts +940 -0
- package/src/model/axisModel.ts +186 -0
- package/src/model/cellAnnotations.ts +92 -0
- package/src/model/cellCoord.ts +114 -0
- package/src/model/cellFormat.ts +562 -0
- package/src/model/cellPaint.ts +537 -0
- package/src/model/conditionalFormat.ts +805 -0
- package/src/model/dataAdapter.ts +478 -0
- package/src/model/dataValidation.ts +542 -0
- package/src/model/formulaRefs.ts +984 -0
- package/src/model/frozenPartition.ts +100 -0
- package/src/model/labels.ts +228 -0
- package/src/model/viewStore.ts +124 -0
- package/src/overlay/SelectionOverlay.tsx +1425 -0
- package/src/overlay/useFillHandle.ts +432 -0
- package/src/popover/CommentPopover.tsx +109 -0
- package/src/popover/LinkPopover.tsx +108 -0
- package/src/popover/ListPopover.tsx +161 -0
- package/src/popover/MessagePopover.tsx +36 -0
- package/src/popover/NotePopover.tsx +106 -0
- package/src/popover/NoteTip.tsx +26 -0
- package/src/popover/PopoverLayer.tsx +210 -0
- package/src/popover/placement.ts +138 -0
- package/src/popover/usePopovers.ts +642 -0
- package/src/selection/selectionState.ts +239 -0
- package/src/selection/useAutoScroller.ts +226 -0
- package/src/selection/usePointDrag.ts +273 -0
- package/src/selection/useSelectionEngine.ts +687 -0
- package/src/styles/components.entry.css +10 -0
- package/src/styles/spreadsheet.css +1029 -0
- 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
|
+
}
|