@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,2313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Spreadsheet root: wiring only (§6.9). Owns the view store, the grid-handle
|
|
3
|
+
* scroll/range relays (onScroll / onRangeChange — the v0.3 applyHx seam is retired, hx is
|
|
4
|
+
* VirtualGrid-owned since v0.4), the setSelection wrapper (Object.is bail / selectionRef sync
|
|
5
|
+
* write / onSelectionChange / announcement evaluation / rearm point (c)), the imperative
|
|
6
|
+
* repaint paths (headers / column track / overlay) and the shared
|
|
7
|
+
* focus/rearm/moveActive/ensureCellVisible helpers; all logic lives in the layers below.
|
|
8
|
+
*
|
|
9
|
+
* Spreadsheet 統合ルート。結線のみ (§6.9)。viewStore・グリッドハンドル発のスクロール / レンジ
|
|
10
|
+
* 中継 (onScroll / onRangeChange — v0.3 の applyHx シームは退役、hx は v0.4 から VirtualGrid の
|
|
11
|
+
* 所有)・setSelection ラッパー (5 段)・命令再描画パス・共有ヘルパを所有し、ロジックは下位層に置く。
|
|
12
|
+
*
|
|
13
|
+
* Phase 3 まで結線済み: 選択エンジン + キーボード + オーバーレイ + live region + WCAG 脱出に
|
|
14
|
+
* 加え、編集セッション (useCellEditor) とクリップボード (useClipboard — 装填クリップ所有) を
|
|
15
|
+
* ref 間接シームで接続する。
|
|
16
|
+
*
|
|
17
|
+
* v0.8 (plan §4.2.13 — hook order per design v5): axis models → view store → zoom → 6
|
|
18
|
+
* autoscrollers (the 6th serves the reference drag) → selection engine → header machines ×2 →
|
|
19
|
+
* header resize → fill handle → point drag (usePointDrag) → the composite drag surface
|
|
20
|
+
* (isDraggingAny / abortDragAny / dragModeAny incl. "point") → cell editor → clipboard →
|
|
21
|
+
* popovers → commands → keyboard. The root owns the transaction counter and the single write
|
|
22
|
+
* funnel `commitEdits(edits, rejection, sheet?) → number | null`, the prop-time validations
|
|
23
|
+
* (labels Escape advisement, shortcuts, colour slots, band palette, CF-without-format), the
|
|
24
|
+
* conditional-format index / memo per (adapter, rules identity, renderVersion), the highlight
|
|
25
|
+
* state (controlled / uncontrolled), the pointing ref edges (editor → engine / headers / point
|
|
26
|
+
* drag / body / corner / zoom dead space), and the ref edges between commands / popovers /
|
|
27
|
+
* clipboard / keyboard (executeCommandRef / openPopoverRef / hideNoteTipRef / pasteRef /
|
|
28
|
+
* pendingPasteOptionsRef / popover anchors).
|
|
29
|
+
*
|
|
30
|
+
* v0.8 (計画 §4.2.13 — フック順は設計 v5): 軸モデル → viewStore → ズーム → オートスクローラー
|
|
31
|
+
* 6 台 (6 台目は参照ドラッグ用) → 選択エンジン → ヘッダー機械 ×2 → ヘッダーリサイズ → fill →
|
|
32
|
+
* 参照ドラッグ (usePointDrag) → 合成ドラッグ面 (isDraggingAny / abortDragAny / "point" 込みの
|
|
33
|
+
* dragModeAny) → エディタ → クリップボード → ポップオーバー → コマンド → キーボード。root は
|
|
34
|
+
* transaction 採番と単一書込漏斗 `commitEdits(edits, rejection, sheet?) → number | null`、prop 時
|
|
35
|
+
* 検証 (labels の Escape 告知 / shortcuts / 色スロット / 帯パレット / CF 単独宣言)、
|
|
36
|
+
* (adapter, 規則 identity, renderVersion) 毎の CF 索引 / memo、強調状態 (制御 / 非制御)、
|
|
37
|
+
* ポインティングの ref 辺 (editor → engine / ヘッダー / 参照ドラッグ / body / コーナー / ズーム死空間)、
|
|
38
|
+
* コマンド / ポップオーバー / クリップボード / キーボード間の ref 辺 (executeCommandRef /
|
|
39
|
+
* openPopoverRef / hideNoteTipRef / pasteRef / pendingPasteOptionsRef / ポップオーバーアンカー) を所有する。
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import type { VirtualGridHandle, VirtualGridRange } from "@aiquants/virtualscroll"
|
|
43
|
+
import type { ProfilerOnRenderCallback } from "react"
|
|
44
|
+
import { forwardRef, type ReactNode, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"
|
|
45
|
+
import type { PasteOptions } from "./clipboard/pasteSpecial.ts"
|
|
46
|
+
import { type ClipInfo, type PasteOutcome, type PasteSheet, useClipboard } from "./clipboard/useClipboard.ts"
|
|
47
|
+
import { type BorderPreset, type CommandRunner, useCommands } from "./commands/useCommands.ts"
|
|
48
|
+
import { type SinkSnapshot, useCellEditor } from "./editor/useCellEditor.ts"
|
|
49
|
+
import type { RenderCellContext } from "./grid/CellView.tsx"
|
|
50
|
+
import { GridBody } from "./grid/GridBody.tsx"
|
|
51
|
+
import { ColumnHeader } from "./header/ColumnHeader.tsx"
|
|
52
|
+
import { columnIndexToName } from "./header/columnName.ts"
|
|
53
|
+
import { RowHeader } from "./header/RowHeader.tsx"
|
|
54
|
+
import { useHeaderResize } from "./header/useHeaderResize.ts"
|
|
55
|
+
import { useHeaderSelection } from "./header/useHeaderSelection.ts"
|
|
56
|
+
import { createEnsureCellVisible } from "./keyboard/ensureCellVisible.ts"
|
|
57
|
+
import { type ShortcutOverrides, type SpreadsheetCommand, validateShortcuts } from "./keyboard/shortcuts.ts"
|
|
58
|
+
import { type CommandDispatch, type MoveActiveInput, useGridKeyboard } from "./keyboard/useGridKeyboard.ts"
|
|
59
|
+
import { useAxisModel } from "./model/axisModel.ts"
|
|
60
|
+
import { type CellCommentInput, normalizeAdapterHyperlink } from "./model/cellAnnotations.ts"
|
|
61
|
+
import { type CellCoord, type CellRange, clampCoord, makeRange, rangeSize, type SpreadsheetOperation } from "./model/cellCoord.ts"
|
|
62
|
+
import { type CellBorder, type CellFormat, type CellFormatPatch, formatCellValue } from "./model/cellFormat.ts"
|
|
63
|
+
import { type EditPaint, IDLE_EDIT_PAINT, normalizeColor, resolveEditPaint } from "./model/cellPaint.ts"
|
|
64
|
+
import { buildCfIndex, type CfIndex, type CfMemo, createCfMemo, resolvePaintedFormat } from "./model/conditionalFormat.ts"
|
|
65
|
+
import { type CellEdit, type EditContext, type EditRejection, type EditSource, lazyCellText, resolveCellAlign, type SpreadsheetDataAdapter } from "./model/dataAdapter.ts"
|
|
66
|
+
import { type DataValidationRule, type ValidationVerdict, validateCellText } from "./model/dataValidation.ts"
|
|
67
|
+
import type { ReferenceAxis } from "./model/formulaRefs.ts"
|
|
68
|
+
import { colToViewportX, rowToViewportY } from "./model/frozenPartition.ts"
|
|
69
|
+
import { resolveLabels, type SpreadsheetLabelOverrides } from "./model/labels.ts"
|
|
70
|
+
import { createViewStore, type ViewStore } from "./model/viewStore.ts"
|
|
71
|
+
import { DEFAULT_HIGHLIGHT_COLORS, type DragFrameMode, type HighlightColors, type HighlightState, type ResizeGuide, SelectionOverlay } from "./overlay/SelectionOverlay.tsx"
|
|
72
|
+
import { useFillHandle } from "./overlay/useFillHandle.ts"
|
|
73
|
+
import { PopoverLayer } from "./popover/PopoverLayer.tsx"
|
|
74
|
+
import { type PopoverAnchorState, type PopoverContext, type PopoverController, type PopoverKind, type PopoverOpenOptions, type PopoverOverrides, usePopovers } from "./popover/usePopovers.ts"
|
|
75
|
+
import { reduceActiveCycle, reduceSelectionExtend, reduceSelectionPress, type SelectionState, selectionStateEquals } from "./selection/selectionState.ts"
|
|
76
|
+
import { useAutoScroller } from "./selection/useAutoScroller.ts"
|
|
77
|
+
import { type PointDragOwner, usePointDrag } from "./selection/usePointDrag.ts"
|
|
78
|
+
import { useSelectionEngine } from "./selection/useSelectionEngine.ts"
|
|
79
|
+
|
|
80
|
+
export type { SelectionState } from "./selection/selectionState.ts"
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Options for the programmatic {@link SpreadsheetHandle.scrollToCell} jump — the render-surface
|
|
84
|
+
* pass-through of the engine's cell-targeted scroll (alignX uses start/end/center, alignY uses
|
|
85
|
+
* top/bottom/center; a frozen column/row target is a no-op on that axis). Selection is NOT
|
|
86
|
+
* changed — this is a pure viewport jump.
|
|
87
|
+
* 命令的セルジャンプ ({@link SpreadsheetHandle.scrollToCell}) のオプション (エンジンのセル狙い
|
|
88
|
+
* スクロールの素通し)。alignX は start / end / center、alignY は top / bottom / center。凍結列 /
|
|
89
|
+
* 行狙いは当該軸で no-op。選択は変えない純粋な viewport ジャンプ。
|
|
90
|
+
* 明示構造型で宣言する (api-extractor は NonNullable<Parameters<...>> 派生を d.ts ロールアップ
|
|
91
|
+
* できず build が落ちる) — エンジン型との双方向一致は Spreadsheet.spec.tsx の型パリティガードが
|
|
92
|
+
* tsc で機械封印する (ズレたら型検査が即赤)。
|
|
93
|
+
*/
|
|
94
|
+
export type ScrollToCellOptions = { alignY?: "top" | "bottom" | "center"; alignX?: "start" | "end" | "center"; offsetX?: number; offsetY?: number }
|
|
95
|
+
|
|
96
|
+
/** Imperative surface (§6.9-8). / 命令 API (§6.9-8)。 */
|
|
97
|
+
export type SpreadsheetHandle = {
|
|
98
|
+
/** Row-height pair seam (§6.9-8). / 行高変更の対シーム。 */
|
|
99
|
+
resizeRow: (row: number, heightPx: number) => void
|
|
100
|
+
/** Column-width resize (§6.9-8). / 列幅変更。 */
|
|
101
|
+
resizeColumn: (col: number, widthPx: number) => void
|
|
102
|
+
/**
|
|
103
|
+
* Programmatic viewport jump to a cell (delegates to the engine's scrollToCell — the
|
|
104
|
+
* floor-and-pin jump API). Selection is unchanged; the deep grid stays reachable at any
|
|
105
|
+
* scale (10^9 px). No-op when the grid is unmounted; an out-of-range coordinate is clamped
|
|
106
|
+
* to the grid edge by the engine (a frozen-axis target is a no-op on that axis).
|
|
107
|
+
* セルへの命令的 viewport ジャンプ (エンジン scrollToCell へ委譲 — floor + アンカー張りの
|
|
108
|
+
* ジャンプ API)。選択は不変で、どの尺度 (10^9 px) でも深部到達性を保つ。グリッド未マウント時は
|
|
109
|
+
* no-op、軸域外の座標はエンジンがグリッド端へクランプ (凍結軸狙いは当該軸で no-op)。
|
|
110
|
+
*/
|
|
111
|
+
scrollToCell: (cell: CellCoord, options?: ScrollToCellOptions) => void
|
|
112
|
+
/** Focuses the sink (`preventScroll` honoured) and re-arms the idle waiting protocol when no session is open (v0.8 §1.10). / シンクへフォーカス (preventScroll 尊重) + 非編集なら再武装 (v0.8 §1.10)。 */
|
|
113
|
+
focus: (options?: { preventScroll?: boolean }) => void
|
|
114
|
+
/** Runs a command exactly as its chord would (v0.8 §1.10); false = unavailable / gate or adapter rejection / commit-gate refusal. / コマンドをチョードと同じ経路で実行 (false = 不可 / 拒否)。 */
|
|
115
|
+
executeCommand: (command: SpreadsheetCommand) => boolean
|
|
116
|
+
/** Applies a format patch to every selected cell (source "format"); RangeError when getCellFormat is undeclared. / 選択全レンジへ書式パッチ (source "format")。getCellFormat 未宣言は RangeError。 */
|
|
117
|
+
applyFormat: (patch: CellFormatPatch) => boolean
|
|
118
|
+
/** Border preset over the selection (default border thin / BORDER_DEFAULT_COLOR). / 選択への罫線プリセット。 */
|
|
119
|
+
setBorders: (preset: BorderPreset, border?: CellBorder) => boolean
|
|
120
|
+
/** Writes the ACTIVE cell's hyperlink (normalised; null clears; source "annotate"). / active セルのリンク書込 (正規化、null で消去)。 */
|
|
121
|
+
setHyperlink: (url: string | null) => boolean
|
|
122
|
+
/** Writes the ACTIVE cell's note (null clears; source "annotate"). / active セルのノート書込。 */
|
|
123
|
+
setNote: (text: string | null) => boolean
|
|
124
|
+
/** Appends a comment to the ACTIVE cell's thread (existing ids kept + the id-less input, one `comments` patch). / active セルのスレッドへ 1 件追加 (既存 id 保持)。 */
|
|
125
|
+
addComment: (input: CellCommentInput) => boolean
|
|
126
|
+
/** Sets (or clears) the validation rule of every selected cell (source "annotate"). / 選択全セルの検証規則の設定 / 消去。 */
|
|
127
|
+
setValidation: (rule: DataValidationRule | null) => boolean
|
|
128
|
+
/** Menu-path paste (`navigator.clipboard.read`): "applied" = the adapter was called, "empty" = nothing written, "rejected" = refused (always when onClipboardUnavailable fired). / メニュー経路の貼り付け。 */
|
|
129
|
+
paste: (options: PasteOptions) => Promise<PasteOutcome>
|
|
130
|
+
/** The confirmed selection (imperative read). / 確定選択の命令読み。 */
|
|
131
|
+
getSelection: () => SelectionState
|
|
132
|
+
/** The adapter's BASE format of a cell (toolbar state) — the getCellFormat capability branch. / セルの base 書式 (getCellFormat の能力分岐)。 */
|
|
133
|
+
getBaseFormat: (cell: CellCoord) => CellFormat | undefined
|
|
134
|
+
/** base ⊕ conditional formats of a cell (the paint the user sees). / base ⊕ 条件付き書式 (描画される書式)。 */
|
|
135
|
+
getPaintedFormat: (cell: CellCoord) => CellFormat | undefined
|
|
136
|
+
/** Opens the in-place editor on the active cell WITHOUT the virtual-keyboard invitation. / active セルの編集開始 (仮想キーボード招待は上げない)。 */
|
|
137
|
+
startEdit: (mode: "edit" | "replace") => void
|
|
138
|
+
/** Opens a popover on the active cell; RangeError when `popovers[kind] === false` or the capability is undeclared. / active セルへポップオーバーを開く (無効 kind は RangeError)。 */
|
|
139
|
+
openPopover: (kind: PopoverKind) => void
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Public props (v0.1.0 contract — §6.9). / 公開 props (v0.1.0 契約 — §6.9)。 */
|
|
143
|
+
export type SpreadsheetProps = {
|
|
144
|
+
/**
|
|
145
|
+
* Identity-stable contract: replacing the adapter object means "sheet switch" and re-slices
|
|
146
|
+
* every visible cell. Do NOT recreate it per render.
|
|
147
|
+
* identity 安定契約 — 差し替えは「シート切替」(全再スライス)。毎レンダー生成は禁止。
|
|
148
|
+
*/
|
|
149
|
+
adapter: SpreadsheetDataAdapter
|
|
150
|
+
defaultRowHeight?: number
|
|
151
|
+
/** Default column width (also the grid's Fenwick baseValue — behaviorOptions.defaultColWidth §7-8). Per-column widths are set via resizeColumn (the v0.3 initialColumnWidth eager initializer is retired). / 既定列幅 (グリッドの Fenwick baseValue も兼ねる §7-8)。列別幅は resizeColumn で設定 (v0.3 の initialColumnWidth eager 初期化は廃止)。 */
|
|
152
|
+
defaultColumnWidth?: number
|
|
153
|
+
/**
|
|
154
|
+
* Freezes the first N columns at the left edge (v0.5 — the VirtualGrid frozenLeadingCols
|
|
155
|
+
* pass-through; LIVE prop). Validation is the engine's fail-fast contract (integer in
|
|
156
|
+
* [0, 128] — RangeError otherwise; values beyond colCount freeze every column). Frozen
|
|
157
|
+
* columns stay visible at any horizontal scroll; scrollToCell / keyboard ensure-visible
|
|
158
|
+
* treat them as always-visible (horizontal no-op). A mid-flight change cancels every
|
|
159
|
+
* in-flight drag gesture without restore (R2 fix — press-time anchors belong to the OLD
|
|
160
|
+
* coordinate partition; mixing them with fresh post-change hit tests can commit fill
|
|
161
|
+
* writes into cells the user never targeted). Selection and the editing session survive.
|
|
162
|
+
* 先頭 N 列の左端凍結 (v0.5 — VirtualGrid frozenLeadingCols の素通し。ライブ prop)。検証は
|
|
163
|
+
* エンジンの fail-fast 契約 ([0, 128] 整数、colCount 超過は全列凍結)。凍結列は横スクロール
|
|
164
|
+
* 位置に依らず常時可視で、scrollToCell / キーボード可視化の横成分は no-op。飛行中の変更は
|
|
165
|
+
* 全ドラッグジェスチャを復元なしで取り消す (R2 是正 — 押下時アンカーは旧パーティション
|
|
166
|
+
* 座標系。変更後の鮮度ヒットテストと混ざると狙っていないセルへの fill 確定になる)。選択と
|
|
167
|
+
* 編集セッションは保持。
|
|
168
|
+
*/
|
|
169
|
+
frozenColumns?: number
|
|
170
|
+
/**
|
|
171
|
+
* Freezes the first N rows at the top (v0.6 — the VirtualGrid frozenLeadingRows pass-through;
|
|
172
|
+
* LIVE prop). Same engine fail-fast contract ([0, 128] integer; > rowCount freezes all).
|
|
173
|
+
* Frozen rows stay visible at any vertical scroll; scrollToCell / ensure-visible treat them
|
|
174
|
+
* as always-visible (vertical no-op). A mid-flight change cancels every in-flight drag.
|
|
175
|
+
* 先頭 N 行の上端凍結 (v0.6 — VirtualGrid frozenLeadingRows の素通し。ライブ prop)。エンジンの
|
|
176
|
+
* fail-fast 契約 ([0, 128] 整数、rowCount 超過は全行凍結) は列と同文。凍結行は縦スクロール
|
|
177
|
+
* 位置に依らず常時可視で、可視化の縦成分は no-op。飛行中の変更は全ドラッグを取り消す。
|
|
178
|
+
*/
|
|
179
|
+
frozenRows?: number
|
|
180
|
+
/**
|
|
181
|
+
* Identity-stable contract (same as adapter): an inline arrow here defeats the CellView memo
|
|
182
|
+
* and turns every host re-render into a full visible re-slice (~800 cells at 16k columns).
|
|
183
|
+
* identity 安定契約 — インライン関数は memo を無効化し毎レンダー全再スライスになる。
|
|
184
|
+
*/
|
|
185
|
+
renderCell?: (ctx: RenderCellContext) => ReactNode
|
|
186
|
+
/**
|
|
187
|
+
* Fires on every confirmed SelectionState change (press replace, extend confirm, cycling,
|
|
188
|
+
* paste replace, Ctrl+A, ...). Never during a drag (state frozen — one fire at pointerup),
|
|
189
|
+
* with ONE documented in-drag firing point: the header subtract reservation converts to a
|
|
190
|
+
* real press on the first movement, firing the deferred press replace mid-drag (total fire
|
|
191
|
+
* count matches v0.1 — conversion 1 + up 1). Never for the initial A1 mount. Independent
|
|
192
|
+
* of the live region's 200ms debounce (firing point: the §6.9-1 setSelection wrapper).
|
|
193
|
+
* 確定 SelectionState が変わるたびに発火。ドラッグ中と初期マウントでは発火しない。明文の
|
|
194
|
+
* ドラッグ中発火点が 1 つ: ヘッダー減算予約の実 press 変換 (初動 move) は遅延 press 置換を
|
|
195
|
+
* ドラッグ中に発火する (発火総数は v0.1 と同一 — 変換 1 + up 1)。
|
|
196
|
+
*/
|
|
197
|
+
onSelectionChange?: (selection: SelectionState) => void
|
|
198
|
+
/** Consumer context menu (§6.4.3-4 / §6.6.1 Shift+F10); keyboard origin carries the active cell viewport rect. / consumer コンテキストメニュー。 */
|
|
199
|
+
onCellContextMenu?: (cell: CellCoord, origin: { kind: "pointer"; event: MouseEvent } | { kind: "keyboard"; cellRect: { top: number; left: number; width: number; height: number } }) => void
|
|
200
|
+
/** Undo request relay (history lives in the consumer — ADR-6). / Undo 要求の中継。 */
|
|
201
|
+
onUndoRequest?: () => void
|
|
202
|
+
/** Redo request relay. / Redo 要求の中継。 */
|
|
203
|
+
onRedoRequest?: () => void
|
|
204
|
+
/** Multi-range copy notice: copy/cut acts on the tail range only and fires this (documented deviation — Excel rejects non-aligned multi-range copy; aligned concat-copy is unsupported). / 複数レンジコピー通知 (末尾レンジのみ実行 + 通知の明示逸脱)。 */
|
|
205
|
+
onCopyMultiRange?: () => void
|
|
206
|
+
/** Rejection notice with reason: "limit" = maxOperationCells exceeded (ADR-12; resize row fan-out gates at maxOperationCells / 10 and autofit at / 4 — §13 design returns), "bounds" = cut-move clipped at the grid edge (§6.5.4-4) OR a row resize that would push the axis-wide hidden-row total past 1,000 (§5.4(b)). 3-arg from v0.1.0 (adding later is breaking). / 拒否理由つき通知 ("bounds" は cut-move の格子端クリップと行非表示軸総数ゲートの 2 系統)。 */
|
|
207
|
+
onOperationRejected?: (op: SpreadsheetOperation, cellCount: number, reason: "limit" | "bounds") => void
|
|
208
|
+
/**
|
|
209
|
+
* Edit rejection notice — fires once per rejected applyEdits with the thrown error and the
|
|
210
|
+
* {@link EditRejection} context: which write path was refused (edit / paste / fill / delete —
|
|
211
|
+
* a cut-move is the Ctrl+V gesture and reports "paste", the same name onOperationRejected
|
|
212
|
+
* uses for it) and, for an in-cell commit, whether the editing session is KEPT (in-grid commit —
|
|
213
|
+
* it stays open with the typed value and focus; Escape cancels, or the value can be fixed)
|
|
214
|
+
* or DISCARDED (grid-exit focusout — the grid closes it as this rejection completes: once
|
|
215
|
+
* the callback returns the typed value is gone and the sink re-armed WITHOUT moving focus,
|
|
216
|
+
* which stays wherever the focusout left it — on the element the user chose, or still on the
|
|
217
|
+
* hidden input after a window blur). Consumers should word their notice from that context; telling the user to
|
|
218
|
+
* "press Escape" after a discarded session is wrong (an idle Escape hands focus out of the
|
|
219
|
+
* grid — WCAG 2.1.2).
|
|
220
|
+
* 編集拒否通知 — 拒否された applyEdits ごとに 1 回、throw されたエラーと
|
|
221
|
+
* {@link EditRejection} 文脈 (拒否経路 edit / paste / fill / delete — cut-move は Ctrl+V の
|
|
222
|
+
* ジェスチャで onOperationRejected と同じ "paste" — と、セル内確定なら
|
|
223
|
+
* セッションが**維持** (グリッド内確定 — 入力値とフォーカスごと開いたまま。Escape で取消 /
|
|
224
|
+
* 修正可) か**破棄** (グリッド外 focusout — この拒否の完了と同時にグリッドが閉じる:
|
|
225
|
+
* コールバックが返った時点で入力値は消え、シンクはフォーカスを動かさずに再武装 — フォーカスは
|
|
226
|
+
* focusout が残した場所 (ユーザーが選んだ要素、ウィンドウ blur なら隠し入力のまま) にある) か)
|
|
227
|
+
* を届ける。消費者は文脈に沿って文言を組む — 破棄済みで「Escape を押せ」は誤案内 (待機中の
|
|
228
|
+
* Escape は WCAG 2.1.2 のフォーカス脱出)。
|
|
229
|
+
*/
|
|
230
|
+
onEditRejected?: (error: unknown, rejection: EditRejection) => void
|
|
231
|
+
/** Change notice for out-of-package writes (undo / collaboration); each increment repaints visible cells (§6.9-2). / パッケージ外経路のデータ変更通知。 */
|
|
232
|
+
dataVersion?: number
|
|
233
|
+
/** Operation cell-count ceiling, default 100,000 (ADR-12). / 操作セル数上限。 */
|
|
234
|
+
maxOperationCells?: number
|
|
235
|
+
/**
|
|
236
|
+
* §11 opt-out (default true): false suppresses the fill-handle node AND inerts its
|
|
237
|
+
* machine. Fill is a WRITE operation (applyEdits) — read-only adapters opt out to avoid
|
|
238
|
+
* the onEditRejected UX on every fill attempt.
|
|
239
|
+
* §11 の opt-out (既定 true)。false はハンドルノードごと非表示 + 機械不活性。fill は書込系
|
|
240
|
+
* (applyEdits) — 読み取り専用 adapter は毎回の onEditRejected UX を避けるため false にする。
|
|
241
|
+
*/
|
|
242
|
+
enableFillHandle?: boolean
|
|
243
|
+
/**
|
|
244
|
+
* §11 opt-out (default true): false suppresses the resize strip / unhide marker DOM
|
|
245
|
+
* entirely (track children, not resident nodes) and inerts the band listeners. NOTE:
|
|
246
|
+
* false also removes the unhide markers — a consumer with hidden tracks loses the
|
|
247
|
+
* in-package restore affordance (imperative resize API only).
|
|
248
|
+
* §11 の opt-out (既定 true)。false はストリップ / マーカーの DOM 生成ごと抑止 + 帯リスナー
|
|
249
|
+
* 不活性。注意: unhide マーカーも消える — 非表示トラックを持つ consumer はパッケージ内の
|
|
250
|
+
* 復元手段を失う (命令 API のみ)。
|
|
251
|
+
*/
|
|
252
|
+
enableResizeUI?: boolean
|
|
253
|
+
/**
|
|
254
|
+
* AT announcement text for the active cell; omit for silence (no built-in strings). `text`
|
|
255
|
+
* is the RAW canonical getCellText; `displayText` is what a sighted user sees (the
|
|
256
|
+
* getCellFormat-formatted string — equals `text` when the cell has no format), so AT can
|
|
257
|
+
* hear "87.0%" for a cell whose raw value is "0.87".
|
|
258
|
+
* AT 向け読み上げ文言 (省略で無音)。`text` は素の正準 getCellText、`displayText` は目視と同じ
|
|
259
|
+
* 表示文字列 (getCellFormat 整形後 — 書式なしセルでは text と同値)。素値 "0.87" の表示
|
|
260
|
+
* "87.0%" をそのまま読み上げられる。
|
|
261
|
+
*/
|
|
262
|
+
formatCellAnnouncement?: (ctx: { row: number; col: number; text: string; displayText: string; columnName: string }) => string
|
|
263
|
+
/** AT announcement for the selection extent ("A1 to C3, 3 rows 3 cols"); omit for silence. / AT 向け選択範囲読み上げ。 */
|
|
264
|
+
formatSelectionAnnouncement?: (ctx: { range: CellRange; anchor: CellCoord; active: CellCoord; rangeRowCount: number; rangeColCount: number; rangeCount: number }) => string
|
|
265
|
+
/**
|
|
266
|
+
* Opt-in AT feedback for commands (v0.8 §1.9): `state` is the toggle's post-state, `null`
|
|
267
|
+
* for non-toggle commands; the text goes to the live region like the other announcements.
|
|
268
|
+
* コマンドの AT 通知 (opt-in、v0.8 §1.9)。`state` はトグル後状態 (非トグルは null)。live region へ。
|
|
269
|
+
*/
|
|
270
|
+
formatCommandAnnouncement?: (ctx: { command: SpreadsheetCommand; state: boolean | null }) => string
|
|
271
|
+
/**
|
|
272
|
+
* UI label vocabulary (v0.8 §1.6) — folds the former `sinkAriaLabel` / `rootAriaLabel` props
|
|
273
|
+
* (clean cut-over). An omitted or explicitly `undefined` key keeps the English default. The
|
|
274
|
+
* resolved `sinkAriaLabel` MUST mention "Escape" (WCAG 2.1.2 escape advisement, §10-4) — a
|
|
275
|
+
* RangeError is thrown during render otherwise.
|
|
276
|
+
* UI ラベル語彙 (v0.8 §1.6) — 旧 sinkAriaLabel / rootAriaLabel prop を畳込み (クリーンカットオーバー)。
|
|
277
|
+
* 省略 / 明示 undefined は英語既定。解決後の sinkAriaLabel は "Escape" を含むこと (違反は render 中
|
|
278
|
+
* RangeError — §10-4)。
|
|
279
|
+
*/
|
|
280
|
+
labels?: SpreadsheetLabelOverrides
|
|
281
|
+
/**
|
|
282
|
+
* Rebindable command chords (v0.8 §1.9 / ADR-17): an array REPLACES that command's default
|
|
283
|
+
* list, `null` unbinds it. Validated at prop time — a non-canonical chord, a modifier-less
|
|
284
|
+
* chord, a single string, an override of a non-rebindable row (`pasteSpecial`), a clash with
|
|
285
|
+
* a non-rebindable built-in row in the command's column, or one chord bound to two commands
|
|
286
|
+
* throws a RangeError during render (`clampZoom` precedent). Consulted through a ref.
|
|
287
|
+
* 再バインド可能なコマンドチョード (v0.8 §1.9 / ADR-17)。配列は既定一覧の置換、null は未束縛。
|
|
288
|
+
* prop 時検証 (非正準 / 修飾なし / 単一 string / 非再バインド行の上書き / 列内予約行との一致 /
|
|
289
|
+
* 二重束縛は render 中 RangeError)。ref 経由で参照。
|
|
290
|
+
*/
|
|
291
|
+
shortcuts?: ShortcutOverrides
|
|
292
|
+
/**
|
|
293
|
+
* Custom colour slots for `fillColorCustom1..5` / `fontColorCustom1..5`: each axis is exactly
|
|
294
|
+
* 5 `#rrggbb` strings (a different length or a non-hex entry throws a RangeError at prop
|
|
295
|
+
* time); an omitted axis uses DEFAULT_CUSTOM_COLORS.
|
|
296
|
+
* 塗り / 文字色のカスタム 5 スロット (各軸ちょうど 5 本の `#rrggbb` — 本数違い / 非 hex は prop 時
|
|
297
|
+
* RangeError。省略軸は DEFAULT_CUSTOM_COLORS)。
|
|
298
|
+
*/
|
|
299
|
+
colors?: { readonly fill?: readonly string[]; readonly font?: readonly string[] }
|
|
300
|
+
/**
|
|
301
|
+
* Host currency / locale for `formatCurrency` and for bare currency symbols in pasted HTML;
|
|
302
|
+
* undeclared ⇒ the `formatCurrency` row passes through and bare symbols are not recovered.
|
|
303
|
+
* `formatCurrency` と貼り付け HTML の裸通貨記号復元に使う通貨 / locale。未宣言なら `formatCurrency`
|
|
304
|
+
* 行は素通しで、裸記号は復元しない。
|
|
305
|
+
*/
|
|
306
|
+
formats?: { readonly currency: string; readonly locale?: string }
|
|
307
|
+
/**
|
|
308
|
+
* Highlight bands of the selection (v0.8 §2.4): controlled value — the same two-phase
|
|
309
|
+
* contract as `zoom`. Default (both props omitted) is all off ⇒ zero band nodes (v0.7 DOM
|
|
310
|
+
* identity). Repainted imperatively on change.
|
|
311
|
+
* 選択の強調帯 (v0.8 §2.4) の制御値 (zoom と同型の二相契約)。両 prop 省略時は全 off = 帯ノード 0
|
|
312
|
+
* (v0.7 DOM 恒等)。変更は命令再描画。
|
|
313
|
+
*/
|
|
314
|
+
highlight?: HighlightState
|
|
315
|
+
/** Initial highlight state for the uncontrolled mode. / 非制御モードの初期強調状態。 */
|
|
316
|
+
defaultHighlight?: HighlightState
|
|
317
|
+
/** Fires when a highlight toggle command requests a new state (a controlled consumer applies it). / 強調トグルコマンドの変更要求通知 (制御 consumer が反映)。 */
|
|
318
|
+
onHighlightChange?: (highlight: HighlightState) => void
|
|
319
|
+
/**
|
|
320
|
+
* Band colours + opacity — identity-stable; written to `--aqsp-band-row / -column /
|
|
321
|
+
* -intersection / -opacity` on the overlay in a layout effect. Each colour must parse
|
|
322
|
+
* (`normalizeColor`) and `opacity` must be a finite number in [0, 1] — RangeError at prop time
|
|
323
|
+
* otherwise. Undeclared ⇒ nothing is written while every band is off (the overlay's style
|
|
324
|
+
* attribute stays byte-identical to v0.7) and DEFAULT_HIGHLIGHT_COLORS (`#0084FF` / `#42FF4F` /
|
|
325
|
+
* `#E1FF00` / 0.2) is written once a band turns on.
|
|
326
|
+
* 帯の色 3 種 + 不透明度 (identity 安定、layout effect で `--aqsp-band-*` へ)。解釈不能な色 /
|
|
327
|
+
* [0, 1] 外の不透明度は prop 時 RangeError。未宣言なら全 off の間は何も書かず (overlay の style は
|
|
328
|
+
* v0.7 と byte 同一)、帯が on になった時点で DEFAULT_HIGHLIGHT_COLORS を書く。
|
|
329
|
+
*/
|
|
330
|
+
highlightColors?: HighlightColors
|
|
331
|
+
/** Per-kind popover opt-out (v0.8 §1.8): `false` ⇒ that kind's commands pass through and `openPopover(kind)` throws a RangeError. / kind 別ポップオーバー opt-out (false は素通し + openPopover は RangeError)。 */
|
|
332
|
+
popovers?: PopoverOverrides
|
|
333
|
+
/** Replaces the default popover components (receives the kind-discriminated context). / 既定ポップオーバーの差替え (kind 判別文脈を受ける)。 */
|
|
334
|
+
renderPopover?: (ctx: PopoverContext) => ReactNode
|
|
335
|
+
/** Clip state notice: armed (`foreign: false`), discarded (`null`), or a cut kept across a sheet switch (`foreign: true`). / クリップ状態通知 (武装 / 破棄 / シート切替を跨いだ cut は foreign)。 */
|
|
336
|
+
onClipChange?: (clip: ClipInfo | null) => void
|
|
337
|
+
/** Hyperlink navigation seam (Alt+Enter, the link popover's Open button, and Ctrl / Meta+click on a link cell — spec 08 §11 D-53) — undeclared ⇒ no navigation, no Open button, no pointer gesture. / ハイパーリンク遷移シーム (Alt+Enter / Open ボタン / リンクセルへの Ctrl / Meta+クリック — 分冊 08 §11 D-53)。未宣言 = 遷移なし・Open ボタンなし・ポインタジェスチャなし。 */
|
|
338
|
+
onHyperlinkOpen?: (url: string, cell: CellCoord) => void
|
|
339
|
+
/** Strict validation rejection notice (v0.8 §1.9) — the commit never reaches the adapter; the message popover shows the rejection. / strict 検証拒否の通知 (commit はアダプタへ届かず、message ポップオーバーが拒否を示す)。 */
|
|
340
|
+
onValidationRejected?: (ctx: { cell: CellCoord; rule: DataValidationRule; text: string; message: string }) => void
|
|
341
|
+
/** Ctrl+Alt+V request with the active cell's viewport rect (visual px, keyboard-origin shape of onCellContextMenu); undeclared ⇒ `paste({ mode: "formats" })`. / Ctrl+Alt+V の要求通知 (未宣言なら書式のみ貼り付け)。 */
|
|
342
|
+
onPasteSpecialRequest?: (origin: { kind: "keyboard"; cellRect: { top: number; left: number; width: number; height: number } }) => void
|
|
343
|
+
/** Menu-path clipboard refusal channel; whenever it fires `paste()` resolves "rejected". / メニュー経路の拒否通知 (発火時 `paste()` は必ず "rejected")。 */
|
|
344
|
+
onClipboardUnavailable?: (cause: "insecure-origin" | "permission-denied" | "empty" | "unreadable") => void
|
|
345
|
+
/** Clock seam for `insertDate` / `insertTime` (declared default: Date.now). / insertDate / insertTime の時計シーム (宣言済み既定 Date.now)。 */
|
|
346
|
+
now?: () => number
|
|
347
|
+
/**
|
|
348
|
+
* Package-level zoom (v0.3): controlled value, clamped to [0.25, 4]. The grid scales
|
|
349
|
+
* visually (transform) while ALL logic stays in logical px; both scrollbars compensate
|
|
350
|
+
* ancestor scale natively. Omit for the uncontrolled mode (defaultZoom + gestures).
|
|
351
|
+
* パッケージズーム (v0.3)。制御値 ([0.25, 4] clamp)。視覚は transform スケール、論理は
|
|
352
|
+
* 論理 px のまま (両スクロールバーは祖先スケール補正内蔵)。省略時は非制御 (defaultZoom +
|
|
353
|
+
* ジェスチャ)。
|
|
354
|
+
*/
|
|
355
|
+
zoom?: number
|
|
356
|
+
/** Initial zoom for the uncontrolled mode (default 1). / 非制御時の初期ズーム (既定 1)。 */
|
|
357
|
+
defaultZoom?: number
|
|
358
|
+
/**
|
|
359
|
+
* Fires when a zoom gesture (Alt+Wheel / Ctrl+Alt+= / Ctrl+Alt++ / Ctrl+Alt+- / Ctrl+Alt+0) requests a
|
|
360
|
+
* new zoom — in the controlled mode the consumer applies it (the gesture alone does not
|
|
361
|
+
* change the view). All four keys match on the PRODUCED character (event.key) regardless
|
|
362
|
+
* of Shift, so layouts that need Shift to type them work as-is (JIS `=` = Shift+`-`,
|
|
363
|
+
* US `+` = Shift+`=`, AZERTY `0` = Shift+`à`); mismatching shifted characters (US `_`, `)`)
|
|
364
|
+
* pass through. Chords carrying the AltGraph modifier state are never claimed (defense in
|
|
365
|
+
* depth: evergreen browsers deliver real AltGr characters WITHOUT ctrl+alt, so typing e.g.
|
|
366
|
+
* Latvian AltGr+F `=` never hits this handler; the guard shields legacy AltGr-emulation
|
|
367
|
+
* shapes only). Gestures are IGNORED while any drag is in
|
|
368
|
+
* flight (press-time rect caches would skew); a controlled consumer changing `zoom`
|
|
369
|
+
* mid-drag inherits that skew. Ctrl+Wheel is deliberately NOT bound (the browser owns it).
|
|
370
|
+
* ズームジェスチャ (Alt+ホイール / Ctrl+Alt+= / + / − / 0 — 4 キーとも Shift 非依存で生成
|
|
371
|
+
* 文字 event.key を信頼、AltGraph 修飾状態つきは非請求の多層防御 — 実 AltGr 印字は
|
|
372
|
+
* 修飾なしで届き本ガード非依存) の変更要求通知 — 制御モードでは
|
|
373
|
+
* consumer が反映する (ジェスチャ単独では変わらない)。飛行中ドラッグの間は要求を無視
|
|
374
|
+
* (押下時 rect キャッシュが歪むため。制御 consumer がドラッグ中に zoom prop を変えた場合は
|
|
375
|
+
* 同じ歪みを負う)。Ctrl+ホイールは意図的に非束縛 (ブラウザ所有)。
|
|
376
|
+
*/
|
|
377
|
+
onZoomChange?: (zoom: number) => void
|
|
378
|
+
testId?: string
|
|
379
|
+
className?: string
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Zoom clamp bounds (v0.3) — the 0.1 gesture grid lives in stepZoom's ×10 integer arithmetic. / ズームの clamp 境界 (v0.3) — ジェスチャの 0.1 グリッドは stepZoom の ×10 整数算術側。 */
|
|
383
|
+
const ZOOM_MIN = 0.25
|
|
384
|
+
const ZOOM_MAX = 4
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Pure clamp — NO quantization (a 0.25 bound is not 0.1-quantizable; consumers may set any
|
|
388
|
+
* value like 1.25). Gestures quantize their own arithmetic before requesting. Non-finite
|
|
389
|
+
* input (NaN / ±Infinity) fail-fasts: Math.min/max would propagate NaN through every ÷z
|
|
390
|
+
* boundary and defeat the same-value gesture bail (unsuppressed onZoomChange(NaN) spam).
|
|
391
|
+
* 純 clamp — 量子化しない (境界 0.25 は 0.1 量子化不能。consumer は 1.25 等の任意値を設定
|
|
392
|
+
* できる)。量子化はジェスチャ側が要求前に自分の加算へ適用する。非有限値 (NaN / ±Infinity) は
|
|
393
|
+
* fail-fast — Math.min/max は NaN を素通しし、全 ÷z 境界の汚染と同値バウンド無効化 (NaN 同士は
|
|
394
|
+
* 不一致) による onZoomChange(NaN) 連射を招くため。
|
|
395
|
+
*/
|
|
396
|
+
const clampZoom = (value: number): number => {
|
|
397
|
+
if (!Number.isFinite(value)) {
|
|
398
|
+
throw new RangeError(`[spreadsheet] zoom must be a finite number (got ${value})`)
|
|
399
|
+
}
|
|
400
|
+
return Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, value))
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* 0.1-grid gesture step (float-drift-free). From an off-grid value (the 0.25 clamp bound or
|
|
405
|
+
* a controlled 1.25) the step lands on the NEAREST detent in the travel direction (0.25 →
|
|
406
|
+
* 0.3 / 0.2) — round-based arithmetic skipped a detent (0.25 → 0.4).
|
|
407
|
+
* ジェスチャの 0.1 グリッド刻み (float ドリフト遮断)。グリッド外の値 (clamp 境界 0.25 や制御
|
|
408
|
+
* 1.25) からは進行方向の最寄りデテントへ着地 (0.25 → 0.3 / 0.2) — 丸め方式はデテントを飛ばす
|
|
409
|
+
* (0.25 → 0.4) ため採らない。
|
|
410
|
+
*/
|
|
411
|
+
const stepZoom = (value: number, direction: 1 | -1): number => (direction > 0 ? (Math.floor(value * 10 + 1e-6) + 1) / 10 : (Math.ceil(value * 10 - 1e-6) - 1) / 10)
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Test-only seam props (NOT part of the public v0.1.0 contract — §8.2 counter injection).
|
|
415
|
+
* テスト専用シーム props (公開 v0.1.0 契約外 — §8.2 カウンタ注入)。
|
|
416
|
+
*/
|
|
417
|
+
type SpreadsheetTestProps = {
|
|
418
|
+
/** Test-only CellView re-render counter; never passed in production. / テスト専用カウンタ。 */
|
|
419
|
+
__onCellRender?: ProfilerOnRenderCallback
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const DEFAULT_ROW_HEIGHT = 24
|
|
423
|
+
const DEFAULT_COLUMN_WIDTH = 100
|
|
424
|
+
const DEFAULT_MAX_OPERATION_CELLS = 100_000
|
|
425
|
+
/** Live region debounce in ms (§6.9-9). / live region デバウンス (ms)。 */
|
|
426
|
+
const ANNOUNCE_DEBOUNCE_MS = 200
|
|
427
|
+
/** Default custom colour slots (v0.8 §1.9) for `fillColorCustom1..5` / `fontColorCustom1..5`. / カスタム色 5 スロットの既定 (v0.8 §1.9)。 */
|
|
428
|
+
export const DEFAULT_CUSTOM_COLORS: readonly string[] = Object.freeze(["#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF"])
|
|
429
|
+
/** Custom colour slot count per axis. / 軸ごとのカスタム色スロット数。 */
|
|
430
|
+
const CUSTOM_COLOR_SLOTS = 5
|
|
431
|
+
/** Strict `#rrggbb` spelling accepted for a custom colour slot. / カスタム色スロットの `#rrggbb` 綴り。 */
|
|
432
|
+
const HEX_COLOR_RE = /^#[0-9A-Fa-f]{6}$/
|
|
433
|
+
/** All-off highlight state (the default ⇒ zero band nodes). / 全 off の強調状態 (既定 = 帯ノード 0)。 */
|
|
434
|
+
const DEFAULT_HIGHLIGHT: HighlightState = Object.freeze({ row: false, column: false, intersection: false })
|
|
435
|
+
/** The shared accepting verdict of the root's validation gate. / 検証ゲートの共有 ok 判定。 */
|
|
436
|
+
const OK_VERDICT: ValidationVerdict = Object.freeze({ ok: true })
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Resolves one custom colour axis: undefined ⇒ DEFAULT_CUSTOM_COLORS; otherwise exactly 5
|
|
440
|
+
* `#rrggbb` strings (fail-fast RangeError — no silent substitution of a bad slot).
|
|
441
|
+
* カスタム色 1 軸の解決: undefined は既定、それ以外はちょうど 5 本の `#rrggbb` (不正は RangeError)。
|
|
442
|
+
*/
|
|
443
|
+
const resolveColorSlots = (axis: "fill" | "font", slots: readonly string[] | undefined): readonly string[] => {
|
|
444
|
+
if (slots === undefined) {
|
|
445
|
+
return DEFAULT_CUSTOM_COLORS
|
|
446
|
+
}
|
|
447
|
+
// 本数はちょうど 5 (不足 / 超過は consumer の設定誤り)
|
|
448
|
+
if (slots.length !== CUSTOM_COLOR_SLOTS) {
|
|
449
|
+
throw new RangeError(`[spreadsheet] colors.${axis} must contain exactly ${CUSTOM_COLOR_SLOTS} colours (got ${slots.length})`)
|
|
450
|
+
}
|
|
451
|
+
// 各スロットは #rrggbb の綴りだけ (コマンドはこの綴りをそのまま書く)
|
|
452
|
+
for (const slot of slots) {
|
|
453
|
+
if (typeof slot !== "string" || !HEX_COLOR_RE.test(slot)) {
|
|
454
|
+
throw new RangeError(`[spreadsheet] colors.${axis} entries must be #rrggbb strings (got ${JSON.stringify(slot)})`)
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return slots
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Validates the band palette at prop time: every colour must parse and the opacity must be a
|
|
462
|
+
* finite number in [0, 1]. Returns the same object (identity kept for the overlay's effect).
|
|
463
|
+
* 帯パレットの prop 時検証: 色 3 種は解釈可能、不透明度は [0, 1] の有限数。同一 object を返す。
|
|
464
|
+
*/
|
|
465
|
+
const validateHighlightColors = (colors: HighlightColors): HighlightColors => {
|
|
466
|
+
for (const key of ["row", "column", "intersection"] as const) {
|
|
467
|
+
if (normalizeColor(colors[key]) === null) {
|
|
468
|
+
throw new RangeError(`[spreadsheet] highlightColors.${key} is not a colour (got ${JSON.stringify(colors[key])})`)
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
if (!(Number.isFinite(colors.opacity) && colors.opacity >= 0 && colors.opacity <= 1)) {
|
|
472
|
+
throw new RangeError(`[spreadsheet] highlightColors.opacity must be a number in [0, 1] (got ${colors.opacity})`)
|
|
473
|
+
}
|
|
474
|
+
return colors
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* The palette handed to the overlay: the declared (validated) palette whenever the consumer set
|
|
479
|
+
* `highlightColors`; DEFAULT_HIGHLIGHT_COLORS while any band is on without a declaration; undefined
|
|
480
|
+
* (no `--aqsp-band-*` write — the overlay's style attribute stays the v0.7 one) when nothing is
|
|
481
|
+
* declared and every band is off. Explicit branches, no `??`.
|
|
482
|
+
* overlay へ渡すパレット: consumer が `highlightColors` を宣言していればその検証済みパレット、未宣言でも
|
|
483
|
+
* 帯が 1 つでも on なら DEFAULT_HIGHLIGHT_COLORS、未宣言 + 全 off は undefined (`--aqsp-band-*` を書かず
|
|
484
|
+
* overlay の style は v0.7 のまま)。明示分岐で `??` は書かない。
|
|
485
|
+
*/
|
|
486
|
+
const resolveOverlayPalette = (declared: HighlightColors | undefined, highlight: HighlightState): HighlightColors | undefined => {
|
|
487
|
+
// 宣言済みパレットは強調状態に依らず overlay へ (prop 時検証済み・identity 安定)
|
|
488
|
+
if (declared !== undefined) {
|
|
489
|
+
return declared
|
|
490
|
+
}
|
|
491
|
+
// 未宣言: 帯が 1 つでも on なら既定パレット、全 off は書込なし (v0.7 DOM 恒等)
|
|
492
|
+
if (highlight.row || highlight.column || highlight.intersection) {
|
|
493
|
+
return DEFAULT_HIGHLIGHT_COLORS
|
|
494
|
+
}
|
|
495
|
+
return undefined
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* A paste commit MUST carry its sheet context (ADR-16) — a missing one is a wiring bug of the
|
|
500
|
+
* caller, refused before anything is allocated.
|
|
501
|
+
* 貼り付け commit はシート文脈必須 (ADR-16) — 欠落は呼び手の結線バグで、採番前に拒否する。
|
|
502
|
+
*/
|
|
503
|
+
const requirePasteSheet = (sheet: PasteSheet | undefined): PasteSheet => {
|
|
504
|
+
if (sheet === undefined) {
|
|
505
|
+
throw new RangeError("[spreadsheet] commitEdits: a paste commit requires its sheet context (sameSheet / conditionalFormats)")
|
|
506
|
+
}
|
|
507
|
+
return sheet
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** Initial A1 selection. / 初期 A1 選択。 */
|
|
511
|
+
const INITIAL_SELECTION: SelectionState = {
|
|
512
|
+
ranges: [makeRange({ row: 0, col: 0 }, { row: 0, col: 0 })],
|
|
513
|
+
anchor: { row: 0, col: 0 },
|
|
514
|
+
active: { row: 0, col: 0 },
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* The spreadsheet root component (wiring only).
|
|
519
|
+
* スプレッドシート統合ルート (結線のみ)。
|
|
520
|
+
*/
|
|
521
|
+
export const Spreadsheet = forwardRef<SpreadsheetHandle, SpreadsheetProps & SpreadsheetTestProps>(function Spreadsheet(
|
|
522
|
+
{
|
|
523
|
+
adapter,
|
|
524
|
+
defaultRowHeight = DEFAULT_ROW_HEIGHT,
|
|
525
|
+
defaultColumnWidth = DEFAULT_COLUMN_WIDTH,
|
|
526
|
+
frozenColumns = 0,
|
|
527
|
+
frozenRows = 0,
|
|
528
|
+
renderCell,
|
|
529
|
+
onSelectionChange,
|
|
530
|
+
onCellContextMenu,
|
|
531
|
+
onUndoRequest,
|
|
532
|
+
onRedoRequest,
|
|
533
|
+
onCopyMultiRange,
|
|
534
|
+
onOperationRejected,
|
|
535
|
+
onEditRejected,
|
|
536
|
+
dataVersion,
|
|
537
|
+
maxOperationCells = DEFAULT_MAX_OPERATION_CELLS,
|
|
538
|
+
enableFillHandle = true,
|
|
539
|
+
enableResizeUI = true,
|
|
540
|
+
formatCellAnnouncement,
|
|
541
|
+
formatSelectionAnnouncement,
|
|
542
|
+
formatCommandAnnouncement,
|
|
543
|
+
labels,
|
|
544
|
+
shortcuts,
|
|
545
|
+
colors,
|
|
546
|
+
formats,
|
|
547
|
+
highlight,
|
|
548
|
+
defaultHighlight,
|
|
549
|
+
onHighlightChange,
|
|
550
|
+
highlightColors,
|
|
551
|
+
popovers: popoversProp,
|
|
552
|
+
renderPopover,
|
|
553
|
+
onClipChange,
|
|
554
|
+
onHyperlinkOpen,
|
|
555
|
+
onValidationRejected,
|
|
556
|
+
onPasteSpecialRequest,
|
|
557
|
+
onClipboardUnavailable,
|
|
558
|
+
now = Date.now,
|
|
559
|
+
zoom,
|
|
560
|
+
defaultZoom,
|
|
561
|
+
onZoomChange,
|
|
562
|
+
testId,
|
|
563
|
+
className,
|
|
564
|
+
__onCellRender,
|
|
565
|
+
}: SpreadsheetProps & SpreadsheetTestProps,
|
|
566
|
+
ref,
|
|
567
|
+
): ReactNode {
|
|
568
|
+
// §6.9-1: フックは常に呼び、空グリッドはレンダー分岐でのみ返す (indexAt fail-fast の前提)
|
|
569
|
+
const rowAxis = useAxisModel(adapter.rowCount, defaultRowHeight)
|
|
570
|
+
const colAxis = useAxisModel(adapter.colCount, defaultColumnWidth)
|
|
571
|
+
|
|
572
|
+
// viewStore は遅延初期化 (useRef(createViewStore()) は毎レンダーでストアを作って捨てる)
|
|
573
|
+
const viewStoreRef = useRef<ViewStore | null>(null)
|
|
574
|
+
if (viewStoreRef.current === null) {
|
|
575
|
+
viewStoreRef.current = createViewStore()
|
|
576
|
+
}
|
|
577
|
+
const viewStore = viewStoreRef.current
|
|
578
|
+
|
|
579
|
+
// ---- prop 時検証 (v0.8 §4.2.13 — clampZoom と同じく render 中に RangeError を投げる) ----
|
|
580
|
+
// ラベルの畳込み: resolveLabels が Escape 告知 (§10-4) を検証する (RC22)
|
|
581
|
+
const resolvedLabels = useMemo(() => resolveLabels(labels), [labels])
|
|
582
|
+
// コマンド表: validateShortcuts の 5 条件 (+ 未知キー) は consumer の設定誤り — render 中に表面化
|
|
583
|
+
const commandTable = useMemo(() => validateShortcuts(shortcuts), [shortcuts])
|
|
584
|
+
// カスタム色スロット (各軸ちょうど 5 本の #rrggbb)
|
|
585
|
+
const resolvedColors = useMemo(() => ({ fill: resolveColorSlots("fill", colors === undefined ? undefined : colors.fill), font: resolveColorSlots("font", colors === undefined ? undefined : colors.font) }), [colors])
|
|
586
|
+
// 帯パレットの prop 時検証 (identity 安定): 宣言時だけ検証し、未宣言は undefined のまま — overlay へ渡す
|
|
587
|
+
// パレットは強調状態が決まってから resolveOverlayPalette で導出する
|
|
588
|
+
const declaredHighlightColors = useMemo(() => (highlightColors === undefined ? undefined : validateHighlightColors(highlightColors)), [highlightColors])
|
|
589
|
+
// 条件付き書式は getCellFormat の上に載る能力 (design v4 §2.3): 単独宣言は塗る先が無い結線誤り
|
|
590
|
+
if (adapter.getConditionalFormats !== undefined && adapter.getCellFormat === undefined) {
|
|
591
|
+
throw new RangeError("[spreadsheet] getConditionalFormats requires getCellFormat")
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// ---- renderVersion (editVersion 購読 + dataVersion) と CF 索引 / memo (v0.8 §2.1) ----
|
|
595
|
+
// GridBody と同じ合流購読 (rAF 合流) — 親が先に描画するため、子のスライスが読む .current は常に新
|
|
596
|
+
const getEditSnapshot = useCallback(() => viewStore.get().editVersion, [viewStore])
|
|
597
|
+
const subscribeEdit = useCallback((listener: () => void) => viewStore.subscribeCoalesced(listener), [viewStore])
|
|
598
|
+
const editVersion = useSyncExternalStore(subscribeEdit, getEditSnapshot, getEditSnapshot)
|
|
599
|
+
const renderVersion = editVersion + (dataVersion === undefined ? 0 : dataVersion)
|
|
600
|
+
// 規則配列は identity 安定契約 (変更は dataVersion で公開) — 能力分岐で null、?? は書かない
|
|
601
|
+
const cfRules = adapter.getConditionalFormats === undefined ? null : adapter.getConditionalFormats()
|
|
602
|
+
// (adapter, 規則 identity, renderVersion) 毎に索引 1 回構築 (O(1)、バケットは遅延) + memo 新規
|
|
603
|
+
const cfState = useMemo(() => {
|
|
604
|
+
// adapter / renderVersion は memo キーとしてだけ参照する (同じ規則配列でもシート切替 / 編集で極値・数式 memo は捨てる)
|
|
605
|
+
void adapter
|
|
606
|
+
void renderVersion
|
|
607
|
+
return { index: cfRules === null ? null : buildCfIndex(cfRules), memo: createCfMemo() }
|
|
608
|
+
}, [adapter, cfRules, renderVersion])
|
|
609
|
+
// GridBody / エディタ / autofit が .current で鮮度読みする ref (レンダー時代入 — paramsRef と同じ規律)
|
|
610
|
+
const cfIndexRef = useRef<CfIndex | null>(cfState.index)
|
|
611
|
+
cfIndexRef.current = cfState.index
|
|
612
|
+
const cfMemoRef = useRef<CfMemo>(cfState.memo)
|
|
613
|
+
cfMemoRef.current = cfState.memo
|
|
614
|
+
/** base ⊕ CF of a cell through the single seam (getCellFormat capability branch is the caller's). / セルの base ⊕ CF (単一シーム — getCellFormat の能力分岐は呼び手)。 */
|
|
615
|
+
const paintedFormatOf = useCallback((row: number, col: number, readText: () => string): CellFormat | undefined => resolvePaintedFormat(adapter, cfIndexRef.current, cfMemoRef.current, row, col, readText), [adapter])
|
|
616
|
+
|
|
617
|
+
const rootRef = useRef<HTMLDivElement | null>(null)
|
|
618
|
+
// ---- ズーム (v0.3): 制御 (zoom prop) / 非制御 (内部 state) の標準二相。実効値は常に clamp ----
|
|
619
|
+
const [internalZoom, setInternalZoom] = useState(() => clampZoom(defaultZoom ?? 1))
|
|
620
|
+
const zoomValue = clampZoom(zoom ?? internalZoom)
|
|
621
|
+
const zoomRef = useRef(zoomValue)
|
|
622
|
+
zoomRef.current = zoomValue
|
|
623
|
+
// ジェスチャ連打はレンダーフラッシュ前に複数イベントが届く — zoomRef (レンダー同期 = 実効値)
|
|
624
|
+
// だけを基点にすると 2 発目が古い値から刻んで段飛ばし + 同値 onZoomChange の二重発火になる。
|
|
625
|
+
// 要求済み値を先行追跡し、レンダーで実効値へ再同期する (制御モードで親が要求を拒否しても
|
|
626
|
+
// 次レンダーで実値へ復帰)。機械側の ÷z / ×z は実効値 zoomRef のみを読む — 混用しない
|
|
627
|
+
const requestedZoomRef = useRef(zoomValue)
|
|
628
|
+
requestedZoomRef.current = zoomValue
|
|
629
|
+
/** Drag-flight probe indirection (filled after the machines mount). / 飛行中ドラッグ照会の間接 ref (機械構築後に充填)。 */
|
|
630
|
+
const isDraggingAnyRef = useRef<(() => boolean) | null>(null)
|
|
631
|
+
const onZoomChangeRef = useRef(onZoomChange)
|
|
632
|
+
onZoomChangeRef.current = onZoomChange
|
|
633
|
+
const zoomControlledRef = useRef(zoom !== undefined)
|
|
634
|
+
zoomControlledRef.current = zoom !== undefined
|
|
635
|
+
/** Applies a gesture's zoom request (controlled mode only notifies). / ジェスチャのズーム要求適用 (制御モードは通知のみ)。 */
|
|
636
|
+
const requestZoom = useCallback((next: number) => {
|
|
637
|
+
const clamped = clampZoom(next)
|
|
638
|
+
if (clamped === requestedZoomRef.current) {
|
|
639
|
+
return
|
|
640
|
+
}
|
|
641
|
+
requestedZoomRef.current = clamped
|
|
642
|
+
if (!zoomControlledRef.current) {
|
|
643
|
+
setInternalZoom(clamped)
|
|
644
|
+
}
|
|
645
|
+
onZoomChangeRef.current?.(clamped)
|
|
646
|
+
}, [])
|
|
647
|
+
// ズームジェスチャ (v0.3): Alt+ホイールと Ctrl+Alt+= / + / − / 0。Ctrl+ホイールはブラウザ所有の
|
|
648
|
+
// ため意図的に非束縛。wheel は React 合成でなく native 非 passive で張る (preventDefault 必須)。
|
|
649
|
+
// 空グリッド分岐の div は rootRef を持たない — 空 → 非空遷移で deps が変わり実 DOM が揃った後に
|
|
650
|
+
// 再結線する (キーボード hook の enabled 規律と同型。これ無しでは空マウント時の null bail が
|
|
651
|
+
// 恒久固定され、ジェスチャが永久に死ぬ — R2(E) blocking)
|
|
652
|
+
const isEmpty = adapter.rowCount === 0 || adapter.colCount === 0
|
|
653
|
+
const zoomGestureEnabled = !isEmpty
|
|
654
|
+
useEffect(() => {
|
|
655
|
+
const root = rootRef.current
|
|
656
|
+
if (!zoomGestureEnabled || root === null) {
|
|
657
|
+
return
|
|
658
|
+
}
|
|
659
|
+
const onWheel = (event: WheelEvent) => {
|
|
660
|
+
// 外側 (document / window の capture 段はここより先に走る) が請求済みのイベントは
|
|
661
|
+
// 尊重して素通し — シンクキーマップの defaultPrevented 規律の対称
|
|
662
|
+
if (event.defaultPrevented) {
|
|
663
|
+
return
|
|
664
|
+
}
|
|
665
|
+
if (!event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
|
|
666
|
+
return
|
|
667
|
+
}
|
|
668
|
+
// 縦成分ゼロ (チルトホイール / 横パン) はズーム意図でない — 消費せず素通しし、
|
|
669
|
+
// 横スクロールをブラウザへ返す (0 を負方向に丸めるとズームアウト誤発動 + 横パン窃取)
|
|
670
|
+
if (event.deltaY === 0) {
|
|
671
|
+
return
|
|
672
|
+
}
|
|
673
|
+
// 認識した Alt+ホイールは preventDefault + stopPropagation で完全請求する。役割は
|
|
674
|
+
// 別々: 「スクロール余地面での恒久死」(R8(E) blocking) を防ぐのは capture 段配置
|
|
675
|
+
// (バブル段だとペインが先に preventDefault し尊重ガードが自ペインを外部請求と誤認)、
|
|
676
|
+
// stopPropagation はその上でペイン (Ctrl のみ免除・defaultPrevented 非考慮の wheel
|
|
677
|
+
// 消費) と 3 本の wheel ブリッジへ後続配送させない唯一の防壁 — 落とすとズームは
|
|
678
|
+
// 効くが**スクロールも同時に走る二重実行**へ退行する ([M156])
|
|
679
|
+
event.preventDefault()
|
|
680
|
+
event.stopPropagation()
|
|
681
|
+
// 飛行中ドラッグの押下時 gBCR キャッシュは倍率変更で歪む — ドラッグ中は要求ごと無視
|
|
682
|
+
// (請求は維持 — スクロールへも流さない)
|
|
683
|
+
if (isDraggingAnyRef.current?.()) {
|
|
684
|
+
return
|
|
685
|
+
}
|
|
686
|
+
requestZoom(stepZoom(requestedZoomRef.current, event.deltaY < 0 ? 1 : -1))
|
|
687
|
+
}
|
|
688
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
689
|
+
// 外側請求済みは素通し (wheel 側と同じ規律 — ホストの capture ショートカットと二重発火しない)
|
|
690
|
+
if (event.defaultPrevented) {
|
|
691
|
+
return
|
|
692
|
+
}
|
|
693
|
+
if (!(event.ctrlKey && event.altKey) || event.metaKey) {
|
|
694
|
+
return
|
|
695
|
+
}
|
|
696
|
+
// AltGraph 状態つきの ctrl+alt は文字入力側へ譲る**多層防御** — evergreen ブラウザ
|
|
697
|
+
// (Chromium 68+ / Firefox 63+) は AltGr と Ctrl/Alt を相互排他にするため実 AltGr
|
|
698
|
+
// 印字は修飾なしで届き本ガードに依存しない (R3(I) がエンジン一次資料で確定)。守るのは
|
|
699
|
+
// レガシー AltGr エミュレーション形状 (旧 Firefox / 合成イベント / 非準拠組込ホスト)
|
|
700
|
+
// のみ — その形状ではラトビア語 (`=` = AltGr+F のみ) 等でズーム請求が入力を奪い得る。
|
|
701
|
+
// 挙動コストゼロ (リテラル Ctrl+Alt 和音は AltGraph を持たない) のため維持
|
|
702
|
+
// ([M176] は現実形状で等価変異 — ⚠️ 登記、台帳 Phase I)
|
|
703
|
+
if (event.getModifierState("AltGraph")) {
|
|
704
|
+
return
|
|
705
|
+
}
|
|
706
|
+
// ズームキー以外は素通し。認識したジェスチャは常に消費し、飛行中ドラッグは効果のみ
|
|
707
|
+
// 無視 (wheel 側と同じ preventDefault → ガードの順序)。Shift は見ない — 4 キーとも
|
|
708
|
+
// event.key (生成文字) だけを信頼する: JIS は Shift+`-` でしか `=` を打てず、AZERTY は
|
|
709
|
+
// Shift+`à` でしか `0` を打てない (Shift を拒否するとメイン行のズームイン / リセットが
|
|
710
|
+
// 恒久到達不能)。US の Shift+`=` は `+` を生みブラウザ同様ズームイン、US の Shift+`-`
|
|
711
|
+
// (`_`) や Shift+`0` (`)`) は不一致で素通し — 生成文字と AltGraph 判定の組がレイアウト
|
|
712
|
+
// 差を吸収する。帰結として非 AltGr の束縛は配列依存 (例: 独配列の Shift+0 = "=" は
|
|
713
|
+
// ズームイン) — 配列非依存の和音が必要なホストは event.code で先に請求すること。
|
|
714
|
+
// 素通しした和音は ctrl+alt 印字形状の遅延開始 (useGridKeyboard §6.6.2-4) により実文字が到着
|
|
715
|
+
// しない限り編集セッションを開かない
|
|
716
|
+
const next = event.key === "=" || event.key === "+" ? stepZoom(requestedZoomRef.current, 1) : event.key === "-" ? stepZoom(requestedZoomRef.current, -1) : event.key === "0" ? 1 : null
|
|
717
|
+
if (next === null) {
|
|
718
|
+
return
|
|
719
|
+
}
|
|
720
|
+
// 認識したズームキーは preventDefault + stopPropagation で完全請求する。R6(E)
|
|
721
|
+
// blocking (当時のシンク AltGr 印字規則が keydown 即 startEdit で空置換セッションを
|
|
722
|
+
// 開いた実欠陥) の主遮断だった capture 配置 + シンク側 defaultPrevented ガードは、
|
|
723
|
+
// v0.3.3 の ctrl+alt 印字形状の遅延開始 (useGridKeyboard §3.11) が破壊の観測面そのものを消した
|
|
724
|
+
// ため現在は**多層防御** — bubble 退行しても保留フラグが立つだけで実害に至らない
|
|
725
|
+
// ([M151] は等価変異化で ⚠️ 降格 — 台帳 Phase I / R2(I) 注)。stopPropagation は
|
|
726
|
+
// ガードを持たないリスナーへの予備ベルト (単独撤去は green 生存)
|
|
727
|
+
event.preventDefault()
|
|
728
|
+
event.stopPropagation()
|
|
729
|
+
if (isDraggingAnyRef.current?.()) {
|
|
730
|
+
return
|
|
731
|
+
}
|
|
732
|
+
requestZoom(next)
|
|
733
|
+
}
|
|
734
|
+
// 両リスナーとも capture 段 — wheel はペイン / ブリッジ (バブル段の消費者) より、keydown は
|
|
735
|
+
// シンク (target 段) の印字規則より先に走らないと請求が間に合わない
|
|
736
|
+
root.addEventListener("wheel", onWheel, { passive: false, capture: true })
|
|
737
|
+
root.addEventListener("keydown", onKeyDown, true)
|
|
738
|
+
return () => {
|
|
739
|
+
root.removeEventListener("wheel", onWheel, { capture: true })
|
|
740
|
+
root.removeEventListener("keydown", onKeyDown, true)
|
|
741
|
+
}
|
|
742
|
+
}, [requestZoom, zoomGestureEnabled])
|
|
743
|
+
const bodyRef = useRef<HTMLDivElement | null>(null)
|
|
744
|
+
const handleRef = useRef<VirtualGridHandle | null>(null)
|
|
745
|
+
const sinkRef = useRef<HTMLTextAreaElement | null>(null)
|
|
746
|
+
const rowTrackRef = useRef<HTMLDivElement | null>(null)
|
|
747
|
+
const windowOriginYRef = useRef(0)
|
|
748
|
+
const colTrackRef = useRef<HTMLDivElement | null>(null)
|
|
749
|
+
const windowOriginXRef = useRef(0)
|
|
750
|
+
const liveRegionRef = useRef<HTMLDivElement | null>(null)
|
|
751
|
+
|
|
752
|
+
// ---- 選択状態 (React state + 命令読み ref — §6.9-1) ----
|
|
753
|
+
const [selection, setSelectionState] = useState<SelectionState>(INITIAL_SELECTION)
|
|
754
|
+
const selectionRef = useRef(selection)
|
|
755
|
+
const getSelection = useCallback(() => selectionRef.current, [])
|
|
756
|
+
|
|
757
|
+
// ---- 強調帯 (v0.8 §2.4): 制御 (highlight prop) / 非制御 (内部 state) の zoom と同型の二相。?? は書かず明示分岐 ----
|
|
758
|
+
const [internalHighlight, setInternalHighlight] = useState<HighlightState>(() => (defaultHighlight === undefined ? DEFAULT_HIGHLIGHT : defaultHighlight))
|
|
759
|
+
const highlightValue = highlight !== undefined ? highlight : internalHighlight
|
|
760
|
+
/** Live highlight state read by the overlay at repaint and by the command runner (render-time assignment). / overlay の repaint とコマンド実行が読む強調状態 (レンダー時代入)。 */
|
|
761
|
+
const highlightRef = useRef<HighlightState>(highlightValue)
|
|
762
|
+
highlightRef.current = highlightValue
|
|
763
|
+
// overlay へ渡すパレット (v0.7 DOM 恒等): 宣言済み or 帯が 1 つでも on のときだけ — 未宣言 + 全 off では
|
|
764
|
+
// undefined で overlay は --aqsp-band-* を書かない (overlay の layout effect が唯一の書き手)
|
|
765
|
+
const overlayHighlightColors = resolveOverlayPalette(declaredHighlightColors, highlightValue)
|
|
766
|
+
const highlightControlledRef = useRef(highlight !== undefined)
|
|
767
|
+
highlightControlledRef.current = highlight !== undefined
|
|
768
|
+
const onHighlightChangeRef = useRef(onHighlightChange)
|
|
769
|
+
onHighlightChangeRef.current = onHighlightChange
|
|
770
|
+
/** Applies a toggle command's highlight request (controlled mode only notifies — the repaint follows the resolved value). / 強調トグルの要求適用 (制御モードは通知のみ — repaint は解決値に追随)。 */
|
|
771
|
+
const requestHighlight = useCallback((next: HighlightState) => {
|
|
772
|
+
if (!highlightControlledRef.current) {
|
|
773
|
+
setInternalHighlight(next)
|
|
774
|
+
}
|
|
775
|
+
onHighlightChangeRef.current?.(next)
|
|
776
|
+
}, [])
|
|
777
|
+
|
|
778
|
+
// ---- 命令再描画の間接 ref (構築循環切り — §6.9-7) ----
|
|
779
|
+
const repaintOverlayRef = useRef<(() => void) | null>(null)
|
|
780
|
+
// 配送モードは v0.8 で "point" (参照ドラッグ) を含む — overlay の DragFrameMode と同じ語彙
|
|
781
|
+
const dragFrameRef = useRef<((range: CellRange | null, mode: DragFrameMode) => void) | null>(null)
|
|
782
|
+
/** Editor seam: ref indirection filled later by Phase 3's useCellEditor return (§6.9-1-(5)). / Phase 3 の useCellEditor 戻り値を後から格納する ref 間接。 */
|
|
783
|
+
const isEditingRef = useRef<(() => boolean) | null>(null)
|
|
784
|
+
// ---- v0.8 のポインティング ref 辺 (§4.2.13 表): editor が書き、engine / header / point-drag / GridBody が読む ----
|
|
785
|
+
const getPointingRef = useRef<() => boolean>(() => false)
|
|
786
|
+
const insertReferenceRef = useRef<(target: CellCoord | CellRange, axis: ReferenceAxis) => void>(() => {})
|
|
787
|
+
const captureSinkRef = useRef<() => SinkSnapshot | null>(() => null)
|
|
788
|
+
const restoreSinkRef = useRef<(snapshot: SinkSnapshot) => void>(() => {})
|
|
789
|
+
/** Live pointing predicate (identity-stable — evaluated on the sink at call time, never cached). / 生きたポインティング述語 (identity 安定 — 呼び出し時にシンクを評価、キャッシュしない)。 */
|
|
790
|
+
const isPointing = useCallback(() => getPointingRef.current(), [])
|
|
791
|
+
/** Pointing seams for the header machines (identity-stable ref indirections). / ヘッダー機械向けのポインティングシーム (ref 間接)。 */
|
|
792
|
+
const pointingSeams = useMemo(() => ({ getPointing: () => getPointingRef.current(), insertReference: (target: CellCoord | CellRange, axis: ReferenceAxis) => insertReferenceRef.current(target, axis) }), [])
|
|
793
|
+
/** Edit paint channel (editor writes, overlay reads for the sink line box / vertical alignment). / エディタ塗りのチャネル (editor が書き overlay が読む)。 */
|
|
794
|
+
const editPaintRef = useRef<EditPaint>(IDLE_EDIT_PAINT)
|
|
795
|
+
/** Ctrl+Shift+V arming (keyboard writes, clipboard reads + clears). / Ctrl+Shift+V の武装 (keyboard が書き clipboard が読んで消す)。 */
|
|
796
|
+
const pendingPasteOptionsRef = useRef<PasteOptions | null>(null)
|
|
797
|
+
/** Popover anchor cells (popovers write, overlay places the nodes). / ポップオーバーアンカー (popovers が書き overlay が配置)。 */
|
|
798
|
+
const popoverAnchorsRef = useRef<PopoverAnchorState>({ interactive: null, tip: null })
|
|
799
|
+
const interactiveAnchorRef = useRef<HTMLDivElement | null>(null)
|
|
800
|
+
const tipAnchorRef = useRef<HTMLDivElement | null>(null)
|
|
801
|
+
// ---- コマンド / ポップオーバー / クリップボードの ref 辺 (各フック構築後にレンダー時代入) ----
|
|
802
|
+
const openPopoverRef = useRef<(kind: PopoverKind, options?: PopoverOpenOptions) => void>(() => {})
|
|
803
|
+
const hideNoteTipRef = useRef<() => boolean>(() => false)
|
|
804
|
+
/** Popover controller read by effects / the editor's validation gate without re-wiring (render-time assignment after usePopovers). / effect と検証ゲートが再結線なしに読むコントローラ (usePopovers 後にレンダー時代入)。 */
|
|
805
|
+
const popoversRef = useRef<PopoverController | null>(null)
|
|
806
|
+
/**
|
|
807
|
+
* Closes the `message` popover only when it is showing a strict-rejection notice (returns
|
|
808
|
+
* true when one was closed — the Escape chain, cancelEdit and a session start consume it,
|
|
809
|
+
* 分冊 08 §4.3). The passive inputMessage popover always returns false: its lifecycle is
|
|
810
|
+
* owned by the active-cell effect alone (Excel keeps the input message while the cell
|
|
811
|
+
* stays selected, editing included).
|
|
812
|
+
* strict 拒否を表示中の message ポップオーバーだけを閉じる (閉じたら true — Escape チェーン /
|
|
813
|
+
* cancelEdit / セッション開始が消費する。分冊 08 §4.3)。受動の inputMessage は常に false —
|
|
814
|
+
* その生存は active セル効果だけが決める (Excel は編集中も含め選択中ずっと入力メッセージを出す)。
|
|
815
|
+
*/
|
|
816
|
+
const closeRejectedMessage = useCallback((): boolean => {
|
|
817
|
+
const controller = popoversRef.current
|
|
818
|
+
if (controller === null || controller.isOpen() !== "message") {
|
|
819
|
+
return false
|
|
820
|
+
}
|
|
821
|
+
const ctx = controller.context
|
|
822
|
+
// 受動 inputMessage を巻き込まない — stale になり得るのは拒否告知だけ (Excel は拒否ダイアログを持ち越さない)
|
|
823
|
+
if (ctx === null || ctx.kind !== "message" || !ctx.current.rejected) {
|
|
824
|
+
return false
|
|
825
|
+
}
|
|
826
|
+
controller.close()
|
|
827
|
+
return true
|
|
828
|
+
}, [])
|
|
829
|
+
const executeCommandRef = useRef<(command: SpreadsheetCommand) => boolean>(() => false)
|
|
830
|
+
const isCommandAvailableRef = useRef<(command: SpreadsheetCommand) => boolean>(() => false)
|
|
831
|
+
const commandsRef = useRef<CommandRunner | null>(null)
|
|
832
|
+
const pasteRef = useRef<(options: PasteOptions) => Promise<PasteOutcome>>(() => Promise.resolve("rejected"))
|
|
833
|
+
|
|
834
|
+
/** Stable indirection for repaintOverlay (owner: Spreadsheet §6.9-7). / repaint の安定間接。 */
|
|
835
|
+
const repaintOverlay = useCallback(() => {
|
|
836
|
+
repaintOverlayRef.current?.()
|
|
837
|
+
}, [])
|
|
838
|
+
|
|
839
|
+
// ---- 共有ヘルパ (所有点はここ 1 箇所 — §6.9-7) ----
|
|
840
|
+
|
|
841
|
+
/** Focus recovery only (never re-arms — §6.7.1 rule); `preventScroll` honoured for the popover return path. / フォーカス回収のみ (rearm は内包しない)。preventScroll はポップオーバー復帰経路が使う。 */
|
|
842
|
+
const focusSink = useCallback((options?: { preventScroll?: boolean }) => {
|
|
843
|
+
sinkRef.current?.focus(options)
|
|
844
|
+
}, [])
|
|
845
|
+
|
|
846
|
+
// ---- WCAG 2.1.2 脱出の旗 (§10-4): Escape の root.focus() だけが root に留まれる内部移動 ----
|
|
847
|
+
const escapeHandOffRef = useRef(false)
|
|
848
|
+
/** Flagged Escape hand-off to the root group — the one in-root focus move onRootFocus lets rest on the root. / 旗つきの Escape 脱出 (onRootFocus が root 滞在を許す唯一の内部移動)。 */
|
|
849
|
+
const handOffFocusToRoot = useCallback(() => {
|
|
850
|
+
const root = rootRef.current
|
|
851
|
+
if (root === null) {
|
|
852
|
+
return
|
|
853
|
+
}
|
|
854
|
+
// focus() は focusin を同期配送する — 旗は onRootFocus の実行中だけ立つ
|
|
855
|
+
escapeHandOffRef.current = true
|
|
856
|
+
try {
|
|
857
|
+
root.focus()
|
|
858
|
+
} finally {
|
|
859
|
+
escapeHandOffRef.current = false
|
|
860
|
+
}
|
|
861
|
+
}, [])
|
|
862
|
+
|
|
863
|
+
/** Re-arms the idle sink waiting protocol (ADR-3): value=" " + setSelectionRange(0, 1) — focus-neutral, so it never steals focus back after an out-of-grid focusout. / 待機プロトコルの再アーム (フォーカス中立 — グリッド外 focusout 後にフォーカスを奪い返さない)。 */
|
|
864
|
+
const rearmSink = useCallback(() => {
|
|
865
|
+
const sink = sinkRef.current
|
|
866
|
+
if (sink !== null) {
|
|
867
|
+
// 武装プロトコル (ADR-3): value " " を全選択。setSelectionRange はフォーカスを動かさない
|
|
868
|
+
// ため、グリッド外 focusout 後の再武装 (入力残留の掃除) でもユーザーが選んだ外部要素から
|
|
869
|
+
// フォーカスを奪わない (select() はブラウザによりフォーカスを伴う)
|
|
870
|
+
sink.value = " "
|
|
871
|
+
sink.setSelectionRange(0, 1)
|
|
872
|
+
}
|
|
873
|
+
}, [])
|
|
874
|
+
|
|
875
|
+
/**
|
|
876
|
+
* Repaints ONLY the row-header track residual transform. Callers: onScroll, resizeRow, and
|
|
877
|
+
* RowHeader's layout effect. vy comes from the grid handle (the truth — §7-4; the v0.6 −H_F
|
|
878
|
+
* frozen-band term makes this the exact twin of repaintColHeaders).
|
|
879
|
+
* 行 track の残差 transform のみを書く命令再描画。vy はグリッドハンドル (§7-4 の真実) から読む
|
|
880
|
+
* (v0.6 の −H_F 凍結帯項で repaintColHeaders の完全双子)。
|
|
881
|
+
*/
|
|
882
|
+
const repaintHeaders = useCallback(() => {
|
|
883
|
+
const track = rowTrackRef.current
|
|
884
|
+
if (track !== null) {
|
|
885
|
+
// v0.6: 凍結行帯の −H_F 項 (repaintColHeaders の −W_F と対称)。vy はハンドル読みへ統一
|
|
886
|
+
// (列側の hx と同じ真実 §7-4 — 従来の viewStore.get().vy 読みは廃止)
|
|
887
|
+
const vy = Math.max(0, handleRef.current?.getScrollPosition().y ?? 0)
|
|
888
|
+
const frozenHeight = handleRef.current?.getFrozenSize().height ?? 0
|
|
889
|
+
track.style.transform = `translateY(${windowOriginYRef.current - vy - frozenHeight}px)`
|
|
890
|
+
}
|
|
891
|
+
}, [])
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* Repaints ONLY the column-header track residual transform (§7-5 — the row repaintHeaders
|
|
895
|
+
* twin; the v0.3 --aqsp-hx CSS variable is retired). hx comes from the grid handle (the
|
|
896
|
+
* truth — §7-4). Callers: onScroll, resize tails, and ColumnHeader's layout effect.
|
|
897
|
+
* 列 track の残差 transform のみを書く命令再描画 (§7-5 — 行 repaintHeaders の双子。v0.3 の
|
|
898
|
+
* --aqsp-hx CSS 変数は退役)。hx はグリッドハンドル (§7-4 の真実) から読む。
|
|
899
|
+
*/
|
|
900
|
+
const repaintColHeaders = useCallback(() => {
|
|
901
|
+
const track = colTrackRef.current
|
|
902
|
+
if (track !== null) {
|
|
903
|
+
const hx = Math.max(0, handleRef.current?.getScrollPosition().x ?? 0)
|
|
904
|
+
// 凍結クリップ内では原点が W_F へシフトする (v0.5 計画 §3-2 — エンジン §8-b の
|
|
905
|
+
// −W_F 項のヘッダー同文。F = 0 は W_F = 0 で従来式と恒等)
|
|
906
|
+
const frozenWidth = handleRef.current?.getFrozenSize().width ?? 0
|
|
907
|
+
track.style.transform = `translateX(${windowOriginXRef.current - hx - frozenWidth}px)`
|
|
908
|
+
}
|
|
909
|
+
}, [])
|
|
910
|
+
|
|
911
|
+
// ensureCellVisible: §6.6.3 の自前 nearest (1 回構築して keyboard / Shift+F10 へ配布 — §6.9-7)。
|
|
912
|
+
// 軸 identity が変わったら作り直す (クロージャが旧軸を掴む stale を防ぐ)
|
|
913
|
+
const ensureCellVisibleRef = useRef<((cell: CellCoord) => void) | null>(null)
|
|
914
|
+
const ensureDepsRef = useRef<{ rowAxis: typeof rowAxis; colAxis: typeof colAxis } | null>(null)
|
|
915
|
+
if (ensureCellVisibleRef.current === null || ensureDepsRef.current?.rowAxis !== rowAxis || ensureDepsRef.current?.colAxis !== colAxis) {
|
|
916
|
+
ensureDepsRef.current = { rowAxis, colAxis }
|
|
917
|
+
ensureCellVisibleRef.current = createEnsureCellVisible({ handleRef, rowAxis, colAxis })
|
|
918
|
+
}
|
|
919
|
+
const ensureCellVisible = useCallback((cell: CellCoord) => {
|
|
920
|
+
ensureCellVisibleRef.current?.(cell)
|
|
921
|
+
}, [])
|
|
922
|
+
|
|
923
|
+
// ---- 読み上げ (live region — §6.9-9) ----
|
|
924
|
+
const announceTimerRef = useRef<number | null>(null)
|
|
925
|
+
const announceRafRef = useRef<number | null>(null)
|
|
926
|
+
const announce = useCallback((text: string) => {
|
|
927
|
+
if (announceTimerRef.current !== null) {
|
|
928
|
+
window.clearTimeout(announceTimerRef.current)
|
|
929
|
+
}
|
|
930
|
+
// 飛行中の「セット段 rAF」も取り消す — 取り残すと直前テキストが 1 フレーム内の再告知に
|
|
931
|
+
// 競り勝ち、離脱済みセルが読み上げられてから正しい告知が来る (stale 競合)
|
|
932
|
+
if (announceRafRef.current !== null) {
|
|
933
|
+
cancelAnimationFrame(announceRafRef.current)
|
|
934
|
+
announceRafRef.current = null
|
|
935
|
+
}
|
|
936
|
+
announceTimerRef.current = window.setTimeout(() => {
|
|
937
|
+
announceTimerRef.current = null
|
|
938
|
+
const node = liveRegionRef.current
|
|
939
|
+
if (node === null) {
|
|
940
|
+
return
|
|
941
|
+
}
|
|
942
|
+
// 同一文字列の再告知に備え「クリア → 次フレームでセット」で変更検知を強制する (§6.9-9)
|
|
943
|
+
node.textContent = ""
|
|
944
|
+
announceRafRef.current = requestAnimationFrame(() => {
|
|
945
|
+
announceRafRef.current = null
|
|
946
|
+
node.textContent = text
|
|
947
|
+
})
|
|
948
|
+
}, ANNOUNCE_DEBOUNCE_MS)
|
|
949
|
+
}, [])
|
|
950
|
+
// アンマウント時にデバウンスタイマと rAF を回収する (残置は他コンポーネントと違い自然無害だが、
|
|
951
|
+
// 本パッケージは全タイマ / rAF を回収する規律で統一している)
|
|
952
|
+
useEffect(
|
|
953
|
+
() => () => {
|
|
954
|
+
if (announceTimerRef.current !== null) {
|
|
955
|
+
window.clearTimeout(announceTimerRef.current)
|
|
956
|
+
}
|
|
957
|
+
if (announceRafRef.current !== null) {
|
|
958
|
+
cancelAnimationFrame(announceRafRef.current)
|
|
959
|
+
}
|
|
960
|
+
},
|
|
961
|
+
[],
|
|
962
|
+
)
|
|
963
|
+
|
|
964
|
+
/** The 3-tier announcement rule (§6.9-9, evaluated top-down). / 3 段機械判定 (上から順)。 */
|
|
965
|
+
const evaluateAnnouncement = useCallback(
|
|
966
|
+
(prev: SelectionState, next: SelectionState) => {
|
|
967
|
+
if (formatCellAnnouncement === undefined && formatSelectionAnnouncement === undefined) {
|
|
968
|
+
return
|
|
969
|
+
}
|
|
970
|
+
const range = next.ranges[next.ranges.length - 1]
|
|
971
|
+
const size = rangeSize(range)
|
|
972
|
+
const isSingleCell = next.ranges.length === 1 && size.rows === 1 && size.cols === 1
|
|
973
|
+
const prevRange = prev.ranges[prev.ranges.length - 1]
|
|
974
|
+
const shapeChanged = prev.ranges.length !== next.ranges.length || prevRange.startRow !== range.startRow || prevRange.startCol !== range.startCol || prevRange.endRow !== range.endRow || prevRange.endCol !== range.endCol
|
|
975
|
+
const announceCell = () => {
|
|
976
|
+
if (formatCellAnnouncement !== undefined) {
|
|
977
|
+
const a = next.active
|
|
978
|
+
const raw = adapter.getCellText(a.row, a.col)
|
|
979
|
+
// displayText は目視と同一の整形後文字列 (塗り書式 = base ⊕ CF — getCellFormat 未宣言は raw と同値)
|
|
980
|
+
const painted = adapter.getCellFormat === undefined ? undefined : paintedFormatOf(a.row, a.col, () => raw)
|
|
981
|
+
announce(formatCellAnnouncement({ row: a.row, col: a.col, text: raw, displayText: formatCellValue(raw, painted === undefined ? undefined : painted.value), columnName: columnIndexToName(a.col) }))
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
// (1) 単一 1×1 レンジ → セル読み上げ (素のクリック / 矢印移動)
|
|
985
|
+
if (isSingleCell) {
|
|
986
|
+
announceCell()
|
|
987
|
+
return
|
|
988
|
+
}
|
|
989
|
+
// (2) レンジ形状・本数が変わった → 選択読み上げ
|
|
990
|
+
if (shapeChanged) {
|
|
991
|
+
if (formatSelectionAnnouncement !== undefined) {
|
|
992
|
+
announce(
|
|
993
|
+
formatSelectionAnnouncement({
|
|
994
|
+
range,
|
|
995
|
+
anchor: next.anchor,
|
|
996
|
+
active: next.active,
|
|
997
|
+
rangeRowCount: size.rows,
|
|
998
|
+
rangeColCount: size.cols,
|
|
999
|
+
rangeCount: next.ranges.length,
|
|
1000
|
+
}),
|
|
1001
|
+
)
|
|
1002
|
+
}
|
|
1003
|
+
return
|
|
1004
|
+
}
|
|
1005
|
+
// (3) レンジ不変で active だけ動いた (巡回) → セル読み上げ (selection 側に振ると同一文言で
|
|
1006
|
+
// aria-live が無音になり、SR の中核データ入力ループが壊れる — §6.9-9)
|
|
1007
|
+
announceCell()
|
|
1008
|
+
},
|
|
1009
|
+
[adapter, formatCellAnnouncement, formatSelectionAnnouncement, announce, paintedFormatOf],
|
|
1010
|
+
)
|
|
1011
|
+
|
|
1012
|
+
/**
|
|
1013
|
+
* The §6.9-1 setSelection wrapper — the ONLY write path for every producer (engine /
|
|
1014
|
+
* keyboard / headers / clipboard). 5 ordered steps.
|
|
1015
|
+
* setSelection ラッパー (§6.9-1) — 全書き込み経路の唯一の入口。5 段の順序処理。
|
|
1016
|
+
*/
|
|
1017
|
+
const setSelection = useCallback(
|
|
1018
|
+
(next: SelectionState) => {
|
|
1019
|
+
const prev = selectionRef.current
|
|
1020
|
+
// (1) 等価 bail — Object.is は reducer の identity 保存契約の高速経路、構造等価は
|
|
1021
|
+
// 構築系 state (Ctrl+A / コーナー / ヘッダー合成) の等価再発火を抑える (どちらも
|
|
1022
|
+
// 「確定 SelectionState の変化ごとに 1 回だけ発火する」onSelectionChange 契約の実装)
|
|
1023
|
+
if (Object.is(next, prev) || selectionStateEquals(next, prev)) {
|
|
1024
|
+
return
|
|
1025
|
+
}
|
|
1026
|
+
// (2) selectionRef を setState より先に同期で書く (同一 tick 内の連続 reducer 適用の stale 防止)
|
|
1027
|
+
selectionRef.current = next
|
|
1028
|
+
// (3) setState
|
|
1029
|
+
setSelectionState(next)
|
|
1030
|
+
// (4) onSelectionChange を同期発火し、読み上げ 3 段判定もここで評価 (prev は (2) 前の退避値)
|
|
1031
|
+
onSelectionChange?.(next)
|
|
1032
|
+
evaluateAnnouncement(prev, next)
|
|
1033
|
+
// (5) 編集セッション外なら rearm (ADR-3 の rearm 点 (c) の実行点はここ 1 箇所)
|
|
1034
|
+
if (!(isEditingRef.current?.() ?? false)) {
|
|
1035
|
+
rearmSink()
|
|
1036
|
+
}
|
|
1037
|
+
},
|
|
1038
|
+
[onSelectionChange, evaluateAnnouncement, rearmSink],
|
|
1039
|
+
)
|
|
1040
|
+
|
|
1041
|
+
/** moveActive (§6.6.2-3): discriminated union to reducer to ensureCellVisible; owned by Spreadsheet (§6.9-7). / 判別 union → reducer → ensureCellVisible の順に適用。 */
|
|
1042
|
+
const moveActive = useCallback(
|
|
1043
|
+
(input: MoveActiveInput) => {
|
|
1044
|
+
const state = selectionRef.current
|
|
1045
|
+
if (input.mode === "cycle") {
|
|
1046
|
+
const next = reduceActiveCycle(state, input.direction)
|
|
1047
|
+
setSelection(next)
|
|
1048
|
+
ensureCellVisible(next.active)
|
|
1049
|
+
return
|
|
1050
|
+
}
|
|
1051
|
+
const cell = clampCoord(input.cell, rowAxis.count, colAxis.count)
|
|
1052
|
+
if (input.mode === "extend") {
|
|
1053
|
+
setSelection(reduceSelectionExtend(state, cell))
|
|
1054
|
+
// ensure-visible は伸長端 (§6.6.2-3)
|
|
1055
|
+
ensureCellVisible(cell)
|
|
1056
|
+
return
|
|
1057
|
+
}
|
|
1058
|
+
setSelection(reduceSelectionPress(state, cell, { extend: false, append: false }))
|
|
1059
|
+
ensureCellVisible(cell)
|
|
1060
|
+
},
|
|
1061
|
+
[setSelection, ensureCellVisible, rowAxis, colAxis],
|
|
1062
|
+
)
|
|
1063
|
+
|
|
1064
|
+
// ---- Phase 3 シーム (実体は useCellEditor / useClipboard — 構築循環は ref 間接で切る §6.9-1) ----
|
|
1065
|
+
const isEditing = useCallback(() => isEditingRef.current?.() ?? false, [])
|
|
1066
|
+
const requestCommitRef = useRef<(() => boolean) | null>(null)
|
|
1067
|
+
const requestCommit = useCallback(() => requestCommitRef.current?.() ?? true, [])
|
|
1068
|
+
const startEditRef = useRef<((mode: "edit" | "replace", caretIndex?: number) => void) | null>(null)
|
|
1069
|
+
const startEdit = useCallback(
|
|
1070
|
+
(mode: "edit" | "replace", caretIndex?: number) => {
|
|
1071
|
+
const wasEditing = isEditingRef.current?.() ?? false
|
|
1072
|
+
startEditRef.current?.(mode, caretIndex)
|
|
1073
|
+
// セッションが実際に開いたときだけ stale な strict 拒否告知を畳む (分冊 08 §4.3): 前回 commit の
|
|
1074
|
+
// 拒否は新しい下書きに当てはまらない。bail (ドラッグ中 / 既存セッション / シンク不在) では閉じない
|
|
1075
|
+
if (!wasEditing && (isEditingRef.current?.() ?? false)) {
|
|
1076
|
+
closeRejectedMessage()
|
|
1077
|
+
}
|
|
1078
|
+
},
|
|
1079
|
+
[closeRejectedMessage],
|
|
1080
|
+
)
|
|
1081
|
+
/** The sole dblclick → editing(edit) channel (§6.4.3-7 / §6.7.1). The PER-CELL ED-1 gate (caret-from-point only when that cell's display === raw text) lives on the ENGINE param (caretFromPointEnabled(cell) — single judgment point), so caretIndex forwards untouched here. / dblclick → editing(edit) 開始の唯一チャネル。**セル単位**の ED-1 ゲート (そのセルの表示 === 素値のときだけキャレット計算) はエンジン param 側の単一判定点 (caretFromPointEnabled(cell)) — ここでは素通し。 */
|
|
1082
|
+
// ---- 仮想キーボード招待 (v0.7 UX 追補 R7): シンクの inputmode はこのフラグの純粋な描画 ----
|
|
1083
|
+
// "text" = 招待中 / "none" = 非招待。招待はポインタでエディタを開いたとき (dblclick) だけ立ち、
|
|
1084
|
+
// フォーカスがグリッド外へ出るか、セッション外の touch / ペン押下で下ろす。Enter / Tab の確定では
|
|
1085
|
+
// 下ろさない (キーボード無し端末の「入力 → Enter → 入力」で OSK を消さない)。キーボード / IME の
|
|
1086
|
+
// 利用者には属性は常に "none" で一度も変わらない。セッションを持つ変換の途中で属性が変わる経路は
|
|
1087
|
+
// 存在しない — 立てるのは押下ゲートが変換を確定した後の dblclick、下ろすのはセッション外の
|
|
1088
|
+
// touch / ペン押下とグリッド脱出 (blur が変換を先に確定する — E2E で実証) だけ。唯一の例外は
|
|
1089
|
+
// ドラッグ飛行中に始まった変換 (startEdit が isDragging で bail しセッションを持たない) だが、
|
|
1090
|
+
// その変換は input 到着ごとに待機シンクの自己修復 (rearmSink) が既に捨てており、守るものが無い
|
|
1091
|
+
// (Chromium は inputmode 変更を text-input-state 変更として通知し、Windows TSF は
|
|
1092
|
+
// OnTextInputTypeChanged で進行中の変換を強制終了する: 未編集 → IME 1 文字 → Enter が
|
|
1093
|
+
// 落ちた利用者報告の根因 — セッション基準の切替は compositionstart の中で属性を変えていた)
|
|
1094
|
+
const [virtualKeyboard, setVirtualKeyboard] = useState(false)
|
|
1095
|
+
const onRequestEdit = useCallback(
|
|
1096
|
+
(_cell: CellCoord, caretIndex?: number) => {
|
|
1097
|
+
// ポインタで開くエディタだけが仮想キーボードを招待する (キーボード無し端末で開ける唯一の経路)。
|
|
1098
|
+
// 変換は進行中でない (dblclick 押下面が先に requestCommit 済み) — 属性変更は安全
|
|
1099
|
+
setVirtualKeyboard(true)
|
|
1100
|
+
startEdit("edit", caretIndex)
|
|
1101
|
+
},
|
|
1102
|
+
[startEdit],
|
|
1103
|
+
)
|
|
1104
|
+
const getClipRef = useRef<(() => ClipInfo | null) | null>(null)
|
|
1105
|
+
const getClip = useCallback(() => getClipRef.current?.() ?? null, [])
|
|
1106
|
+
const discardClipRef = useRef<(() => void) | null>(null)
|
|
1107
|
+
const discardClip = useCallback(() => {
|
|
1108
|
+
discardClipRef.current?.()
|
|
1109
|
+
}, [])
|
|
1110
|
+
const discardClipOnCommitRef = useRef<((source: EditSource) => void) | null>(null)
|
|
1111
|
+
const discardClipOnCommit = useCallback((source: EditSource) => {
|
|
1112
|
+
discardClipOnCommitRef.current?.(source)
|
|
1113
|
+
}, [])
|
|
1114
|
+
/** Sheet-switch relay into the clipboard hook (copy discarded, cut kept as foreign). / シート切替のクリップ中継 (copy 破棄、cut は foreign で保持)。 */
|
|
1115
|
+
const onAdapterSwitchRef = useRef<() => void>(() => {})
|
|
1116
|
+
|
|
1117
|
+
/** Transaction counter (v0.8 §4.2.13 / §1.3): one strictly increasing id per applyEdits call of THIS grid instance (a rejected call consumes its id too — an id the adapter has seen is never reused) — the consumer's undo groups by it. / トランザクション採番 (v0.8 §4.2.13 / §1.3): このグリッド実体の applyEdits 呼び出し 1 回につき厳密増加の id 1 つ (拒否された呼び出しも id を消費 — アダプタが見た id は再利用しない) — consumer の Undo はこれで束ねる。 */
|
|
1118
|
+
const transactionRef = useRef(0)
|
|
1119
|
+
/**
|
|
1120
|
+
* The single write funnel (§6.9-2 / ADR-16): `commitEdits(edits, rejection, sheet?) → number | null`.
|
|
1121
|
+
* A paste commit MUST carry `sheet` (RangeError otherwise — a wiring bug). An EMPTY transaction
|
|
1122
|
+
* (no edits and no conditional formats to carry) never reaches the adapter (no id, no bumpEdit,
|
|
1123
|
+
* no clip discard) and returns null. Otherwise the transaction id is allocated, the EditContext is
|
|
1124
|
+
* derived from the caller-declared rejection (the paste arm carries sameSheet / conditionalFormats)
|
|
1125
|
+
* and applyEdits runs ONCE: success bumps the edit version, runs the source-aware commit discard
|
|
1126
|
+
* (format / annotate keep the clip, the clipboard's own paste keeps it, everything else discards)
|
|
1127
|
+
* and returns the id; a throw emits onEditRejected with the rejection context and returns null.
|
|
1128
|
+
* 単一書込漏斗 (§6.9-2 / ADR-16): `commitEdits(edits, rejection, sheet?) → number | null`。paste の
|
|
1129
|
+
* commit は sheet 必須 (欠落は結線バグ = RangeError)。空トランザクション (edits なし・運ぶ規則なし) は
|
|
1130
|
+
* アダプタへ届けず null (採番 / bumpEdit / クリップ破棄なし)。それ以外は採番し、拒否文脈から EditContext を
|
|
1131
|
+
* 導出 (paste 枝は sameSheet / conditionalFormats) して applyEdits を 1 回: 成功 → bumpEdit + source を
|
|
1132
|
+
* 見た commit 破棄 (format / annotate と自 paste は温存) + id、throw → onEditRejected + null。
|
|
1133
|
+
*/
|
|
1134
|
+
const commitEdits = useCallback(
|
|
1135
|
+
(edits: readonly CellEdit[], rejection: EditRejection, sheet?: PasteSheet): number | null => {
|
|
1136
|
+
let context: EditContext
|
|
1137
|
+
if (rejection.source === "paste") {
|
|
1138
|
+
// シート文脈は空 edits より先に検証する (結線バグは実行内容に依らず即時表面化)
|
|
1139
|
+
const pasteSheet = requirePasteSheet(sheet)
|
|
1140
|
+
// CF だけを運ぶ paste は空 edits でも 1 トランザクション (RC9 — 規則も無ければ不達)
|
|
1141
|
+
if (edits.length === 0 && pasteSheet.conditionalFormats.length === 0) {
|
|
1142
|
+
return null
|
|
1143
|
+
}
|
|
1144
|
+
// 採番はアダプタ呼び出しの前 (拒否されても id は消費 — 厳密増加、applyEdits 1 回 1 id)
|
|
1145
|
+
transactionRef.current += 1
|
|
1146
|
+
context = { transaction: transactionRef.current, source: "paste", sameSheet: pasteSheet.sameSheet, conditionalFormats: pasteSheet.conditionalFormats }
|
|
1147
|
+
} else {
|
|
1148
|
+
// 空トランザクションはアダプタへ届けない (採番なし・bumpEdit なし・クリップ破棄なし)
|
|
1149
|
+
if (edits.length === 0) {
|
|
1150
|
+
return null
|
|
1151
|
+
}
|
|
1152
|
+
transactionRef.current += 1
|
|
1153
|
+
context = { transaction: transactionRef.current, source: rejection.source }
|
|
1154
|
+
}
|
|
1155
|
+
try {
|
|
1156
|
+
adapter.applyEdits(edits, context)
|
|
1157
|
+
} catch (error) {
|
|
1158
|
+
// 拒否文脈 (経路 + セッションの行方) は呼び手が事前宣言する — ラッパーは中継だけ (SSOT)
|
|
1159
|
+
onEditRejected?.(error, rejection)
|
|
1160
|
+
return null
|
|
1161
|
+
}
|
|
1162
|
+
viewStore.bumpEdit()
|
|
1163
|
+
// applyEdits 系はクリップの破棄条件 (§6.5.4-4) — source を渡し、書式 / 注釈の commit は
|
|
1164
|
+
// アンツを保つ (Excel の Ctrl+B と同じ)。自 paste の温存判定はクリップフック側が担う
|
|
1165
|
+
discardClipOnCommit(rejection.source)
|
|
1166
|
+
return context.transaction
|
|
1167
|
+
},
|
|
1168
|
+
[adapter, viewStore, onEditRejected, discardClipOnCommit],
|
|
1169
|
+
)
|
|
1170
|
+
/** The clipboard's paste seam: ONE transaction with its sheet context (ADR-16). / クリップボードの paste シーム (シート文脈つき 1 トランザクション)。 */
|
|
1171
|
+
const commitPaste = useCallback((edits: readonly CellEdit[], sheet: PasteSheet): number | null => commitEdits(edits, { source: "paste" }, sheet), [commitEdits])
|
|
1172
|
+
|
|
1173
|
+
// ---- VirtualGrid コールバック (§6.9-6 — v0.4 で 2 軸化) ----
|
|
1174
|
+
|
|
1175
|
+
/** Mirrors vy into the store and repaints imperative surfaces on either axis (§7-4). / vy 写像 + 両軸の命令再描画。 */
|
|
1176
|
+
const onScroll = useCallback(
|
|
1177
|
+
(position: { x: number; y: number }) => {
|
|
1178
|
+
viewStore.setVy(position.y)
|
|
1179
|
+
// 両 track と overlay の残差更新は onScroll から ref 直接呼び (React 不介在)。
|
|
1180
|
+
// 横成分もここへ届く — v0.3 の applyHx rAF 合流は退役 (グリッドが hx を所有し、
|
|
1181
|
+
// 通知はホイール / パン / バー / キーボードの全経路で 1 本化された)
|
|
1182
|
+
repaintHeaders()
|
|
1183
|
+
repaintColHeaders()
|
|
1184
|
+
repaintOverlay()
|
|
1185
|
+
},
|
|
1186
|
+
[viewStore, repaintHeaders, repaintColHeaders, repaintOverlay],
|
|
1187
|
+
)
|
|
1188
|
+
|
|
1189
|
+
/** Feeds the RENDERING 2D ranges into the store (§6.9-6 / §7-5). / 描画 2D レンジのストア供給。 */
|
|
1190
|
+
const lastDeliveredRangeRef = useRef({ rowStart: -1, rowEnd: -1, colStart: -1, colEnd: -1 })
|
|
1191
|
+
const onRangeChange = useCallback(
|
|
1192
|
+
(range: VirtualGridRange) => {
|
|
1193
|
+
viewStore.setRowRange(range.renderingRowStart, range.renderingRowEnd)
|
|
1194
|
+
// 列レンジは ColumnHeader の再レンダー源 (§7-5 — 行側 setRowRange → RowHeader の完全対称)
|
|
1195
|
+
viewStore.setColRange(range.renderingColStart, range.renderingColEnd)
|
|
1196
|
+
// repaint は実レンジ変化時のみ (グリッドは毎スクロールフレーム配送する — 無差別
|
|
1197
|
+
// repaint は onScroll の同期 repaint と重なり 1 フレーム 2 描画になる)。マウント時の
|
|
1198
|
+
// A1 アクティブ枠を描くのはこの中継経路 (グリッドの rAF 遅延 throttled callback から届く
|
|
1199
|
+
// 初回の onScroll / onRangeChange) — SelectionOverlay 自身のマウント effect は viewport
|
|
1200
|
+
// 未計測 ({0,0} / range null。グリッドの viewport 計測は layout effect の setState で
|
|
1201
|
+
// 1 レンダー遅れる) のまま走り何も描かず、selection layout effect も初回マウントでは
|
|
1202
|
+
// ref 未登録の no-op (post-B F2 実測: 初回フレームの唯一の所有者は本中継)
|
|
1203
|
+
const last = lastDeliveredRangeRef.current
|
|
1204
|
+
if (last.rowStart !== range.renderingRowStart || last.rowEnd !== range.renderingRowEnd || last.colStart !== range.renderingColStart || last.colEnd !== range.renderingColEnd) {
|
|
1205
|
+
lastDeliveredRangeRef.current = { rowStart: range.renderingRowStart, rowEnd: range.renderingRowEnd, colStart: range.renderingColStart, colEnd: range.renderingColEnd }
|
|
1206
|
+
repaintOverlay()
|
|
1207
|
+
}
|
|
1208
|
+
},
|
|
1209
|
+
[viewStore, repaintOverlay],
|
|
1210
|
+
)
|
|
1211
|
+
|
|
1212
|
+
// ---- ドラッグ矩形 writer (エンジン → overlay の常設ノードへ) ----
|
|
1213
|
+
// mode reader ref (§3.2 — isAnyDraggingRef 同型のレンダー時代入): 全書き手 (エンジン /
|
|
1214
|
+
// ヘッダー機械 / オートスクローラー tick) がこの単一リレーを通るため、構築時固定 writer の
|
|
1215
|
+
// オートスクローラー経由でも正しい減算モードが overlay へ届く (per-call ラップ不要)
|
|
1216
|
+
const engineDragModeRef = useRef<() => DragFrameMode>(() => "add")
|
|
1217
|
+
const onDragFrame = useCallback((range: CellRange | null) => {
|
|
1218
|
+
dragFrameRef.current?.(range, engineDragModeRef.current())
|
|
1219
|
+
}, [])
|
|
1220
|
+
/** Keyboard-pointing reference-rect writer (分冊 04 §3.20): stamps the FIXED "point" mode — the composite reader reports "point" only while the reference DRAG is in flight, but the keyboard paints without any drag. / キーボードポインティングの参照矩形 writer (分冊 04 §3.20): 固定 "point" モードで刻む — 合成 reader が "point" を返すのは参照ドラッグ飛行中だけで、キーボードはドラッグなしで描くため。 */
|
|
1221
|
+
const onPointFrame = useCallback((range: CellRange | null) => {
|
|
1222
|
+
dragFrameRef.current?.(range, "point")
|
|
1223
|
+
}, [])
|
|
1224
|
+
|
|
1225
|
+
// ---- fill プレビュー / リサイズガイドラインの writer リレー (§7.2 / §8.2 — positioner は
|
|
1226
|
+
// overlay 所有。fill 機械と軸別 2 スクローラーの全書き手がこの単一リレーを通る) ----
|
|
1227
|
+
const fillFrameRef = useRef<((range: CellRange | null) => void) | null>(null)
|
|
1228
|
+
const onFillFrame = useCallback((range: CellRange | null) => {
|
|
1229
|
+
fillFrameRef.current?.(range)
|
|
1230
|
+
}, [])
|
|
1231
|
+
const resizeGuideFrameRef = useRef<((guide: ResizeGuide | null) => void) | null>(null)
|
|
1232
|
+
const writeResizeGuide = useCallback((guide: ResizeGuide | null) => {
|
|
1233
|
+
resizeGuideFrameRef.current?.(guide)
|
|
1234
|
+
}, [])
|
|
1235
|
+
/** Fill-handle node registration (overlay renders it, useFillHandle wires it). / fill ハンドルノードの登録 (overlay が描画し useFillHandle が結線)。 */
|
|
1236
|
+
const fillHandleNodeRef = useRef<HTMLDivElement | null>(null)
|
|
1237
|
+
|
|
1238
|
+
/**
|
|
1239
|
+
* Batch resize seam (§7.2): per-index tree/virtualscroll updates in a loop, then bump /
|
|
1240
|
+
* repaint ONCE at the tail (the per-index imperative API would run a synchronous repaint
|
|
1241
|
+
* per call — 1M calls = repaint hell). Row entries still run updateItemSize per call
|
|
1242
|
+
* (virtualscroll's viewport compensation is per-call by design — budget row (f) prices
|
|
1243
|
+
* this residual); the row tail repaints BOTH headers and overlay (the v0.1 resizeRow pair).
|
|
1244
|
+
* バッチリサイズシーム (§7.2)。per-index の木 / virtualscroll 更新をループし bump / repaint
|
|
1245
|
+
* は末尾 1 回 (per-index 命令 API の同期 repaint 地獄の遮断)。行の updateItemSize は
|
|
1246
|
+
* per-call (virtualscroll の補償スクロールが per-call 設計 — 予算行 (f) がこの付帯コストを
|
|
1247
|
+
* 見積もる)。行軸末尾は repaintHeaders + repaintOverlay の対 (v0.1 resizeRow と同じ)。
|
|
1248
|
+
*/
|
|
1249
|
+
const resizeTracks = useCallback(
|
|
1250
|
+
(axis: "row" | "col", entries: readonly { index: number; size: number }[]) => {
|
|
1251
|
+
if (entries.length === 0) {
|
|
1252
|
+
return
|
|
1253
|
+
}
|
|
1254
|
+
const handle = handleRef.current
|
|
1255
|
+
if (axis === "col") {
|
|
1256
|
+
for (const entry of entries) {
|
|
1257
|
+
// 対呼び出し (§3.8): colAxis (SSOT) を先に更新し、グリッドの幅木へ通知する。
|
|
1258
|
+
// 丸めは一度だけ — 両者へ同一値を渡す (食い違うと幅 self-heal が巻き戻す)
|
|
1259
|
+
const size = Math.round(entry.size)
|
|
1260
|
+
colAxis.resize(entry.index, size)
|
|
1261
|
+
handle?.updateColSize(entry.index, size)
|
|
1262
|
+
}
|
|
1263
|
+
// hx 再クランプと可視セルの再配置はグリッド内部 (総幅同期 + 幅エポック) が担う —
|
|
1264
|
+
// ここでの明示 reclamp は退役。ヘッダーの再レンダーは colVersion、残差は末尾 repaint
|
|
1265
|
+
viewStore.bumpColVersion()
|
|
1266
|
+
repaintColHeaders()
|
|
1267
|
+
repaintOverlay()
|
|
1268
|
+
return
|
|
1269
|
+
}
|
|
1270
|
+
for (const entry of entries) {
|
|
1271
|
+
// 丸めは一度だけ — rowAxis とグリッド内部木へ同一値を渡す (v0.1 resizeRow と同じ)
|
|
1272
|
+
const size = Math.round(entry.size)
|
|
1273
|
+
rowAxis.resize(entry.index, size)
|
|
1274
|
+
handle?.updateRowSize(entry.index, size)
|
|
1275
|
+
}
|
|
1276
|
+
viewStore.bumpRowGeometry()
|
|
1277
|
+
repaintHeaders()
|
|
1278
|
+
repaintOverlay()
|
|
1279
|
+
},
|
|
1280
|
+
[rowAxis, colAxis, viewStore, repaintColHeaders, repaintHeaders, repaintOverlay],
|
|
1281
|
+
)
|
|
1282
|
+
|
|
1283
|
+
/** Pointer contextmenu relay to onCellContextMenu (§6.4.3-4 sole channel). / pointer 起点の横流し。 */
|
|
1284
|
+
const onContextMenuSelect = useCallback(
|
|
1285
|
+
(cell: CellCoord, event: MouseEvent) => {
|
|
1286
|
+
onCellContextMenu?.(cell, { kind: "pointer", event })
|
|
1287
|
+
},
|
|
1288
|
+
[onCellContextMenu],
|
|
1289
|
+
)
|
|
1290
|
+
|
|
1291
|
+
// ---- オートスクローラー 3 種 (セル both / 列ヘッダー x / 行ヘッダー y — §6.4.4 / §6.8) ----
|
|
1292
|
+
const cellAutoScroller = useAutoScroller({ handleRef, rowAxis, colAxis, bodyRef, onDragFrame, axis: "both", zoomRef })
|
|
1293
|
+
const colHeaderRef = useRef<HTMLDivElement | null>(null)
|
|
1294
|
+
const rowHeaderRef = useRef<HTMLDivElement | null>(null)
|
|
1295
|
+
const colAutoScroller = useAutoScroller({ handleRef, rowAxis, colAxis, bodyRef, onDragFrame, axis: "x", zoomRef })
|
|
1296
|
+
const rowAutoScroller = useAutoScroller({ handleRef, rowAxis, colAxis, bodyRef, onDragFrame, axis: "y", zoomRef })
|
|
1297
|
+
// fill 専用の軸別 2 インスタンス (§8.2 — writer も axis も構築時固定。v0.3 のボックス方式では
|
|
1298
|
+
// fill 機械が武装時に両方を start する — 軸選択・反転なし。既存 3 台と合わせ計 5 台、全て
|
|
1299
|
+
// 構築時固定の単一規約)
|
|
1300
|
+
const fillXAutoScroller = useAutoScroller({ handleRef, rowAxis, colAxis, bodyRef, onDragFrame: onFillFrame, axis: "x", zoomRef })
|
|
1301
|
+
const fillYAutoScroller = useAutoScroller({ handleRef, rowAxis, colAxis, bodyRef, onDragFrame: onFillFrame, axis: "y", zoomRef })
|
|
1302
|
+
// 6 台目 (v0.8 §4.2.13 表 3 行目): 参照ドラッグ (usePointDrag) 専用の両軸スクローラー — writer は
|
|
1303
|
+
// 同じドラッグ矩形リレー (合成モードが "point" を刻む)
|
|
1304
|
+
const pointAutoScroller = useAutoScroller({ handleRef, rowAxis, colAxis, bodyRef, onDragFrame, axis: "both", zoomRef })
|
|
1305
|
+
/** The reference-drag owner, consulted by the engine BEFORE its commit gate (render-time assignment after usePointDrag). / 参照ドラッグ所有者 (engine が commit ゲートより先に諮る — usePointDrag 後にレンダー時代入)。 */
|
|
1306
|
+
const pointDragRef = useRef<PointDragOwner | null>(null)
|
|
1307
|
+
|
|
1308
|
+
// 空グリッドは §6.9-1 の空分岐で実 DOM を描かない — 結線フックは enabled=false で眠らせ、
|
|
1309
|
+
// 非空遷移の再レンダーで再結線する (ref identity 不変のため deps だけでは再実行されない)。
|
|
1310
|
+
// isEmpty 本体はズームジェスチャ effect と共有するため上方で単一定義
|
|
1311
|
+
|
|
1312
|
+
// 合成ドラッグ判定の ref 間接 (エンジン構築時にはヘッダー機械が未構築 — 構築循環切り)
|
|
1313
|
+
const isAnyDraggingRef = useRef<() => boolean>(() => false)
|
|
1314
|
+
const isAnyDragging = useCallback(() => isAnyDraggingRef.current(), [])
|
|
1315
|
+
|
|
1316
|
+
// ---- 選択エンジン (§6.4.3) ----
|
|
1317
|
+
const engine = useSelectionEngine({
|
|
1318
|
+
zoomRef,
|
|
1319
|
+
enabled: !isEmpty,
|
|
1320
|
+
rowAxis,
|
|
1321
|
+
colAxis,
|
|
1322
|
+
handleRef,
|
|
1323
|
+
bodyRef,
|
|
1324
|
+
isEditing,
|
|
1325
|
+
requestCommit,
|
|
1326
|
+
getSelection,
|
|
1327
|
+
setSelection,
|
|
1328
|
+
focusSink,
|
|
1329
|
+
onDragFrame,
|
|
1330
|
+
onContextMenuSelect,
|
|
1331
|
+
onRequestEdit,
|
|
1332
|
+
// ED-1 の採用条件 (c): 「そのセルの」表示テキスト === 素の正準文字列のときだけキャレット
|
|
1333
|
+
// 計算 (§6)。renderCell (カスタム DOM) は全セル無効。getCellFormat 在りでもセル単位に判定
|
|
1334
|
+
// し、表示が素値と一致するセル (text 型・ラベル・書式なし列) はクリック位置キャレットを
|
|
1335
|
+
// 保つ — 乖離するセル (¥1,234 の offset を 1234 に適用できない) だけ末尾 (v0.1 挙動) へ。
|
|
1336
|
+
// 判定は dblclick 毎に O(1) (該当セル 1 個の整形比較のみ)。
|
|
1337
|
+
caretFromPointEnabled: (cell) => {
|
|
1338
|
+
if (renderCell !== undefined) {
|
|
1339
|
+
return false
|
|
1340
|
+
}
|
|
1341
|
+
// 数式セル (getCellFormula 宣言 + 数式あり) はエディタが数式ソースを開く — 表示値の offset は
|
|
1342
|
+
// 数式文字列へ写像できないので末尾 (v0.8 §4.2.13)
|
|
1343
|
+
if (adapter.getCellFormula !== undefined && adapter.getCellFormula(cell.row, cell.col) !== undefined) {
|
|
1344
|
+
return false
|
|
1345
|
+
}
|
|
1346
|
+
if (adapter.getCellFormat === undefined) {
|
|
1347
|
+
return true
|
|
1348
|
+
}
|
|
1349
|
+
const raw = adapter.getCellText(cell.row, cell.col)
|
|
1350
|
+
return formatCellValue(raw, adapter.getCellFormat(cell.row, cell.col)?.value) === raw
|
|
1351
|
+
},
|
|
1352
|
+
isAnyDragging,
|
|
1353
|
+
autoScroller: cellAutoScroller,
|
|
1354
|
+
repaintOverlay,
|
|
1355
|
+
// v0.8 (§4.2.8): 参照ドラッグ所有者を commit ゲートより先に諮り、ポインティング中の dblclick は編集を開かない
|
|
1356
|
+
pointDragRef,
|
|
1357
|
+
getPointing: isPointing,
|
|
1358
|
+
// v0.8 リンク開通 (分冊 08 §11 D-53): onHyperlinkOpen とリンク側面の両宣言時だけ能力を渡す
|
|
1359
|
+
// (どちらか欠けたら param ごと不在 = エンジンの押下経路は恒等)。リンク無しセルは消費しない
|
|
1360
|
+
// (append / 減算ジェスチャへ返す)。側面は毎回オブジェクト経由で読む (getBaseFormat と同型 —
|
|
1361
|
+
// this 依存のアダプタ実装を壊さない)。生 URL は信頼境界 (ADR-10) で正規化 — 許可外スキームは
|
|
1362
|
+
// リンク無し扱いでホストへ渡さない
|
|
1363
|
+
openHyperlinkAt:
|
|
1364
|
+
onHyperlinkOpen === undefined || adapter.getCellHyperlink === undefined
|
|
1365
|
+
? undefined
|
|
1366
|
+
: (cell) => {
|
|
1367
|
+
const url = adapter.getCellHyperlink === undefined ? undefined : normalizeAdapterHyperlink(adapter.getCellHyperlink(cell.row, cell.col))
|
|
1368
|
+
if (url === undefined) {
|
|
1369
|
+
return false
|
|
1370
|
+
}
|
|
1371
|
+
onHyperlinkOpen(url, cell)
|
|
1372
|
+
return true
|
|
1373
|
+
},
|
|
1374
|
+
})
|
|
1375
|
+
|
|
1376
|
+
// ---- ヘッダー選択 (§6.8 — 列 / 行の転置共通機械) ----
|
|
1377
|
+
const colHeaderMachine = useHeaderSelection({
|
|
1378
|
+
zoomRef,
|
|
1379
|
+
enabled: !isEmpty,
|
|
1380
|
+
axis: "col",
|
|
1381
|
+
headerRef: colHeaderRef,
|
|
1382
|
+
mainAxis: colAxis,
|
|
1383
|
+
crossCount: adapter.rowCount,
|
|
1384
|
+
handleRef,
|
|
1385
|
+
isEditing,
|
|
1386
|
+
requestCommit,
|
|
1387
|
+
getSelection,
|
|
1388
|
+
setSelection,
|
|
1389
|
+
focusSink,
|
|
1390
|
+
onDragFrame,
|
|
1391
|
+
autoScroller: colAutoScroller,
|
|
1392
|
+
repaintOverlay,
|
|
1393
|
+
// v0.8 (§4.2.9): ポインティング中の帯押下は全列参照 (A:A) の挿入
|
|
1394
|
+
pointing: pointingSeams,
|
|
1395
|
+
})
|
|
1396
|
+
const rowHeaderMachine = useHeaderSelection({
|
|
1397
|
+
zoomRef,
|
|
1398
|
+
enabled: !isEmpty,
|
|
1399
|
+
axis: "row",
|
|
1400
|
+
headerRef: rowHeaderRef,
|
|
1401
|
+
mainAxis: rowAxis,
|
|
1402
|
+
crossCount: adapter.colCount,
|
|
1403
|
+
handleRef,
|
|
1404
|
+
isEditing,
|
|
1405
|
+
requestCommit,
|
|
1406
|
+
getSelection,
|
|
1407
|
+
setSelection,
|
|
1408
|
+
focusSink,
|
|
1409
|
+
onDragFrame,
|
|
1410
|
+
autoScroller: rowAutoScroller,
|
|
1411
|
+
repaintOverlay,
|
|
1412
|
+
// v0.8 (§4.2.9): ポインティング中の帯押下は全行参照 (1:1) の挿入
|
|
1413
|
+
pointing: pointingSeams,
|
|
1414
|
+
})
|
|
1415
|
+
|
|
1416
|
+
// ---- リサイズ / unhide 機械 (§7 PT-1) + fill 機械 (§8 PT-2) ----
|
|
1417
|
+
const headerResizeMachine = useHeaderResize({
|
|
1418
|
+
zoomRef,
|
|
1419
|
+
enabled: !isEmpty && enableResizeUI,
|
|
1420
|
+
colBandRef: colHeaderRef,
|
|
1421
|
+
rowBandRef: rowHeaderRef,
|
|
1422
|
+
colAxis,
|
|
1423
|
+
rowAxis,
|
|
1424
|
+
viewStore,
|
|
1425
|
+
handleRef,
|
|
1426
|
+
isEditing,
|
|
1427
|
+
requestCommit,
|
|
1428
|
+
focusSink,
|
|
1429
|
+
getSelection,
|
|
1430
|
+
adapter,
|
|
1431
|
+
// autofit の採用条件 = renderCell 未指定 (カスタム DOM は measureText 計測を保証できない §10)
|
|
1432
|
+
autofitEnabled: renderCell === undefined,
|
|
1433
|
+
defaultRowHeight,
|
|
1434
|
+
defaultColumnWidth,
|
|
1435
|
+
maxOperationCells,
|
|
1436
|
+
onOperationRejected,
|
|
1437
|
+
resizeTracks,
|
|
1438
|
+
writeResizeGuide,
|
|
1439
|
+
repaintOverlay,
|
|
1440
|
+
// v0.8 (§4.2.10): ポインティング中のストリップ押下は consume (挿入なし・確定なし)
|
|
1441
|
+
isPointing,
|
|
1442
|
+
})
|
|
1443
|
+
const fillMachine = useFillHandle({
|
|
1444
|
+
zoomRef,
|
|
1445
|
+
enabled: !isEmpty && enableFillHandle,
|
|
1446
|
+
handleNodeRef: fillHandleNodeRef,
|
|
1447
|
+
bodyRef,
|
|
1448
|
+
rowAxis,
|
|
1449
|
+
colAxis,
|
|
1450
|
+
handleRef,
|
|
1451
|
+
getSelection,
|
|
1452
|
+
setSelection,
|
|
1453
|
+
focusSink,
|
|
1454
|
+
adapter,
|
|
1455
|
+
commitEdits,
|
|
1456
|
+
maxOperationCells,
|
|
1457
|
+
onOperationRejected,
|
|
1458
|
+
writeFillPreview: onFillFrame,
|
|
1459
|
+
repaintOverlay,
|
|
1460
|
+
xAutoScroller: fillXAutoScroller,
|
|
1461
|
+
yAutoScroller: fillYAutoScroller,
|
|
1462
|
+
})
|
|
1463
|
+
// ---- 参照ドラッグ (v0.8 §4.1.18 — 独立所有者、§4.2.13 表 7 行目) ----
|
|
1464
|
+
const pointDrag = usePointDrag({
|
|
1465
|
+
zoomRef,
|
|
1466
|
+
enabled: !isEmpty,
|
|
1467
|
+
bodyRef,
|
|
1468
|
+
handleRef,
|
|
1469
|
+
rowAxis,
|
|
1470
|
+
colAxis,
|
|
1471
|
+
// editor のシームは ref 間接 (editor は後で構築される — 構築循環切り)
|
|
1472
|
+
getPointing: isPointing,
|
|
1473
|
+
insertReference: pointingSeams.insertReference,
|
|
1474
|
+
captureSink: () => captureSinkRef.current(),
|
|
1475
|
+
restoreSink: (snapshot) => restoreSinkRef.current(snapshot),
|
|
1476
|
+
focusSink,
|
|
1477
|
+
autoScroller: pointAutoScroller,
|
|
1478
|
+
onDragFrame,
|
|
1479
|
+
})
|
|
1480
|
+
pointDragRef.current = pointDrag
|
|
1481
|
+
|
|
1482
|
+
// Escape 優先順 (§6.6.1) は全ドラッグに効く — 6 機械の合成面 (§7.4: リサイズ / fill / 参照ドラッグ飛行中は
|
|
1483
|
+
// キーボード全キー consume (Escape 除く)・contextmenu 凍結・clipboard 凍結が自動成立)
|
|
1484
|
+
const isDraggingAny = useCallback(
|
|
1485
|
+
() => engine.isDragging() || colHeaderMachine.isDragging() || rowHeaderMachine.isDragging() || headerResizeMachine.isDragging() || fillMachine.isDragging() || pointDrag.isDragging(),
|
|
1486
|
+
[engine, colHeaderMachine, rowHeaderMachine, headerResizeMachine, fillMachine, pointDrag],
|
|
1487
|
+
)
|
|
1488
|
+
// ズームジェスチャのドラッグ中無効化 (v0.3) が構築循環なしで読めるよう間接 ref へ充填
|
|
1489
|
+
isDraggingAnyRef.current = isDraggingAny
|
|
1490
|
+
// エンジンの contextmenu 凍結ゲートへ合成面を配線 (レンダー時代入 — paramsRef と同じ規律)
|
|
1491
|
+
isAnyDraggingRef.current = isDraggingAny
|
|
1492
|
+
// 配送モードの reader をリレーへ配線 (§3.2 — レンダー時代入): 参照ドラッグ飛行中は "point"、それ以外は
|
|
1493
|
+
// セル機械の add / subtract (ヘッダーは常に "add")。§4.2.13 表 8 行目の合成 dragModeAny
|
|
1494
|
+
engineDragModeRef.current = () => (pointDrag.isDragging() ? "point" : engine.getDragMode())
|
|
1495
|
+
const abortDragAny = useCallback(() => {
|
|
1496
|
+
engine.abortDrag()
|
|
1497
|
+
colHeaderMachine.abortDrag()
|
|
1498
|
+
rowHeaderMachine.abortDrag()
|
|
1499
|
+
headerResizeMachine.abortDrag()
|
|
1500
|
+
fillMachine.abortDrag()
|
|
1501
|
+
// 参照ドラッグの中断はトークンをドラッグ前へ戻すだけ (セッション継続 — §4.2.7 の Escape 規律)
|
|
1502
|
+
pointDrag.abortDrag()
|
|
1503
|
+
}, [engine, colHeaderMachine, rowHeaderMachine, headerResizeMachine, fillMachine, pointDrag])
|
|
1504
|
+
|
|
1505
|
+
// ---- 編集セッション (§6.7.1) + クリップボード (§6.5.4) — シームの実体 ----
|
|
1506
|
+
const getActive = useCallback(() => selectionRef.current.active, [])
|
|
1507
|
+
/**
|
|
1508
|
+
* The validation gate (v0.8 §4.2.6 / 分冊 08 §4.2), shared by the editor's changed commit and
|
|
1509
|
+
* the editing-column `fillSelection` (which runs it per target cell): no rule / non-strict ⇒
|
|
1510
|
+
* ok (the cell keeps its invalid mark); only a strict rejection stops the commit and reports
|
|
1511
|
+
* through `onValidationRejected` + the `message` popover (`rejected`).
|
|
1512
|
+
* 検証ゲート (v0.8 §4.2.6 / 分冊 08 §4.2) — エディタの変更ある確定と編集列 fillSelection (対象セル毎)
|
|
1513
|
+
* が共有。規則なし / 非 strict は受理 (セルは不正表示のまま)、strict の拒否だけが commit を止めて
|
|
1514
|
+
* onValidationRejected + message ポップオーバー (rejected) を出す。
|
|
1515
|
+
*/
|
|
1516
|
+
const validateCommit = useCallback(
|
|
1517
|
+
(cell: CellCoord, text: string): ValidationVerdict => {
|
|
1518
|
+
const rule = adapter.getCellValidation === undefined ? undefined : adapter.getCellValidation(cell.row, cell.col)
|
|
1519
|
+
if (rule === undefined) {
|
|
1520
|
+
return OK_VERDICT
|
|
1521
|
+
}
|
|
1522
|
+
const evaluateFormula = adapter.evaluateFormula
|
|
1523
|
+
const verdict = validateCellText(rule, text, evaluateFormula === undefined ? undefined : (formula) => evaluateFormula(formula, cell))
|
|
1524
|
+
if (verdict.ok || rule.strict !== true) {
|
|
1525
|
+
return OK_VERDICT
|
|
1526
|
+
}
|
|
1527
|
+
onValidationRejected?.({ cell, rule, text, message: verdict.message })
|
|
1528
|
+
// message kind が有効なときだけ拒否メッセージを載せる (無効 kind の open は RangeError)
|
|
1529
|
+
if (popoversRef.current?.isKindEnabled("message")) {
|
|
1530
|
+
openPopoverRef.current("message", { message: verdict.message, rejected: true })
|
|
1531
|
+
}
|
|
1532
|
+
return verdict
|
|
1533
|
+
},
|
|
1534
|
+
[adapter, onValidationRejected],
|
|
1535
|
+
)
|
|
1536
|
+
const editor = useCellEditor({
|
|
1537
|
+
enabled: !isEmpty,
|
|
1538
|
+
sinkRef,
|
|
1539
|
+
rootRef,
|
|
1540
|
+
adapter,
|
|
1541
|
+
commitEdits,
|
|
1542
|
+
getActive,
|
|
1543
|
+
ensureCellVisible,
|
|
1544
|
+
focusSink,
|
|
1545
|
+
rearmSink,
|
|
1546
|
+
discardClip,
|
|
1547
|
+
isDragging: isAnyDragging,
|
|
1548
|
+
// Excel 同様、編集中もセルの水平揃えを保つ (数値セルは右揃えのまま編集)。getCellFormat 未宣言は
|
|
1549
|
+
// null = v0.1 恒等 (textarea の text-align を触らない)。宣言時は塗り書式 (base ⊕ CF) で判定し
|
|
1550
|
+
// text は遅延 1 回読み (v0.8 §4.2.13)
|
|
1551
|
+
resolveEditAlign: (cell) => {
|
|
1552
|
+
if (adapter.getCellFormat === undefined) {
|
|
1553
|
+
return null
|
|
1554
|
+
}
|
|
1555
|
+
const readText = lazyCellText(adapter, cell.row, cell.col)
|
|
1556
|
+
return resolveCellAlign(paintedFormatOf(cell.row, cell.col, readText), readText)
|
|
1557
|
+
},
|
|
1558
|
+
// エディタ塗り (v0.8 §2.3): 塗り書式の文字装飾をシンクへ、行箱 / 垂直揃えは editPaintRef 経由で overlay へ
|
|
1559
|
+
resolveEditPaint: (cell) => (adapter.getCellFormat === undefined ? null : resolveEditPaint(paintedFormatOf(cell.row, cell.col, lazyCellText(adapter, cell.row, cell.col)))),
|
|
1560
|
+
editPaintRef,
|
|
1561
|
+
repaintOverlay,
|
|
1562
|
+
// 検証ゲート (v0.8 §4.2.6) — 編集列 fillSelection と共有する root の useCallback
|
|
1563
|
+
validateCommit,
|
|
1564
|
+
})
|
|
1565
|
+
// レンダー時代入でシームを実体へ接続 (paramsRef と同じ規律 — 構築循環切りの ref 間接)
|
|
1566
|
+
isEditingRef.current = editor.isEditing
|
|
1567
|
+
requestCommitRef.current = editor.requestCommit
|
|
1568
|
+
startEditRef.current = editor.startEdit
|
|
1569
|
+
getPointingRef.current = editor.getPointing
|
|
1570
|
+
insertReferenceRef.current = editor.insertReference
|
|
1571
|
+
captureSinkRef.current = editor.captureSink
|
|
1572
|
+
restoreSinkRef.current = editor.restoreSink
|
|
1573
|
+
|
|
1574
|
+
/**
|
|
1575
|
+
* Session cancel + the dismissal of a stale strict-rejection notice (分冊 08 §4.3): the
|
|
1576
|
+
* rejection belongs to the abandoned commit attempt, so a cancel that ends (or discards)
|
|
1577
|
+
* the session also closes its `message` popover — Excel dismisses the rejection dialog
|
|
1578
|
+
* together with the edit. The passive inputMessage is untouched (closeRejectedMessage).
|
|
1579
|
+
* セッション取消 + stale な strict 拒否告知の消灯 (分冊 08 §4.3)。拒否は放棄した commit 試行の
|
|
1580
|
+
* もの — セッションを終える (捨てる) 取消はその message ポップオーバーも閉じる (Excel は編集
|
|
1581
|
+
* ごと拒否ダイアログを畳む)。受動 inputMessage には触れない (closeRejectedMessage)。
|
|
1582
|
+
*/
|
|
1583
|
+
const cancelEdit = useCallback(() => {
|
|
1584
|
+
editor.cancelEdit()
|
|
1585
|
+
closeRejectedMessage()
|
|
1586
|
+
}, [editor.cancelEdit, closeRejectedMessage])
|
|
1587
|
+
|
|
1588
|
+
const clipboard = useClipboard({
|
|
1589
|
+
enabled: !isEmpty,
|
|
1590
|
+
sinkRef,
|
|
1591
|
+
adapter,
|
|
1592
|
+
isEditing,
|
|
1593
|
+
getSelection,
|
|
1594
|
+
setSelection,
|
|
1595
|
+
commitPaste,
|
|
1596
|
+
maxOperationCells,
|
|
1597
|
+
onOperationRejected,
|
|
1598
|
+
onCopyMultiRange,
|
|
1599
|
+
repaintOverlay,
|
|
1600
|
+
isDragging: isAnyDragging,
|
|
1601
|
+
// v0.8 (§4.2.5): 寸法シーム (貼り付け寸法は gateResizeEntries を通り resizeTracks へ)、Ctrl+Shift+V の
|
|
1602
|
+
// 武装 ref、通貨宣言、クリップ / 拒否 / 跨ぎ cut 元側の通知
|
|
1603
|
+
resize: { rowAxis, colAxis, resizeTracks },
|
|
1604
|
+
pendingPasteOptionsRef,
|
|
1605
|
+
formats,
|
|
1606
|
+
onClipChange,
|
|
1607
|
+
onClipboardUnavailable,
|
|
1608
|
+
onEditRejected,
|
|
1609
|
+
})
|
|
1610
|
+
getClipRef.current = clipboard.getClip
|
|
1611
|
+
discardClipRef.current = clipboard.discardClip
|
|
1612
|
+
discardClipOnCommitRef.current = clipboard.discardClipOnCommit
|
|
1613
|
+
onAdapterSwitchRef.current = clipboard.onAdapterSwitch
|
|
1614
|
+
pasteRef.current = clipboard.paste
|
|
1615
|
+
|
|
1616
|
+
// ---- ポップオーバー層 (v0.8 §4.1.15 / ADR-15) ----
|
|
1617
|
+
const popovers = usePopovers({
|
|
1618
|
+
enabled: !isEmpty,
|
|
1619
|
+
adapter,
|
|
1620
|
+
popovers: popoversProp,
|
|
1621
|
+
sinkRef,
|
|
1622
|
+
bodyRef,
|
|
1623
|
+
handleRef,
|
|
1624
|
+
rowAxis,
|
|
1625
|
+
colAxis,
|
|
1626
|
+
zoomRef,
|
|
1627
|
+
getActive,
|
|
1628
|
+
isEditing,
|
|
1629
|
+
requestCommit,
|
|
1630
|
+
focusSink,
|
|
1631
|
+
rearmSink,
|
|
1632
|
+
isDraggingAny,
|
|
1633
|
+
commitEdits,
|
|
1634
|
+
labels: resolvedLabels,
|
|
1635
|
+
onHyperlinkOpen,
|
|
1636
|
+
anchors: popoverAnchorsRef,
|
|
1637
|
+
repaintOverlay,
|
|
1638
|
+
})
|
|
1639
|
+
popoversRef.current = popovers
|
|
1640
|
+
openPopoverRef.current = popovers.open
|
|
1641
|
+
hideNoteTipRef.current = popovers.hideTip
|
|
1642
|
+
|
|
1643
|
+
// ---- コマンド実行 (v0.8 §4.1.13 / ADR-17) ----
|
|
1644
|
+
/** The active cell's viewport rect in visual px — the keyboard-origin cellRect recipe (onCellContextMenu twin) for onPasteSpecialRequest. / active セルの視覚 px 矩形 (keyboard 起点 cellRect のレシピ — onCellContextMenu と同式)。 */
|
|
1645
|
+
const keyboardCellRect = useCallback(() => {
|
|
1646
|
+
const active = selectionRef.current.active
|
|
1647
|
+
// 画面外座標へメニューを出さない (§6.6.1) — 可視化してからハンドルの同期読み (§7-4)
|
|
1648
|
+
ensureCellVisible(active)
|
|
1649
|
+
const handle = handleRef.current
|
|
1650
|
+
const position = handle === null ? { x: 0, y: 0 } : handle.getScrollPosition()
|
|
1651
|
+
const frozen = handle === null ? { rows: 0, cols: 0 } : handle.getFrozenSize()
|
|
1652
|
+
// 両軸とも番兵クランプ (detached の {-1, -1} を座標へ流さない)、ズーム境界で視覚 px へ
|
|
1653
|
+
const z = zoomRef.current
|
|
1654
|
+
return {
|
|
1655
|
+
top: rowToViewportY(rowAxis, active.row, Math.max(0, position.y), frozen.rows) * z,
|
|
1656
|
+
left: colToViewportX(colAxis, active.col, Math.max(0, position.x), frozen.cols) * z,
|
|
1657
|
+
width: colAxis.sizeOf(active.col) * z,
|
|
1658
|
+
height: rowAxis.sizeOf(active.row) * z,
|
|
1659
|
+
}
|
|
1660
|
+
}, [rowAxis, colAxis, ensureCellVisible])
|
|
1661
|
+
const commands = useCommands({
|
|
1662
|
+
adapter,
|
|
1663
|
+
getSelection,
|
|
1664
|
+
commitEdits,
|
|
1665
|
+
getActive,
|
|
1666
|
+
isEditing,
|
|
1667
|
+
requestCommit,
|
|
1668
|
+
cancelEdit,
|
|
1669
|
+
getSessionText: editor.getSessionText,
|
|
1670
|
+
validateCommit,
|
|
1671
|
+
insertAtCaret: editor.insertAtCaret,
|
|
1672
|
+
refreshEditPaint: editor.refreshPaint,
|
|
1673
|
+
maxOperationCells,
|
|
1674
|
+
onOperationRejected,
|
|
1675
|
+
colors: resolvedColors,
|
|
1676
|
+
formats,
|
|
1677
|
+
getHighlight: () => highlightRef.current,
|
|
1678
|
+
setHighlight: requestHighlight,
|
|
1679
|
+
popovers: popoversProp,
|
|
1680
|
+
openPopover: (kind) => openPopoverRef.current(kind),
|
|
1681
|
+
onHyperlinkOpen,
|
|
1682
|
+
onPasteSpecialRequest,
|
|
1683
|
+
paste: (options) => pasteRef.current(options),
|
|
1684
|
+
rowAxis,
|
|
1685
|
+
colAxis,
|
|
1686
|
+
resizeTracks,
|
|
1687
|
+
defaultRowHeight,
|
|
1688
|
+
defaultColumnWidth,
|
|
1689
|
+
now,
|
|
1690
|
+
announce,
|
|
1691
|
+
formatCommandAnnouncement,
|
|
1692
|
+
keyboardCellRect,
|
|
1693
|
+
})
|
|
1694
|
+
commandsRef.current = commands
|
|
1695
|
+
executeCommandRef.current = commands.executeCommand
|
|
1696
|
+
isCommandAvailableRef.current = commands.isCommandAvailable
|
|
1697
|
+
/** Keyboard command dispatch (one declaration per table identity; execute / isAvailable through the refs). / キーボードのコマンド配送 (表 identity 毎に 1 宣言、実行は ref 経由)。 */
|
|
1698
|
+
const commandDispatch = useMemo((): CommandDispatch => ({ table: commandTable, execute: (command) => executeCommandRef.current(command), isAvailable: (command) => isCommandAvailableRef.current(command) }), [commandTable])
|
|
1699
|
+
|
|
1700
|
+
// Undo / Redo の発火はクリップの破棄条件 (§6.5.4-4 — 巻き戻し後の古い指紋で move を成立させない。copy アンツも Excel 同様消灯)
|
|
1701
|
+
const handleUndoRequest = useCallback(() => {
|
|
1702
|
+
discardClip()
|
|
1703
|
+
onUndoRequest?.()
|
|
1704
|
+
}, [discardClip, onUndoRequest])
|
|
1705
|
+
const handleRedoRequest = useCallback(() => {
|
|
1706
|
+
discardClip()
|
|
1707
|
+
onRedoRequest?.()
|
|
1708
|
+
}, [discardClip, onRedoRequest])
|
|
1709
|
+
|
|
1710
|
+
// ---- キーボード (§6.6) ----
|
|
1711
|
+
useGridKeyboard({
|
|
1712
|
+
zoomRef,
|
|
1713
|
+
enabled: !isEmpty,
|
|
1714
|
+
sinkRef,
|
|
1715
|
+
rootRef,
|
|
1716
|
+
handOffFocusToRoot,
|
|
1717
|
+
getSelection,
|
|
1718
|
+
setSelection,
|
|
1719
|
+
moveActive,
|
|
1720
|
+
ensureCellVisible,
|
|
1721
|
+
isEditing,
|
|
1722
|
+
requestCommit,
|
|
1723
|
+
startEdit,
|
|
1724
|
+
commitEdits,
|
|
1725
|
+
maxOperationCells,
|
|
1726
|
+
onOperationRejected,
|
|
1727
|
+
getClip,
|
|
1728
|
+
discardClip,
|
|
1729
|
+
isDragging: isDraggingAny,
|
|
1730
|
+
abortDrag: abortDragAny,
|
|
1731
|
+
// 能力はキーごとに paramsRef 経由で読まれる — adapter identity 切替 (シート切替) の
|
|
1732
|
+
// 再レンダーで自然に追随する (§2.1)
|
|
1733
|
+
findDataEdge: adapter.findDataEdge,
|
|
1734
|
+
getEditingMode: editor.getEditingMode,
|
|
1735
|
+
cancelEdit,
|
|
1736
|
+
onUndoRequest: handleUndoRequest,
|
|
1737
|
+
onRedoRequest: handleRedoRequest,
|
|
1738
|
+
onCellContextMenu,
|
|
1739
|
+
rowAxis,
|
|
1740
|
+
colAxis,
|
|
1741
|
+
handleRef,
|
|
1742
|
+
rearmSink,
|
|
1743
|
+
// v0.8 (§4.2.7): コマンド表、編集列のポインティング行、Delete の hyperlink 消去、Escape step 0、Ctrl+Shift+V
|
|
1744
|
+
commands: commandDispatch,
|
|
1745
|
+
pointing: editor,
|
|
1746
|
+
adapter,
|
|
1747
|
+
hideNoteTip: () => hideNoteTipRef.current(),
|
|
1748
|
+
// Escape チェーン step 1 (分冊 08 §4.3): strict 拒否 message の消灯 (受動 inputMessage は対象外)
|
|
1749
|
+
closeRejectedMessage,
|
|
1750
|
+
pendingPasteOptionsRef,
|
|
1751
|
+
// ポインティング行の参照矩形 (分冊 04 §3.20): 固定 "point" モードの writer リレー
|
|
1752
|
+
onPointFrame,
|
|
1753
|
+
})
|
|
1754
|
+
|
|
1755
|
+
// ---- 入力メッセージ (root 方針): active セルが変わるたび、前のメッセージを畳んで新セルの inputMessage を出す ----
|
|
1756
|
+
const activeRow = selection.active.row
|
|
1757
|
+
const activeCol = selection.active.col
|
|
1758
|
+
useLayoutEffect(() => {
|
|
1759
|
+
const controller = popoversRef.current
|
|
1760
|
+
if (controller === null) {
|
|
1761
|
+
return
|
|
1762
|
+
}
|
|
1763
|
+
// tip ↔ シンクの説明結合は「ホバーセルが active の間」の不変条件 (ADR-15) — active 変化のたびに再評価する
|
|
1764
|
+
// (tip の載るセルから離れれば外し、tip の載るセルへ乗れば結ぶ。message 能力とは独立)
|
|
1765
|
+
controller.syncTipAriaDescribedBy()
|
|
1766
|
+
if (!controller.isKindEnabled("message")) {
|
|
1767
|
+
return
|
|
1768
|
+
}
|
|
1769
|
+
// 前セルの message (入力メッセージ / strict 拒否) は閉じる — エディタ kind は触らない
|
|
1770
|
+
if (controller.isOpen() === "message") {
|
|
1771
|
+
controller.close()
|
|
1772
|
+
}
|
|
1773
|
+
// 新 active セルに inputMessage があれば載せる (無ければ open は no-op)
|
|
1774
|
+
controller.open("message")
|
|
1775
|
+
}, [activeRow, activeCol])
|
|
1776
|
+
|
|
1777
|
+
// 強調状態の変化 (制御 / 非制御どちらも解決値) は overlay の命令再描画 (帯は repaint 時にだけ読まれる)
|
|
1778
|
+
useLayoutEffect(() => {
|
|
1779
|
+
repaintOverlay()
|
|
1780
|
+
}, [repaintOverlay, highlightValue.row, highlightValue.column, highlightValue.intersection])
|
|
1781
|
+
|
|
1782
|
+
// ---- シンクの直クリックガード (idle の armed 選択保護 — §4.2 ADR-4。所有は SelectionOverlay) ----
|
|
1783
|
+
const onSinkMouseDown = useCallback(
|
|
1784
|
+
(event: MouseEvent) => {
|
|
1785
|
+
if (!(isEditingRef.current?.() ?? false)) {
|
|
1786
|
+
event.preventDefault()
|
|
1787
|
+
focusSink()
|
|
1788
|
+
rearmSink()
|
|
1789
|
+
}
|
|
1790
|
+
},
|
|
1791
|
+
[focusSink, rearmSink],
|
|
1792
|
+
)
|
|
1793
|
+
|
|
1794
|
+
// ---- WCAG 2.1.2 再進入 (§10-4): React onFocus (focusin バブリング) + relatedTarget 規則 ----
|
|
1795
|
+
const onRootFocus = useCallback(
|
|
1796
|
+
(event: React.FocusEvent<HTMLDivElement>) => {
|
|
1797
|
+
const root = rootRef.current
|
|
1798
|
+
const related = event.relatedTarget
|
|
1799
|
+
// relatedTarget が root 内 (シンク含む) の内部移動。**シンク**を離れて root 自身に着地した
|
|
1800
|
+
// フォーカスは、Escape の脱出 (旗つき — handOffFocusToRoot) 以外は押下既定動作が逃がした迷子
|
|
1801
|
+
// (touch・ペンのタップは OSK 回避で素通し / 押下ゲートが見ない chord 遷移の取りこぼし) —
|
|
1802
|
+
// シンクへ戻す (再武装しない: 編集セッションの値を守る)。シンクはキーボードでは root へ焦点を
|
|
1803
|
+
// 渡さない (Tab は巡回として消費、Escape は旗つき) ため、旗なしのシンク → root は常に迷子。
|
|
1804
|
+
// 他の root 内要素からの着地 (スクロールバー矢印ボタンや renderCell のフォーカス要素からの
|
|
1805
|
+
// Shift+Tab 後方巡回) は休止点として root に留める — 戻すと後方 Tab がグリッド内で巡回に
|
|
1806
|
+
// 消費され、後方脱出が Escape 経由でしかできなくなる (R3 / R4 是正 — フォーカス不変条件)
|
|
1807
|
+
if (root !== null && related instanceof Node && root.contains(related)) {
|
|
1808
|
+
if (event.target === root && related === sinkRef.current && !escapeHandOffRef.current) {
|
|
1809
|
+
focusSink()
|
|
1810
|
+
}
|
|
1811
|
+
return
|
|
1812
|
+
}
|
|
1813
|
+
// 編集セッション生存中は再武装しない — requestCommit の composition 経路 (blur →
|
|
1814
|
+
// focusSink) は relatedTarget=null の focusin をここへ届け、rearm すると commit 失敗で
|
|
1815
|
+
// 維持したはずの編集値を " " で上書き破壊する (suppressFocusoutCommit と対の窓)
|
|
1816
|
+
if (isEditingRef.current?.() ?? false) {
|
|
1817
|
+
return
|
|
1818
|
+
}
|
|
1819
|
+
// 外部からの Tab / Shift+Tab 進入・プログラム focus → シンクへ回収 + 再武装
|
|
1820
|
+
focusSink()
|
|
1821
|
+
rearmSink()
|
|
1822
|
+
},
|
|
1823
|
+
[focusSink, rearmSink],
|
|
1824
|
+
)
|
|
1825
|
+
|
|
1826
|
+
// ---- グリッド脱出 (root の focusout バブリング) で仮想キーボードの招待を下ろす ----
|
|
1827
|
+
/** Withdraws the virtual-keyboard invitation when focus really leaves the grid (in-root moves and the editor's composition-confirming internal blur are not exits). / 本当にグリッド外へ出る focusout で招待を下ろす (root 内移動と composition 確定の内部 blur は脱出でない)。 */
|
|
1828
|
+
const onRootBlur = useCallback(
|
|
1829
|
+
(event: React.FocusEvent<HTMLDivElement>) => {
|
|
1830
|
+
const root = rootRef.current
|
|
1831
|
+
const related = event.relatedTarget
|
|
1832
|
+
if (root !== null && related instanceof Node && root.contains(related)) {
|
|
1833
|
+
return
|
|
1834
|
+
}
|
|
1835
|
+
// requestCommit の composition 確定 blur (relatedTarget null) は直後に focusSink で戻る内部往復 —
|
|
1836
|
+
// 招待を下ろすと変換中に別セルを押した利用者の OSK が消える
|
|
1837
|
+
if (editor.isFocusoutSuppressed()) {
|
|
1838
|
+
return
|
|
1839
|
+
}
|
|
1840
|
+
setVirtualKeyboard(false)
|
|
1841
|
+
},
|
|
1842
|
+
[editor],
|
|
1843
|
+
)
|
|
1844
|
+
|
|
1845
|
+
// ---- 中ボタンリリースの抑止 (R3 / R4 是正): Linux Chromium の PRIMARY selection 貼り付けは中ボタン
|
|
1846
|
+
// **リリース**の既定動作。押下面の preventDefault でフォーカスがシンクに留まるため、リリースを
|
|
1847
|
+
// 止めないとシンクへ paste が届き、選択中の " " (または直近の PRIMARY 文字列) がアクティブセル /
|
|
1848
|
+
// 選択範囲へ静かに書き込まれる (実ブラウザ実証: pointerup の preventDefault で貼り付けは止まる)。
|
|
1849
|
+
// 武装は 1 回限りの window capture (グリッド外リリースも止める)、pointercancel で解除 ----
|
|
1850
|
+
const middleReleaseRef = useRef<{ up: (event: PointerEvent) => void; cancel: (event: PointerEvent) => void } | null>(null)
|
|
1851
|
+
/** Disarms the middle-release canceller (idempotent). / 中ボタンリリース抑止の解除 (冪等)。 */
|
|
1852
|
+
const disarmMiddleRelease = useCallback(() => {
|
|
1853
|
+
const armed = middleReleaseRef.current
|
|
1854
|
+
if (armed === null) {
|
|
1855
|
+
return
|
|
1856
|
+
}
|
|
1857
|
+
middleReleaseRef.current = null
|
|
1858
|
+
window.removeEventListener("pointerup", armed.up, true)
|
|
1859
|
+
window.removeEventListener("pointercancel", armed.cancel, true)
|
|
1860
|
+
}, [])
|
|
1861
|
+
/** Arms the one-shot middle-release canceller: the next pointerup of any button disarms it, a middle one is also cancelled. / 中ボタンリリース抑止の武装 (次の pointerup で解除、中ボタンなら既定動作も止める)。 */
|
|
1862
|
+
const armMiddleRelease = useCallback(() => {
|
|
1863
|
+
if (middleReleaseRef.current !== null) {
|
|
1864
|
+
return
|
|
1865
|
+
}
|
|
1866
|
+
const up = (event: PointerEvent) => {
|
|
1867
|
+
// mouse のリリースだけが解除 / 抑止の対象 — 中ボタン保持中の touch タップ (別ポインタ) で
|
|
1868
|
+
// 解除すると本物の中ボタンリリースが素通りし PRIMARY 貼り付けの穴が再開する (R5 是正)
|
|
1869
|
+
if (event.pointerType !== "mouse") {
|
|
1870
|
+
return
|
|
1871
|
+
}
|
|
1872
|
+
disarmMiddleRelease()
|
|
1873
|
+
if (event.button === 1) {
|
|
1874
|
+
event.preventDefault()
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
const cancel = (event: PointerEvent) => {
|
|
1878
|
+
if (event.pointerType === "mouse") {
|
|
1879
|
+
disarmMiddleRelease()
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
middleReleaseRef.current = { up, cancel }
|
|
1883
|
+
window.addEventListener("pointerup", up, true)
|
|
1884
|
+
window.addEventListener("pointercancel", cancel, true)
|
|
1885
|
+
}, [disarmMiddleRelease])
|
|
1886
|
+
useEffect(() => disarmMiddleRelease, [disarmMiddleRelease])
|
|
1887
|
+
|
|
1888
|
+
// ---- root capture のポインタ監視 (R3 / R4 是正): 中ボタン押下でリリース抑止を武装し、chord 遷移
|
|
1889
|
+
// (主ボタン保持中の 2 本目の押下 / 解放) の既定動作を止める。chord 遷移は pointerdown / pointerup
|
|
1890
|
+
// でなく **pointermove** (buttons の変化) で届くため押下面のゲートには見えない — 止めないと compat
|
|
1891
|
+
// mousedown がフォーカスを root や矢印ボタンへ逃がし、compat mouseup が PRIMARY 貼り付けを
|
|
1892
|
+
// シンクへ届ける (実ブラウザ実証)。主ボタン先行解放後の実 pointerup (中ボタン) は武装済みの
|
|
1893
|
+
// 抑止が止める ----
|
|
1894
|
+
useEffect(() => {
|
|
1895
|
+
const root = rootRef.current
|
|
1896
|
+
if (isEmpty || root === null) {
|
|
1897
|
+
return
|
|
1898
|
+
}
|
|
1899
|
+
// 直近に観測した mouse の buttons (chord 遷移 = 同じポインタの buttons が move で変わること)
|
|
1900
|
+
let lastButtons = 0
|
|
1901
|
+
const onPointerDown = (event: PointerEvent) => {
|
|
1902
|
+
if (event.pointerType !== "mouse") {
|
|
1903
|
+
// touch / ペンの押下 = スクロール意図: 仮想キーボードの招待を下ろす (タップでシンクへ焦点が
|
|
1904
|
+
// 戻っても OSK を出さない — 招待中でなければ同値 setState で無再描画)。**セッション中は
|
|
1905
|
+
// 下ろさない** (R8 是正): 生き残る変換は必ずセッションの中にある (ドラッグ飛行中に始まった
|
|
1906
|
+
// 変換だけがセッションを持たず、それは待機シンクの自己修復が捨てる) ため、この 1 条件で
|
|
1907
|
+
// 「変換中に属性が変わらない」が成立する (グリッド脱出の解除は blur が変換を先に確定してから届く)。
|
|
1908
|
+
// 編集中の touch 押下はキャレット配置 / スクロールで、OSK で入力中の利用者から
|
|
1909
|
+
// キーボードを奪わない — 解除はセッション終了後の次の touch 押下
|
|
1910
|
+
if (!(isEditingRef.current?.() ?? false)) {
|
|
1911
|
+
setVirtualKeyboard(false)
|
|
1912
|
+
}
|
|
1913
|
+
return
|
|
1914
|
+
}
|
|
1915
|
+
lastButtons = event.buttons
|
|
1916
|
+
if (event.button !== 1) {
|
|
1917
|
+
return
|
|
1918
|
+
}
|
|
1919
|
+
// 編集中のエディタ (シンク) への中クリックは textarea の PRIMARY 貼り付け (プラットフォーム慣習)
|
|
1920
|
+
if (event.target === sinkRef.current && (isEditingRef.current?.() ?? false)) {
|
|
1921
|
+
return
|
|
1922
|
+
}
|
|
1923
|
+
armMiddleRelease()
|
|
1924
|
+
}
|
|
1925
|
+
const onPointerUp = (event: PointerEvent) => {
|
|
1926
|
+
if (event.pointerType === "mouse") {
|
|
1927
|
+
lastButtons = event.buttons
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
const onPointerMove = (event: PointerEvent) => {
|
|
1931
|
+
if (event.pointerType !== "mouse" || event.buttons === lastButtons) {
|
|
1932
|
+
return
|
|
1933
|
+
}
|
|
1934
|
+
const gained = event.buttons & ~lastButtons
|
|
1935
|
+
lastButtons = event.buttons
|
|
1936
|
+
// chord 遷移の既定動作を止める (フォーカス移動 / PRIMARY 貼り付け)。各機械の own move 処理は
|
|
1937
|
+
// 素通し (preventDefault は伝播を止めない — 主ボタンの chord 解放はそこで確定する)
|
|
1938
|
+
event.preventDefault()
|
|
1939
|
+
if ((gained & 4) !== 0) {
|
|
1940
|
+
armMiddleRelease()
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
root.addEventListener("pointerdown", onPointerDown, true)
|
|
1944
|
+
root.addEventListener("pointerup", onPointerUp, true)
|
|
1945
|
+
root.addEventListener("pointermove", onPointerMove, true)
|
|
1946
|
+
return () => {
|
|
1947
|
+
root.removeEventListener("pointerdown", onPointerDown, true)
|
|
1948
|
+
root.removeEventListener("pointerup", onPointerUp, true)
|
|
1949
|
+
root.removeEventListener("pointermove", onPointerMove, true)
|
|
1950
|
+
}
|
|
1951
|
+
}, [isEmpty, armMiddleRelease])
|
|
1952
|
+
|
|
1953
|
+
// ---- 未所有面の最終ゲート (§6.8-3 の第 8 サイト — R3 / R4 是正) ----
|
|
1954
|
+
/** Dead-space press gate on the zoom wrapper's OWN pixels (e.g. the strip under the row-header band): cancel the focus move, commit gate, focus recovery. Owned surfaces keep their own gates — this one never covers for them. / ズームラッパー自身のピクセル (行ヘッダー帯下の余白等) の押下ゲート。所有面の肩代わりはしない。 */
|
|
1955
|
+
const onZoomPointerDown = useCallback(
|
|
1956
|
+
(event: React.PointerEvent<HTMLDivElement>) => {
|
|
1957
|
+
// ラッパー自身が標的の押下だけを受け持つ (所有面 — セル / ヘッダー / コーナー / バー帯 / fill /
|
|
1958
|
+
// リサイズ / シンク — の押下は各面のゲートの責務。defaultPrevented で肩代わりすると各面の
|
|
1959
|
+
// 欠落が本ゲートに隠れて検出不能になる — R4 是正)。touch / ペンは素通し (OSK は仮想キーボード
|
|
1960
|
+
// 招待フラグが扱う — touch / ペンの押下は root capture の監視が招待を下ろす。迷子は onRootFocus が回収する)
|
|
1961
|
+
if (event.target !== event.currentTarget || event.pointerType !== "mouse") {
|
|
1962
|
+
return
|
|
1963
|
+
}
|
|
1964
|
+
// 止めないと mousedown 既定が root group へフォーカスを移しキーボードが全滅する (コーナーと同型)
|
|
1965
|
+
event.preventDefault()
|
|
1966
|
+
if (event.button !== 0) {
|
|
1967
|
+
return
|
|
1968
|
+
}
|
|
1969
|
+
// 参照ポインティング中 (v0.8 §4.2.13): 死空間押下は consume してシンクへ焦点を戻すだけ (確定も挿入もしない)
|
|
1970
|
+
if (isPointing()) {
|
|
1971
|
+
focusSink()
|
|
1972
|
+
return
|
|
1973
|
+
}
|
|
1974
|
+
// 編集中の requestCommit ゲート (§6.8-3): 死空間もフォーカス不動で focusout が発火しない
|
|
1975
|
+
if (isEditing() && !requestCommit()) {
|
|
1976
|
+
focusSink()
|
|
1977
|
+
return
|
|
1978
|
+
}
|
|
1979
|
+
focusSink()
|
|
1980
|
+
},
|
|
1981
|
+
[focusSink, isEditing, requestCommit, isPointing],
|
|
1982
|
+
)
|
|
1983
|
+
|
|
1984
|
+
// ---- マウント武装 + 初期 rowRange 種付け (空→非空「エッジ」限定 — Phase 1 実装レビュー R3 の
|
|
1985
|
+
// correctness 実欠陥ゲート。deps は rowAxis 再構築でも発火するため、エッジ判定 ref で
|
|
1986
|
+
// 「非空のまま軸だけ変わった」ケースの再種付け (深部スクロール中の rowRange 巻き戻し) を防ぐ) ----
|
|
1987
|
+
const seededRef = useRef(false)
|
|
1988
|
+
useLayoutEffect(() => {
|
|
1989
|
+
if (isEmpty) {
|
|
1990
|
+
seededRef.current = false
|
|
1991
|
+
return
|
|
1992
|
+
}
|
|
1993
|
+
if (seededRef.current) {
|
|
1994
|
+
return
|
|
1995
|
+
}
|
|
1996
|
+
seededRef.current = true
|
|
1997
|
+
// フォーカス中立の武装 — マウントでページのフォーカスを奪わない (v0.7 まで select() の副作用で
|
|
1998
|
+
// Blink / WebKit がシンクを自動フォーカスしていた。キーボード進入は Tab / クリックの再進入経路)
|
|
1999
|
+
rearmSink()
|
|
2000
|
+
// 初期 rowRange の種付け: onRangeChange の初回配送は rAF ×2 遅れる。getRange() は
|
|
2001
|
+
// 「pull かつ 1 レンダー古い」、getViewportSize() も初回計測前は {0, 0} (未計測初期値。
|
|
2002
|
+
// -1 番兵は getScrollPosition の detached 系のみ — 実測) のため、
|
|
2003
|
+
// root の clientHeight をマウント時 1 回だけ layout read して可視域を種付けする
|
|
2004
|
+
// (§6.7.2-3 の gBCR 禁止規律はホットパス対象 — マウント 1 回は対象外。ヘッダー帯ぶんの
|
|
2005
|
+
// わずかな過剰被覆は無害で、直後の onRangeChange 初回配送が正す)
|
|
2006
|
+
// ズーム下では root clientHeight は視覚 px — 論理 px へ ÷z (v0.3。z=1 は従来どおり)
|
|
2007
|
+
const seedHeight = (rootRef.current?.clientHeight ?? 0) / zoomRef.current
|
|
2008
|
+
if (seedHeight > 0 && rowAxis.totalSize() > 0) {
|
|
2009
|
+
viewStore.setRowRange(0, rowAxis.indexAt(Math.min(seedHeight - 1, rowAxis.totalSize() - 1)))
|
|
2010
|
+
}
|
|
2011
|
+
// 列レンジも対称に種付け (§7-5 — ColumnHeader の初回可視化。行ヘッダー帯ぶんの過剰被覆は
|
|
2012
|
+
// 行側と同じく無害で、onRangeChange の初回配送が正す)
|
|
2013
|
+
const seedWidth = (rootRef.current?.clientWidth ?? 0) / zoomRef.current
|
|
2014
|
+
if (seedWidth > 0 && colAxis.totalSize() > 0) {
|
|
2015
|
+
viewStore.setColRange(0, colAxis.indexAt(Math.min(seedWidth - 1, colAxis.totalSize() - 1)))
|
|
2016
|
+
}
|
|
2017
|
+
}, [isEmpty, rearmSink, viewStore, rowAxis, colAxis])
|
|
2018
|
+
|
|
2019
|
+
// ---- シート切替 (adapter identity 変化 — §6.2.3) は選択を初期 A1 へリセットする ----
|
|
2020
|
+
// 旧グリッド座標のレンジは新軸で out-of-bounds になり得て、repaintOverlay の sizeOf が
|
|
2021
|
+
// 例外を投げる (Fenwick は範囲外 index で fail-fast) — クランプでなくシート切替の意味論
|
|
2022
|
+
// どおり全リセット (Excel のシート切替と同じく選択は持ち越さない)
|
|
2023
|
+
const prevAdapterRef = useRef(adapter)
|
|
2024
|
+
useLayoutEffect(() => {
|
|
2025
|
+
if (prevAdapterRef.current !== adapter) {
|
|
2026
|
+
prevAdapterRef.current = adapter
|
|
2027
|
+
// 飛行中ドラッグは復元なしで取り消す (各機械の dragBeforeRef は旧シート座標 — 復元経路
|
|
2028
|
+
// (Escape / lostpointercapture) から新軸へ範囲外選択が漏れるのを断つ)
|
|
2029
|
+
engine.cancelDrag()
|
|
2030
|
+
colHeaderMachine.cancelDrag()
|
|
2031
|
+
rowHeaderMachine.cancelDrag()
|
|
2032
|
+
headerResizeMachine.cancelDrag()
|
|
2033
|
+
fillMachine.cancelDrag()
|
|
2034
|
+
// 参照ドラッグ (v0.8 usePointDrag) も同列で取り消す (spec 05 D-21 の是正): capture 解放 +
|
|
2035
|
+
// 参照矩形消灯 + 両軸スクローラー停止。シンク断面は復元しない (直後の cancelEdit がセッション
|
|
2036
|
+
// ごと捨てる)。放置すると旧軸座標の rAF / スクローラー tick が新シートへ insertReference し、
|
|
2037
|
+
// capture と合成ドラッグ面 (キー全 consume・clipboard 凍結) は次の pointerup まで残る
|
|
2038
|
+
pointDrag.cancelDrag()
|
|
2039
|
+
// 編集セッションも復元なしで終了 (値破棄 — Excel のシート切替と同じくエディタを持ち
|
|
2040
|
+
// 越さない)。commit しない: 旧 adapter は既に props から消えており、放置すると次の
|
|
2041
|
+
// commit が新シートの同座標へ誤書込する (縮小シートでは範囲外 throw で固着、空シート
|
|
2042
|
+
// では sink ごと消えた宙吊りセッションが rearm を恒久抑止する)
|
|
2043
|
+
editor.cancelEdit()
|
|
2044
|
+
// ポップオーバーとアンカーも畳む (v0.8): 旧シート座標のアンカーを新軸の repaint に持ち越さない
|
|
2045
|
+
// (範囲外 index は Fenwick が fail-fast する) — 以降の破棄 / repaint より前
|
|
2046
|
+
popoversRef.current?.hideTip()
|
|
2047
|
+
popoversRef.current?.close()
|
|
2048
|
+
// hx の新列軸への再クランプは v0.4 からグリッド内部 (総幅と木の恒常同期) が担う —
|
|
2049
|
+
// 明示 reclamp は退役 (狭いシートへの切替でもグリッドが同コミットで自己回復する)
|
|
2050
|
+
if (isEmpty) {
|
|
2051
|
+
// 空アダプタへの切替はラッパーを通さず直接リセット — 0 行グリッドに A1 は存在せず、
|
|
2052
|
+
// 通知 (onSelectionChange) も読み上げ (getCellText) も rearm も流してはならない
|
|
2053
|
+
selectionRef.current = INITIAL_SELECTION
|
|
2054
|
+
setSelectionState(INITIAL_SELECTION)
|
|
2055
|
+
} else {
|
|
2056
|
+
setSelection(INITIAL_SELECTION)
|
|
2057
|
+
}
|
|
2058
|
+
// クリップはシート切替の意味論 (v0.8 §4.2.5): copy は破棄、cut は foreign として保持し
|
|
2059
|
+
// 切替先の paste を跨ぎ move にする (マーキーは foreign で非表示 — 旧シート座標を新軸で
|
|
2060
|
+
// 描かない)。選択リセットの後に置く**防御的順序** (R5(H) 実測注記): 現配線では
|
|
2061
|
+
// repaintOverlayRef が passive effect 代入のため、この layout effect 中の同期 repaint は
|
|
2062
|
+
// 旧クロージャ (旧軸) で走り、旧選択が範囲外になる事故は起こり得ない。ref 代入を layout
|
|
2063
|
+
// effect 化する将来変更でこの順序が実防壁になるため維持する
|
|
2064
|
+
onAdapterSwitchRef.current()
|
|
2065
|
+
}
|
|
2066
|
+
}, [adapter, isEmpty, setSelection, engine, colHeaderMachine, rowHeaderMachine, headerResizeMachine, fillMachine, pointDrag, viewStore, editor.cancelEdit])
|
|
2067
|
+
|
|
2068
|
+
// ---- 凍結列数の変化 (v0.5 ライブ prop) は飛行中ジェスチャを復元なしで取り消す ----
|
|
2069
|
+
// 押下時の grab オフセット / アンカーは旧パーティション座標系で確立済み — 変更後の鮮度
|
|
2070
|
+
// ヒットテスト (新座標系) と混ざると、ユーザーが狙っていないセルへ fill が確定し得る
|
|
2071
|
+
// (R2 実測: F 0 → 6 の途中変更で縦 1 セル fill が凍結列 4 セルへ誤書込)。シート切替シームと
|
|
2072
|
+
// 同じ stale 座標遮断だが、座標系は同一グリッドのため選択と編集セッションは保持する
|
|
2073
|
+
const prevFrozenColsRef = useRef(frozenColumns)
|
|
2074
|
+
const prevFrozenRowsRef = useRef(frozenRows)
|
|
2075
|
+
useLayoutEffect(() => {
|
|
2076
|
+
// 列 / 行いずれの凍結変更も飛行中ドラッグを復元なしで取り消す (R2 是正の縦拡張 —
|
|
2077
|
+
// 押下時アンカーは旧パーティション座標系。混成ヒットで誤 fill 確定を防ぐ)
|
|
2078
|
+
if (prevFrozenColsRef.current !== frozenColumns || prevFrozenRowsRef.current !== frozenRows) {
|
|
2079
|
+
prevFrozenColsRef.current = frozenColumns
|
|
2080
|
+
prevFrozenRowsRef.current = frozenRows
|
|
2081
|
+
engine.cancelDrag()
|
|
2082
|
+
colHeaderMachine.cancelDrag()
|
|
2083
|
+
rowHeaderMachine.cancelDrag()
|
|
2084
|
+
headerResizeMachine.cancelDrag()
|
|
2085
|
+
fillMachine.cancelDrag()
|
|
2086
|
+
// 参照ドラッグも同列 (spec 05 D-21 の是正): 押下時 bodyRect / 原点セルは旧パーティション座標系。
|
|
2087
|
+
// 取消 = capture 解放 + 矩形消灯 + スクローラー停止だけ — セッションと最後のトークンは保つ
|
|
2088
|
+
// (cancelDrag は abortDrag と違いシンク断面を復元しない)
|
|
2089
|
+
pointDrag.cancelDrag()
|
|
2090
|
+
}
|
|
2091
|
+
}, [frozenColumns, frozenRows, engine, colHeaderMachine, rowHeaderMachine, headerResizeMachine, fillMachine, pointDrag])
|
|
2092
|
+
|
|
2093
|
+
// ---- dataVersion (外部書込通知 §6.9-2 (b) — Undo / 協調編集) もクリップの破棄条件 ----
|
|
2094
|
+
// adapter 外で値が書き戻ると cut 時行列のまま照合が通り、古い指紋の move が外部値を "" で
|
|
2095
|
+
// clear する実データ喪失になる (グリッド内 Undo の中継破棄と同じ根拠を外部チャネルへ対称化)
|
|
2096
|
+
const prevDataVersionRef = useRef(dataVersion)
|
|
2097
|
+
useLayoutEffect(() => {
|
|
2098
|
+
if (prevDataVersionRef.current !== dataVersion) {
|
|
2099
|
+
prevDataVersionRef.current = dataVersion
|
|
2100
|
+
discardClip()
|
|
2101
|
+
}
|
|
2102
|
+
}, [dataVersion, discardClip])
|
|
2103
|
+
|
|
2104
|
+
// 確定 SelectionState の変更で overlay を React 経路からも再描画 (§6.7.2-3 (a))
|
|
2105
|
+
useLayoutEffect(() => {
|
|
2106
|
+
repaintOverlay()
|
|
2107
|
+
}, [repaintOverlay, selection])
|
|
2108
|
+
|
|
2109
|
+
// ---- SpreadsheetHandle (§6.9-8) ----
|
|
2110
|
+
/** The command runner behind the handle's write methods (filled at render — null only before the first render completes). / handle の書込メソッドが使うコマンド実行器 (レンダー時充填)。 */
|
|
2111
|
+
const requireCommands = useCallback((): CommandRunner => {
|
|
2112
|
+
const runner = commandsRef.current
|
|
2113
|
+
if (runner === null) {
|
|
2114
|
+
throw new RangeError("[spreadsheet] the command runner is not mounted")
|
|
2115
|
+
}
|
|
2116
|
+
return runner
|
|
2117
|
+
}, [])
|
|
2118
|
+
useImperativeHandle(
|
|
2119
|
+
ref,
|
|
2120
|
+
() => ({
|
|
2121
|
+
resizeRow: (row: number, heightPx: number) => {
|
|
2122
|
+
// 丸めは一度だけ — rowAxis とグリッド内部木へ同一値を渡す (食い違うと高さ照合が巻き戻す)
|
|
2123
|
+
const size = Math.round(heightPx)
|
|
2124
|
+
rowAxis.resize(row, size)
|
|
2125
|
+
handleRef.current?.updateRowSize(row, size)
|
|
2126
|
+
viewStore.bumpRowGeometry()
|
|
2127
|
+
// ウィンドウ原点より上をリサイズした場合、この同期 repaint は旧 windowOriginY 基準
|
|
2128
|
+
// (新原点は rAF → RowHeader 再レンダー → layout effect で 1 フレーム後に確定 —
|
|
2129
|
+
// §6.8-2 の順序契約の設計内猶予。自己修復するため実害なし)
|
|
2130
|
+
repaintHeaders()
|
|
2131
|
+
repaintOverlay()
|
|
2132
|
+
},
|
|
2133
|
+
resizeColumn: (col: number, widthPx: number) => {
|
|
2134
|
+
// 対呼び出し (§3.8): colAxis (SSOT) を先に更新し、グリッドの幅木へ通知する。
|
|
2135
|
+
// 丸めは一度だけ — 両者へ同一値を渡す (食い違うと幅 self-heal が巻き戻す)
|
|
2136
|
+
const size = Math.round(widthPx)
|
|
2137
|
+
colAxis.resize(col, size)
|
|
2138
|
+
handleRef.current?.updateColSize(col, size)
|
|
2139
|
+
// ヘッダー再レンダーは colVersion (購読 2)、セル再配置と hx 再クランプはグリッドの
|
|
2140
|
+
// 幅エポック + 総幅同期が担う。残差 transform は同期 repaint
|
|
2141
|
+
viewStore.bumpColVersion()
|
|
2142
|
+
repaintColHeaders()
|
|
2143
|
+
repaintOverlay()
|
|
2144
|
+
},
|
|
2145
|
+
scrollToCell: (cell: CellCoord, options?: ScrollToCellOptions) => {
|
|
2146
|
+
// エンジンのセル狙いジャンプへ素通し (§7-4 の真実。選択は変えない — 純 viewport 移動)。
|
|
2147
|
+
// 深部 (10^9 px) でも量子化アンカー + 残差 transform で到達する
|
|
2148
|
+
handleRef.current?.scrollToCell(cell.row, cell.col, options)
|
|
2149
|
+
},
|
|
2150
|
+
focus: (options?: { preventScroll?: boolean }) => {
|
|
2151
|
+
// フォーカス回収 + 非編集なら再武装 (編集セッションの値は守る — §1.10)
|
|
2152
|
+
focusSink(options)
|
|
2153
|
+
if (!isEditing()) {
|
|
2154
|
+
rearmSink()
|
|
2155
|
+
}
|
|
2156
|
+
},
|
|
2157
|
+
// コマンド / 書式 / 注釈の書込はコマンド実行器 (useCommands) の同じ経路 (ref 経由 — handle は安定)
|
|
2158
|
+
executeCommand: (command: SpreadsheetCommand) => executeCommandRef.current(command),
|
|
2159
|
+
applyFormat: (patch: CellFormatPatch) => requireCommands().applyFormat(patch),
|
|
2160
|
+
setBorders: (preset: BorderPreset, border?: CellBorder) => requireCommands().setBorders(preset, border),
|
|
2161
|
+
setHyperlink: (url: string | null) => requireCommands().setHyperlink(url),
|
|
2162
|
+
setNote: (text: string | null) => requireCommands().setNote(text),
|
|
2163
|
+
addComment: (input: CellCommentInput) => requireCommands().addComment(input),
|
|
2164
|
+
setValidation: (rule: DataValidationRule | null) => requireCommands().setValidation(rule),
|
|
2165
|
+
paste: (options: PasteOptions) => pasteRef.current(options),
|
|
2166
|
+
getSelection: () => selectionRef.current,
|
|
2167
|
+
// base = アダプタ書式 (能力分岐)、painted = base ⊕ CF (単一シーム resolvePaintedFormat)
|
|
2168
|
+
getBaseFormat: (cell: CellCoord) => (adapter.getCellFormat === undefined ? undefined : adapter.getCellFormat(cell.row, cell.col)),
|
|
2169
|
+
getPaintedFormat: (cell: CellCoord) => (adapter.getCellFormat === undefined ? undefined : paintedFormatOf(cell.row, cell.col, lazyCellText(adapter, cell.row, cell.col))),
|
|
2170
|
+
// 命令的な編集開始は仮想キーボードを招待しない (ポインタ起点の onRequestEdit だけが招待する)
|
|
2171
|
+
startEdit: (mode: "edit" | "replace") => {
|
|
2172
|
+
startEdit(mode)
|
|
2173
|
+
},
|
|
2174
|
+
openPopover: (kind: PopoverKind) => {
|
|
2175
|
+
openPopoverRef.current(kind)
|
|
2176
|
+
},
|
|
2177
|
+
}),
|
|
2178
|
+
[rowAxis, colAxis, viewStore, repaintColHeaders, repaintHeaders, repaintOverlay, focusSink, isEditing, rearmSink, adapter, paintedFormatOf, startEdit, requireCommands],
|
|
2179
|
+
)
|
|
2180
|
+
|
|
2181
|
+
// ---- 空グリッド分岐 (§6.9-1: フックの後・結線の前) ----
|
|
2182
|
+
if (isEmpty) {
|
|
2183
|
+
return <div className={className ? `aqsp-root ${className}` : "aqsp-root"} data-aqsp-empty="" data-testid={testId} />
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
// live region は読み上げ prop のどれか 1 つでも宣言されたときだけ描く (コマンド通知も同じ領域へ)
|
|
2187
|
+
const hasAnnouncement = formatCellAnnouncement !== undefined || formatSelectionAnnouncement !== undefined || formatCommandAnnouncement !== undefined
|
|
2188
|
+
|
|
2189
|
+
return (
|
|
2190
|
+
// biome-ignore lint/a11y/useSemanticElements: fieldset はフォーム意味論であり不適。role="group" は Escape 脱出の着地点に現在地名を与えるための選択 (§6.9-7 / §10-4)
|
|
2191
|
+
// biome-ignore lint/a11y/noNoninteractiveTabindex: tabIndex=0 は WCAG 2.1.2 の脱出着地点 + グリッド再進入口 (§10-4) — 意図的な tab stop
|
|
2192
|
+
<div ref={rootRef} className={className ? `aqsp-root ${className}` : "aqsp-root"} role="group" aria-label={resolvedLabels.rootAriaLabel} tabIndex={0} onFocus={onRootFocus} onBlur={onRootBlur} data-aqsp-root="" data-testid={testId}>
|
|
2193
|
+
{/* ズームラッパー (v0.3): 視覚は transform スケール、内側レイアウトは 100%/z の論理 px —
|
|
2194
|
+
virtualscroll の実測 (ResizeObserver / clientHeight) は論理値のまま。z=1 は無スタイル
|
|
2195
|
+
(v0.2 と byte 同一の DOM 幾何 — 既存全ゲートの不変性) */}
|
|
2196
|
+
<div className="aqsp-zoom" data-aqsp-zoom={zoomValue} onPointerDown={onZoomPointerDown} style={zoomValue === 1 ? undefined : { width: `${100 / zoomValue}%`, height: `${100 / zoomValue}%`, transform: `scale(${zoomValue})` }}>
|
|
2197
|
+
<div
|
|
2198
|
+
className="aqsp-corner"
|
|
2199
|
+
aria-hidden="true"
|
|
2200
|
+
data-aqsp-corner=""
|
|
2201
|
+
onPointerDown={(event) => {
|
|
2202
|
+
// mouse 限定 (§6.4.1 の表ヘッダー「mouse のみ」— エンジン / ヘッダー機械と同一ゲート)
|
|
2203
|
+
if (event.pointerType !== "mouse") {
|
|
2204
|
+
return
|
|
2205
|
+
}
|
|
2206
|
+
// 非主ボタンも既定のフォーカス移動だけは止める (全押下面共通 — R2 是正)
|
|
2207
|
+
event.preventDefault()
|
|
2208
|
+
if (event.button !== 0) {
|
|
2209
|
+
return
|
|
2210
|
+
}
|
|
2211
|
+
// 参照ポインティング中 (v0.8 §4.2.13): コーナー押下は consume + シンクへ焦点 (全選択も確定もしない)
|
|
2212
|
+
if (isPointing()) {
|
|
2213
|
+
focusSink()
|
|
2214
|
+
return
|
|
2215
|
+
}
|
|
2216
|
+
// 編集中の requestCommit ゲート (§6.8-3 — コーナーもフォーカス不動で focusout 不発)
|
|
2217
|
+
if (isEditing() && !requestCommit()) {
|
|
2218
|
+
focusSink()
|
|
2219
|
+
return
|
|
2220
|
+
}
|
|
2221
|
+
focusSink()
|
|
2222
|
+
// コーナーの全セル選択は単一構築 state の setSelection 1 回 (過渡状態なし)。active = A1
|
|
2223
|
+
setSelection({
|
|
2224
|
+
ranges: [makeRange({ row: 0, col: 0 }, { row: adapter.rowCount - 1, col: adapter.colCount - 1 })],
|
|
2225
|
+
anchor: { row: 0, col: 0 },
|
|
2226
|
+
active: { row: 0, col: 0 },
|
|
2227
|
+
})
|
|
2228
|
+
}}
|
|
2229
|
+
/>
|
|
2230
|
+
<ColumnHeader
|
|
2231
|
+
viewStore={viewStore}
|
|
2232
|
+
colAxis={colAxis}
|
|
2233
|
+
frozenCols={frozenColumns}
|
|
2234
|
+
handleRef={handleRef}
|
|
2235
|
+
bandRef={colHeaderRef}
|
|
2236
|
+
colTrackRef={colTrackRef}
|
|
2237
|
+
windowOriginXRef={windowOriginXRef}
|
|
2238
|
+
repaintColHeaders={repaintColHeaders}
|
|
2239
|
+
selectionRanges={selection.ranges}
|
|
2240
|
+
enableResizeUI={enableResizeUI}
|
|
2241
|
+
/>
|
|
2242
|
+
<RowHeader
|
|
2243
|
+
viewStore={viewStore}
|
|
2244
|
+
rowAxis={rowAxis}
|
|
2245
|
+
handleRef={handleRef}
|
|
2246
|
+
bandRef={rowHeaderRef}
|
|
2247
|
+
selectionRanges={selection.ranges}
|
|
2248
|
+
rowTrackRef={rowTrackRef}
|
|
2249
|
+
windowOriginYRef={windowOriginYRef}
|
|
2250
|
+
repaintHeaders={repaintHeaders}
|
|
2251
|
+
enableResizeUI={enableResizeUI}
|
|
2252
|
+
frozenRows={frozenRows}
|
|
2253
|
+
/>
|
|
2254
|
+
<div className="aqsp-body-stack" data-aqsp-body-stack="">
|
|
2255
|
+
<GridBody
|
|
2256
|
+
adapter={adapter}
|
|
2257
|
+
rowAxis={rowAxis}
|
|
2258
|
+
colAxis={colAxis}
|
|
2259
|
+
viewStore={viewStore}
|
|
2260
|
+
handleRef={handleRef}
|
|
2261
|
+
bodyRef={bodyRef}
|
|
2262
|
+
dataVersion={dataVersion}
|
|
2263
|
+
defaultColumnWidth={defaultColumnWidth}
|
|
2264
|
+
frozenColumns={frozenColumns}
|
|
2265
|
+
frozenRows={frozenRows}
|
|
2266
|
+
renderCell={renderCell}
|
|
2267
|
+
onScroll={onScroll}
|
|
2268
|
+
onRangeChange={onRangeChange}
|
|
2269
|
+
focusSink={focusSink}
|
|
2270
|
+
isEditing={isEditing}
|
|
2271
|
+
requestCommit={requestCommit}
|
|
2272
|
+
testId={testId ? `${testId}-vs` : undefined}
|
|
2273
|
+
__onCellRender={__onCellRender}
|
|
2274
|
+
cfIndexRef={cfIndexRef}
|
|
2275
|
+
cfMemoRef={cfMemoRef}
|
|
2276
|
+
isPointing={isPointing}
|
|
2277
|
+
/>
|
|
2278
|
+
<SelectionOverlay
|
|
2279
|
+
rowAxis={rowAxis}
|
|
2280
|
+
colAxis={colAxis}
|
|
2281
|
+
handleRef={handleRef}
|
|
2282
|
+
sinkRef={sinkRef}
|
|
2283
|
+
getSelection={getSelection}
|
|
2284
|
+
editing={editor.editing}
|
|
2285
|
+
virtualKeyboard={virtualKeyboard}
|
|
2286
|
+
repaintRef={repaintOverlayRef}
|
|
2287
|
+
dragFrameRef={dragFrameRef}
|
|
2288
|
+
getClip={getClip}
|
|
2289
|
+
getIsDraggingAny={isDraggingAny}
|
|
2290
|
+
getIsFillDragging={fillMachine.isDragging}
|
|
2291
|
+
fillFrameRef={fillFrameRef}
|
|
2292
|
+
resizeGuideFrameRef={resizeGuideFrameRef}
|
|
2293
|
+
fillHandleNodeRef={fillHandleNodeRef}
|
|
2294
|
+
enableFillHandle={enableFillHandle}
|
|
2295
|
+
frozenCols={frozenColumns}
|
|
2296
|
+
frozenRows={frozenRows}
|
|
2297
|
+
sinkAriaLabel={resolvedLabels.sinkAriaLabel}
|
|
2298
|
+
onSinkMouseDown={onSinkMouseDown}
|
|
2299
|
+
editPaintRef={editPaintRef}
|
|
2300
|
+
highlightRef={highlightRef}
|
|
2301
|
+
highlightColors={overlayHighlightColors}
|
|
2302
|
+
popoverAnchors={popoverAnchorsRef}
|
|
2303
|
+
interactiveAnchorRef={interactiveAnchorRef}
|
|
2304
|
+
tipAnchorRef={tipAnchorRef}
|
|
2305
|
+
/>
|
|
2306
|
+
{/* ポップオーバー層 (ADR-15): overlay の直後の兄弟 (CSS の前提順)。層は導出規則が true のときだけ DOM を持つ */}
|
|
2307
|
+
{popovers.layerEnabled ? <PopoverLayer controller={popovers} labels={resolvedLabels} renderPopover={renderPopover} interactiveAnchorRef={interactiveAnchorRef} tipAnchorRef={tipAnchorRef} /> : null}
|
|
2308
|
+
</div>
|
|
2309
|
+
{hasAnnouncement ? <div ref={liveRegionRef} className="aqsp-live-region" aria-live="polite" data-aqsp-live-region="" /> : null}
|
|
2310
|
+
</div>
|
|
2311
|
+
</div>
|
|
2312
|
+
)
|
|
2313
|
+
})
|