@aiquants/spreadsheet 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +1103 -0
  2. package/LICENSE.md +155 -0
  3. package/README.md +817 -0
  4. package/dist/index.cjs +21 -0
  5. package/dist/index.d.cts +1960 -0
  6. package/dist/index.d.ts +1960 -0
  7. package/dist/index.js +7711 -0
  8. package/dist/styles/spreadsheet.css +2 -0
  9. package/dist/styles/spreadsheet.standalone.css +3 -0
  10. package/package.json +94 -0
  11. package/src/Spreadsheet.tsx +2313 -0
  12. package/src/clipboard/clipPayload.ts +1242 -0
  13. package/src/clipboard/fixtures/excel.fixture.ts +301 -0
  14. package/src/clipboard/fixtures/html.fixture.ts +90 -0
  15. package/src/clipboard/fixtures/sheets.fixture.ts +83 -0
  16. package/src/clipboard/htmlInterop.ts +1694 -0
  17. package/src/clipboard/numberFormatCodec.ts +870 -0
  18. package/src/clipboard/pasteSpecial.ts +608 -0
  19. package/src/clipboard/tiling.ts +370 -0
  20. package/src/clipboard/tsv.ts +186 -0
  21. package/src/clipboard/useClipboard.ts +1154 -0
  22. package/src/commands/selfCalc.ts +279 -0
  23. package/src/commands/useCommands.ts +946 -0
  24. package/src/editor/useCellEditor.ts +708 -0
  25. package/src/grid/CellView.tsx +51 -0
  26. package/src/grid/GridBody.tsx +489 -0
  27. package/src/header/ColumnHeader.tsx +167 -0
  28. package/src/header/RowHeader.tsx +144 -0
  29. package/src/header/columnName.ts +31 -0
  30. package/src/header/resizeGates.ts +146 -0
  31. package/src/header/resizeStrips.ts +79 -0
  32. package/src/header/useHeaderResize.ts +598 -0
  33. package/src/header/useHeaderSelection.ts +454 -0
  34. package/src/index.ts +122 -0
  35. package/src/keyboard/compositionGuard.ts +53 -0
  36. package/src/keyboard/ensureCellVisible.ts +77 -0
  37. package/src/keyboard/shortcuts.ts +718 -0
  38. package/src/keyboard/useGridKeyboard.ts +940 -0
  39. package/src/model/axisModel.ts +186 -0
  40. package/src/model/cellAnnotations.ts +92 -0
  41. package/src/model/cellCoord.ts +114 -0
  42. package/src/model/cellFormat.ts +562 -0
  43. package/src/model/cellPaint.ts +537 -0
  44. package/src/model/conditionalFormat.ts +805 -0
  45. package/src/model/dataAdapter.ts +478 -0
  46. package/src/model/dataValidation.ts +542 -0
  47. package/src/model/formulaRefs.ts +984 -0
  48. package/src/model/frozenPartition.ts +100 -0
  49. package/src/model/labels.ts +228 -0
  50. package/src/model/viewStore.ts +124 -0
  51. package/src/overlay/SelectionOverlay.tsx +1425 -0
  52. package/src/overlay/useFillHandle.ts +432 -0
  53. package/src/popover/CommentPopover.tsx +109 -0
  54. package/src/popover/LinkPopover.tsx +108 -0
  55. package/src/popover/ListPopover.tsx +161 -0
  56. package/src/popover/MessagePopover.tsx +36 -0
  57. package/src/popover/NotePopover.tsx +106 -0
  58. package/src/popover/NoteTip.tsx +26 -0
  59. package/src/popover/PopoverLayer.tsx +210 -0
  60. package/src/popover/placement.ts +138 -0
  61. package/src/popover/usePopovers.ts +642 -0
  62. package/src/selection/selectionState.ts +239 -0
  63. package/src/selection/useAutoScroller.ts +226 -0
  64. package/src/selection/usePointDrag.ts +273 -0
  65. package/src/selection/useSelectionEngine.ts +687 -0
  66. package/src/styles/components.entry.css +10 -0
  67. package/src/styles/spreadsheet.css +1029 -0
  68. package/src/styles/standalone.entry.css +9 -0
@@ -0,0 +1,1425 @@
1
+ /**
2
+ * @fileoverview Viewport-space selection overlay (ADR-4 / §6.7.2). All paint goes through the
3
+ * single repaintOverlay() function (React re-render path and scroll-notification path share it);
4
+ * the drag rect is a resident node written directly by onDragFrame. The sink lives INSIDE the
5
+ * overlay (the only pointer-events: auto element). v0.5 layer structure: every write pass
6
+ * resolves a layer set — one main layer at F = 0, or a frozen layer (clip [0, W_F), identity
7
+ * mapX) plus a scroll layer (clip [W_F, →), mapX = tree − hx − W_F) at F > 0 — each with its
8
+ * own container, resident node set, and fill pool; boundary-crossing rects are clipped per
9
+ * layer (standard frozen-pane technique).
10
+ *
11
+ * ビューポート空間の選択オーバーレイ (ADR-4 / §6.7.2)。塗りは repaintOverlay() 1 本に集約
12
+ * (React 再レンダー経路とスクロール通知経路が共有)。ドラッグ矩形は常設ノードへの直書き。
13
+ * シンクはオーバーレイ内に常駐する (唯一の pointer-events: auto)。v0.5 の層構造: 書込パス毎に
14
+ * 層集合を解決 — F = 0 は主層 1 枚、F > 0 は凍結層 (クリップ [0, W_F)・恒等 mapX) +
15
+ * スクロール層 (クリップ [W_F, →)・mapX = 木 − hx − W_F)。各層が専用コンテナ / 常設ノード束 /
16
+ * 塗りプールを持ち、境界跨ぎ矩形は各層のクリップが切る (標準の凍結ペイン技法)。
17
+ *
18
+ * v0.8 rich cells (plan §2.4 / §4.2.12): every capability below is a DECLARATION — when the root
19
+ * does not pass it the overlay reproduces the v0.7 DOM and style writes exactly.
20
+ * - Highlight bands: per range (tail BAND_RANGE_CAP ranges) five resident nodes — two row
21
+ * segments, two column segments and the intersection — inserted UNDER the fills. Partitioned
22
+ * BY INPUT: a segment's main-axis size is the layer's clamp extent, written only when that
23
+ * extent changes (the `layersFor` heal pass); per frame only its `transform` places the inner
24
+ * edge at `clamp(edge, 0, extent)`, so a range scrolled out of view still yields full-viewport
25
+ * coverage and the scroll path is transform-only by construction. Cross-axis sizes and the
26
+ * intersection node are rewritten only when their numbers change (selection / axis / clamp
27
+ * movement — a JS-side same-value cache, never a DOM read). Colours flow through
28
+ * `--aqsp-band-*`, written by ONE layout effect and removed again when the palette retires.
29
+ * - Edit paint: the sink's `paddingTop` follows `EditPaint.lineBoxPx` / `verticalAlign`.
30
+ * - Popover anchors: two nodes owned by the popover layer are placed on their cells by the same
31
+ * frozen-partition mapping as the fill handle (hidden under a frozen band / off-viewport).
32
+ * - Drag-rect mode "point" (formula-reference drag) paints `data-aqsp-drag-mode="point"`.
33
+ *
34
+ * v0.8 リッチセル (計画 §2.4 / §4.2.12): 以下の能力は全て**宣言** — root が渡さなければ v0.7 の
35
+ * DOM と style 書込を厳密に再現する。
36
+ * - 強調帯: レンジ毎 (末尾 BAND_RANGE_CAP 件) に常設 5 ノード — 行 2 セグメント / 列 2 セグメント /
37
+ * 交点 — を fill の**下**へ挿入。**入力で分割**: セグメントの主軸サイズは層のクランプ幅 / 高さで、
38
+ * その値が変わるとき (`layersFor` の heal パス) だけ書き、毎フレームは `transform` だけで内側の辺を
39
+ * `clamp(edge, 0, extent)` へ置く — 画面外へスクロールしたレンジでも viewport 全幅を覆い、スクロール
40
+ * 経路は構造的に transform のみ。交差軸サイズと交点ノードは数値が変わったときだけ書く (選択 / 軸 /
41
+ * クランプ移動 — JS 側の同値キャッシュ、DOM 読みなし)。色は `--aqsp-band-*` を layout effect が 1 回書き、
42
+ * パレットの退役で外す。
43
+ * - エディタ塗り: シンクの `paddingTop` は `EditPaint.lineBoxPx` / `verticalAlign` に従う。
44
+ * - ポップオーバーアンカー: ポップオーバー層所有の 2 ノードを fill ハンドルと同じ凍結分割写像でセルへ
45
+ * 配置 (凍結帯の下 / viewport 外は非表示)。
46
+ * - ドラッグ矩形モード "point" (参照ドラッグ) は `data-aqsp-drag-mode="point"` を描く。
47
+ */
48
+
49
+ import { useWheelBridge, type VirtualGridHandle } from "@aiquants/virtualscroll"
50
+ import { type ReactNode, type RefObject, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from "react"
51
+ import type { AxisModel } from "../model/axisModel.ts"
52
+ import { isWholeCols, isWholeRows } from "../model/cellAnnotations.ts"
53
+ import { type CellCoord, type CellRange, makeRange } from "../model/cellCoord.ts"
54
+ import { type EditPaint, IDLE_EDIT_PAINT } from "../model/cellPaint.ts"
55
+ import { colToViewportX, frozenHeightOf, frozenWidthOf, rowToViewportY } from "../model/frozenPartition.ts"
56
+ import { SCROLLBAR_WIDTH } from "../model/viewStore.ts"
57
+ import type { PopoverAnchorState } from "../popover/usePopovers.ts"
58
+ import type { SelectionState } from "../selection/selectionState.ts"
59
+
60
+ /** Viewport-space rect (private — distinct from the logical-space LogicalRect). / ビューポート矩形 (非公開)。 */
61
+ type ViewportRect = { top: number; left: number; width: number; height: number }
62
+
63
+ /** Clamp margin: one screen each side. / クランプ余白 (± 1 画面)。 */
64
+ const CLAMP_MARGIN_SCREENS = 1
65
+
66
+ /** Ants-band outset in logical px (kept in sync with the CSS var fallback -3px). / アンツ帯の外側オフセット論理 px (CSS 変数フォールバック -3px と同期)。 */
67
+ const CLIP_MARQUEE_OUTSET = 3
68
+
69
+ /**
70
+ * Progressive per-edge ants outset: extend outward by the available headroom, floored (floor
71
+ * is strictly overshoot-free — round would poke the band up to 0.5px past the clip edge).
72
+ * Exported for the direct fractional-input pin (barrel-private).
73
+ * 漸進端別アウトセット: 余白ぶんだけ外へ (floor — round は最大 0.5px はみ出す)。小数入力の直接
74
+ * ピン用にモジュール export (バレル非公開)。
75
+ */
76
+ export const clipMarqueeOutset = (headroom: number): string => `${-Math.min(CLIP_MARQUEE_OUTSET, Math.max(0, Math.floor(headroom)))}px`
77
+
78
+ /** Row / column / intersection highlight switches (v0.8 §2.4 — default all off = zero band nodes). / 行 / 列 / 交点強調のスイッチ (v0.8 §2.4 — 既定全 off = 帯ノード 0)。 */
79
+ export type HighlightState = { row: boolean; column: boolean; intersection: boolean }
80
+
81
+ /** Band colours + shared opacity, written to `--aqsp-band-*` by ONE layout effect (never per frame). / 帯の色 + 共通不透明度 (`--aqsp-band-*` へ layout effect が 1 回書く — 毎フレームは書かない)。 */
82
+ export type HighlightColors = { row: string; column: string; intersection: string; opacity: number }
83
+
84
+ /** Default band palette (plan §1.9) — the same values the `.aqsp-root` CSS variables fall back to. / 既定の帯パレット (計画 §1.9) — `.aqsp-root` の CSS 変数既定と同値。 */
85
+ export const DEFAULT_HIGHLIGHT_COLORS: HighlightColors = Object.freeze({ row: "#0084FF", column: "#42FF4F", intersection: "#E1FF00", opacity: 0.2 })
86
+
87
+ /** Range cap of the band pool: beyond it only the TAIL ranges carry bands (deviation row). / 帯プールのレンジ上限 (超過は末尾レンジだけ — 逸脱行)。 */
88
+ export const BAND_RANGE_CAP = 16
89
+
90
+ /** Live drag-rect modes: §3.2 add / subtract plus the v0.8 "point" (formula-reference drag). / ドラッグ矩形のモード: §3.2 の add / subtract + v0.8 の "point" (参照ドラッグ)。 */
91
+ export type DragFrameMode = "add" | "subtract" | "point"
92
+
93
+ /** Band kinds (the `data-aqsp-band` vocabulary). / 帯の種別 (`data-aqsp-band` の語彙)。 */
94
+ type BandKind = "row" | "column" | "intersection"
95
+
96
+ /** Segment edges (the `data-aqsp-band-edge` vocabulary of the four segments). / セグメントの辺 (4 セグメントの `data-aqsp-band-edge` 語彙)。 */
97
+ type BandEdge = "left" | "right" | "above" | "below"
98
+
99
+ /**
100
+ * Last-written numbers of one band group — JS-side same-value suppression so a frame writes a
101
+ * size, a display or the cross tint only when it actually changes (no DOM reads on the hot
102
+ * path). `crossTint` is the colour family the cross node currently paints ("intersection" = the
103
+ * CSS default, no inline background).
104
+ * 帯 1 組の直近書込値 — JS 側の同値抑止で、サイズ / display / 交点の色は実際に変わるフレームだけ書く
105
+ * (ホットパスで DOM を読まない)。`crossTint` は交点ノードが現在塗る色族 ("intersection" = CSS 既定、
106
+ * インライン background なし)。
107
+ */
108
+ type BandWriteState = { rowShown: boolean; colShown: boolean; crossShown: boolean; crossTint: BandKind; rowHeight: number; colWidth: number; crossWidth: number; crossHeight: number }
109
+
110
+ /** One range's band group: two row segments, two column segments and the intersection node. / レンジ 1 つの帯組: 行 2 セグメント + 列 2 セグメント + 交点ノード。 */
111
+ export type BandNodes = { rowLeft: HTMLDivElement; rowRight: HTMLDivElement; colAbove: HTMLDivElement; colBelow: HTMLDivElement; cross: HTMLDivElement; last: BandWriteState }
112
+
113
+ /** Geometry rect consumed by the band placer (structural — the private ViewportRect satisfies it). / 帯配置が消費する矩形 (構造型 — 非公開 ViewportRect が満たす)。 */
114
+ type BandRect = { top: number; left: number; width: number; height: number }
115
+
116
+ /** Layer clamp extents consumed by the band placer (structural subset of LayerCtx). / 帯配置が消費する層のクランプ範囲 (LayerCtx の構造的部分集合)。 */
117
+ type BandExtents = { clampWidth: number; clampHeight: number }
118
+
119
+ /** Creates one hidden band node with its drawing traits and DOM hooks. / 描画特性と DOM フックを持つ帯ノード 1 個を非表示で生成。 */
120
+ const makeBandNode = (kind: BandKind, edge: BandEdge | null): HTMLDivElement => {
121
+ const node = document.createElement("div")
122
+ node.className = "aqsp-band"
123
+ node.setAttribute("data-aqsp-band", kind)
124
+ // 4 セグメントだけが辺を名乗る (交点は辺を持たない)
125
+ if (edge !== null) {
126
+ node.setAttribute("data-aqsp-band-edge", edge)
127
+ }
128
+ node.style.display = "none"
129
+ return node
130
+ }
131
+
132
+ /**
133
+ * Creates a band group (5 hidden nodes + an empty write cache). Barrel-private; exported for
134
+ * the synthetic-layer geometry pins.
135
+ * 帯組 (非表示 5 ノード + 空の書込キャッシュ) を生成。バレル非公開 — 合成層の幾何ピン用に export。
136
+ */
137
+ export const createBandNodes = (): BandNodes => ({
138
+ rowLeft: makeBandNode("row", "left"),
139
+ rowRight: makeBandNode("row", "right"),
140
+ colAbove: makeBandNode("column", "above"),
141
+ colBelow: makeBandNode("column", "below"),
142
+ cross: makeBandNode("intersection", null),
143
+ last: { rowShown: false, colShown: false, crossShown: false, crossTint: "intersection", rowHeight: -1, colWidth: -1, crossWidth: -1, crossHeight: -1 },
144
+ })
145
+
146
+ /** The group's nodes in DOM (= paint) order: segments under the intersection. / 帯組のノードを DOM (= 描画) 順に: セグメントの上に交点。 */
147
+ const bandNodeList = (nodes: BandNodes): readonly HTMLDivElement[] => [nodes.rowLeft, nodes.rowRight, nodes.colAbove, nodes.colBelow, nodes.cross]
148
+
149
+ /**
150
+ * Heal pass of the band geometry (§2.4): the MAIN-axis size of every segment is the layer's
151
+ * clamp extent — row segments are `clampWidth` wide, column segments `clampHeight` tall. This is
152
+ * the ONLY writer of those sizes; the per-frame placer never touches them.
153
+ * 帯幾何の heal パス (§2.4): 各セグメントの**主軸**サイズは層のクランプ範囲 — 行セグメントは幅
154
+ * `clampWidth`、列セグメントは高さ `clampHeight`。このサイズの唯一の書き手で、毎フレームの配置は触らない。
155
+ */
156
+ export const healBandGeometry = (nodes: BandNodes, clampWidth: number, clampHeight: number): void => {
157
+ const width = `${clampWidth}px`
158
+ const height = `${clampHeight}px`
159
+ nodes.rowLeft.style.width = width
160
+ nodes.rowRight.style.width = width
161
+ nodes.colAbove.style.height = height
162
+ nodes.colBelow.style.height = height
163
+ }
164
+
165
+ /** Clamps a coordinate into [0, max]. / 座標を [0, max] へクランプ。 */
166
+ const clampTo = (value: number, max: number): number => Math.min(Math.max(value, 0), max)
167
+
168
+ /**
169
+ * The popover anchors' hidden state (`placeAnchor`): a clip that paints nothing and takes no
170
+ * hits while the node stays rendered, so a focused editor inside survives (no browser focus
171
+ * fixup — `display: none` / `visibility: hidden` would blur it).
172
+ * ポップオーバーアンカーの非表示状態 (`placeAnchor`): 何も描かずヒットも取らないがノードは描画ツリーに
173
+ * 残る clip — 中のフォーカス中エディタが生き残る (ブラウザの focus fixup が走らない。`display: none` /
174
+ * `visibility: hidden` はフォーカスを外す)。
175
+ */
176
+ const ANCHOR_HIDDEN_CLIP = "inset(100%)"
177
+
178
+ /** Toggles display on a node list. / ノード列の display を切替。 */
179
+ const setShown = (nodes: readonly HTMLDivElement[], shown: boolean): void => {
180
+ for (const node of nodes) {
181
+ node.style.display = shown ? "block" : "none"
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Per-frame band placement for one range rect in one layer (§2.4, partitioned BY INPUT):
187
+ * - row segments: `transform` only — left segment's right edge at `clamp(rect.left, 0, W)`
188
+ * (so `translate(clamp − W)` with its healed width W), right segment's left edge at
189
+ * `clamp(rect.right, 0, W)`; their height (cross-axis) is written only when it changes;
190
+ * - column segments: the transposed twin with H = clampHeight;
191
+ * - intersection: range-sized (size when changed, `transform` per frame); when EXACTLY ONE
192
+ * family is shown and `highlight.intersection` is off, the node lights up tinted with that
193
+ * family's colour (inline `background` through the family's `--aqsp-band-*` variable) — the
194
+ * sheets-enhancer extension paints one full-width band THROUGH the selection when the other
195
+ * family is off, so the flanking segments alone would break the band colour at the range;
196
+ * with both families on the hole is kept (extension parity);
197
+ * - a whole-axis range (`spansAxis` — every column of a row-header selection, or every row of
198
+ * a column-header selection) omits BOTH segment families and keeps only the intersection: the
199
+ * spanned-axis band would duplicate the selection, and the cross-axis band placed at the
200
+ * range's extent would tint the whole viewport on the other side (the sheets-enhancer
201
+ * extension draws nothing for whole-axis selections); `highlight` switches each family.
202
+ * Display is written only on a change (JS-side cache).
203
+ * レンジ矩形 1 つの層内配置 (毎フレーム、§2.4 の入力分割):
204
+ * - 行セグメント: `transform` のみ — 左セグメントの右辺を `clamp(rect.left, 0, W)` へ (heal 済み幅 W で
205
+ * `translate(clamp − W)`)、右セグメントの左辺を `clamp(rect.right, 0, W)` へ。高さ (交差軸) は変化時だけ。
206
+ * - 列セグメント: H = clampHeight の転置双子。
207
+ * - 交点: レンジサイズ (サイズは変化時、`transform` は毎フレーム)。**単一族のみ**表示かつ intersection
208
+ * off のときはその族の色 (族の `--aqsp-band-*` 変数を参照するインライン `background`) で点灯する —
209
+ * sheets-enhancer 拡張は片族 off なら選択を貫く全幅帯 1 本を描くため、両脇セグメントだけではレンジ
210
+ * 位置で帯色が途切れる。両族表示時は拡張同様に穴のまま。
211
+ * - 全軸スパンのレンジ (`spansAxis` — 行ヘッダー選択の全列 / 列ヘッダー選択の全行) は行帯 / 列帯の**両族**を
212
+ * 省略し交点だけ残す: 同軸の帯は選択の重複、交差軸の帯をレンジ寸法で置くと反対側の viewport 全面が染まる
213
+ * (sheets-enhancer 拡張は全軸選択で何も描かない)。`highlight` が族毎に切替。display は変化時のみ (JS キャッシュ)。
214
+ */
215
+ export const placeBandSegments = (nodes: BandNodes, rect: BandRect, extents: BandExtents, highlight: HighlightState, spansAxis: boolean): void => {
216
+ const last = nodes.last
217
+ // 行帯: 全軸スパンのレンジは両族とも省略 (帯は交点に吸収される — 交差軸の帯は viewport 全面を染めるため)
218
+ const rowShown = highlight.row && !spansAxis
219
+ if (rowShown !== last.rowShown) {
220
+ last.rowShown = rowShown
221
+ setShown([nodes.rowLeft, nodes.rowRight], rowShown)
222
+ }
223
+ if (rowShown) {
224
+ // 交差軸サイズ (行帯の高さ) は変化時だけ
225
+ if (rect.height !== last.rowHeight) {
226
+ last.rowHeight = rect.height
227
+ const height = `${rect.height}px`
228
+ nodes.rowLeft.style.height = height
229
+ nodes.rowRight.style.height = height
230
+ }
231
+ // 主軸は transform のみ — 内側の辺をクランプ範囲へ置く (主軸サイズは heal パスの clampWidth)
232
+ nodes.rowLeft.style.transform = `translate(${clampTo(rect.left, extents.clampWidth) - extents.clampWidth}px, ${rect.top}px)`
233
+ nodes.rowRight.style.transform = `translate(${clampTo(rect.left + rect.width, extents.clampWidth)}px, ${rect.top}px)`
234
+ }
235
+ // 列帯: 行帯と同じ全軸スパン省略
236
+ const colShown = highlight.column && !spansAxis
237
+ if (colShown !== last.colShown) {
238
+ last.colShown = colShown
239
+ setShown([nodes.colAbove, nodes.colBelow], colShown)
240
+ }
241
+ if (colShown) {
242
+ if (rect.width !== last.colWidth) {
243
+ last.colWidth = rect.width
244
+ const width = `${rect.width}px`
245
+ nodes.colAbove.style.width = width
246
+ nodes.colBelow.style.width = width
247
+ }
248
+ nodes.colAbove.style.transform = `translate(${rect.left}px, ${clampTo(rect.top, extents.clampHeight) - extents.clampHeight}px)`
249
+ nodes.colBelow.style.transform = `translate(${rect.left}px, ${clampTo(rect.top + rect.height, extents.clampHeight)}px)`
250
+ }
251
+ // 交点: レンジそのもの (サイズは変化時、transform は毎フレーム)。単一族のみ表示で intersection が
252
+ // off のときもその族の色で点灯する — 拡張は片族 off なら選択を貫く全幅帯 1 本を描くため、両脇
253
+ // セグメントだけではレンジ位置で帯色が途切れる (両族表示時は拡張同様に穴のまま)
254
+ const crossTint: BandKind | null = highlight.intersection ? "intersection" : rowShown !== colShown ? (rowShown ? "row" : "column") : null
255
+ const crossShown = crossTint !== null
256
+ if (crossShown !== last.crossShown) {
257
+ last.crossShown = crossShown
258
+ setShown([nodes.cross], crossShown)
259
+ }
260
+ if (crossTint !== null) {
261
+ // 埋め色は族の CSS 変数を参照するインライン background (パレット交換に追随)、交点表示へ戻るとき解除
262
+ if (crossTint !== last.crossTint) {
263
+ last.crossTint = crossTint
264
+ nodes.cross.style.background = crossTint === "intersection" ? "" : `var(--aqsp-band-${crossTint})`
265
+ }
266
+ if (rect.width !== last.crossWidth || rect.height !== last.crossHeight) {
267
+ last.crossWidth = rect.width
268
+ last.crossHeight = rect.height
269
+ nodes.cross.style.width = `${rect.width}px`
270
+ nodes.cross.style.height = `${rect.height}px`
271
+ }
272
+ nodes.cross.style.transform = `translate(${rect.left}px, ${rect.top}px)`
273
+ }
274
+ }
275
+
276
+ /** Hides a band group (writes only the nodes currently shown). / 帯組を消灯 (表示中のノードだけ書く)。 */
277
+ export const hideBandNodes = (nodes: BandNodes): void => {
278
+ const last = nodes.last
279
+ if (last.rowShown) {
280
+ last.rowShown = false
281
+ setShown([nodes.rowLeft, nodes.rowRight], false)
282
+ }
283
+ if (last.colShown) {
284
+ last.colShown = false
285
+ setShown([nodes.colAbove, nodes.colBelow], false)
286
+ }
287
+ if (last.crossShown) {
288
+ last.crossShown = false
289
+ setShown([nodes.cross], false)
290
+ }
291
+ }
292
+
293
+ /** Detaches a band group from the DOM. / 帯組を DOM から外す。 */
294
+ const removeBandNodes = (nodes: BandNodes): void => {
295
+ for (const node of bandNodeList(nodes)) {
296
+ node.remove()
297
+ }
298
+ }
299
+
300
+ /**
301
+ * Editing sink's top padding from the row height and the edit paint (§2.3): the free space is
302
+ * `rowHeight − 1 (bottom grid line) − lineBoxPx`; middle centres it (the cell's flex centring),
303
+ * top is 0 and bottom pushes the line box to the bottom. Never negative.
304
+ * 行高とエディタ塗りからシンクの上パディング (§2.3): 余白 = `行高 − 1 (下罫) − lineBoxPx`。middle は
305
+ * 中央 (セルの flex 中央)、top は 0、bottom は下詰め。負にはならない。
306
+ */
307
+ export const sinkPaddingTop = (rowHeight: number, paint: EditPaint): number => {
308
+ const free = rowHeight - 1 - paint.lineBoxPx
309
+ if (paint.verticalAlign === "top") {
310
+ return 0
311
+ }
312
+ if (paint.verticalAlign === "bottom") {
313
+ return Math.max(0, free)
314
+ }
315
+ return Math.max(0, free / 2)
316
+ }
317
+
318
+ /** Shared empty range list (no allocation on the all-off path). / 共有の空レンジ列 (全 off 経路で確保しない)。 */
319
+ const NO_RANGES: readonly CellRange[] = Object.freeze([])
320
+
321
+ type SelectionOverlayProps = {
322
+ rowAxis: AxisModel
323
+ colAxis: AxisModel
324
+ handleRef: RefObject<VirtualGridHandle | null>
325
+ sinkRef: RefObject<HTMLTextAreaElement | null>
326
+ /**
327
+ * Imperative read of the confirmed selection (owner: Spreadsheet's selectionRef). A
328
+ * render-synced prop would go stale on the sheet-switch commit: the passive repaint effect
329
+ * runs with NEW axes before the reset re-render lands, and old-selection x new-axes hits
330
+ * the Fenwick out-of-bounds fail-fast inside sizeOf.
331
+ * 確定選択の命令読み (所有は Spreadsheet の selectionRef)。レンダー同期 prop だとシート切替
332
+ * コミットで stale — passive repaint がリセット再レンダーより先に新軸で走り、旧選択 × 新軸が
333
+ * sizeOf の Fenwick 範囲外 fail-fast を踏む。
334
+ */
335
+ getSelection: () => SelectionState
336
+ /** Editing state as a boolean prop (§6.7.2-4 — mind the name clash with the isEditing function seam; !editing is safe as a per-render evaluation). / 編集状態の boolean prop (関数シーム isEditing との同名衝突に注意)。 */
337
+ editing: boolean
338
+ /** Virtual-keyboard invitation (Spreadsheet owns the flag): rendered as the sink's inputmode — "text" while invited, "none" otherwise. / 仮想キーボード招待 (フラグは Spreadsheet が所有) — シンクの inputmode として描画 ("text" = 招待中 / "none")。 */
339
+ virtualKeyboard: boolean
340
+ /** Registration target for repaintOverlay (owner: Spreadsheet — the §6.9-7 repaintOverlayRef indirection). / repaintOverlay の登録先 (所有は Spreadsheet)。 */
341
+ repaintRef: RefObject<(() => void) | null>
342
+ /** Drag-rect writer registration (called via the Spreadsheet onDragFrame relay, which appends the §3.2 mode; v0.8 adds "point" for the formula-reference drag). / ドラッグ矩形 writer の登録先 (Spreadsheet の onDragFrame リレー経由 — mode は §3.2 のリレーが付加。v0.8 は参照ドラッグの "point" を追加)。 */
343
+ dragFrameRef: RefObject<((range: CellRange | null, mode: DragFrameMode) => void) | null>
344
+ /** Armed-clip reader for the marching-ants marquee (copy / cut kind + range; null hides; a `foreign` clip — a cut kept across a sheet switch, v0.8 — is never drawn: its range belongs to the other sheet). / 装填中クリップ (copy / cut の kind + range) の読み — null なら非表示。`foreign` (シート切替を跨いで保持した cut — v0.8) は描かない (レンジは別シートの座標)。 */
345
+ getClip: () => { kind: "copy" | "cut"; range: CellRange; foreign: boolean } | null
346
+ /** Composite drag probe across ALL 5 machines (§8.1 fill-handle visibility). / 全 5 機械合成のドラッグ判定 (§8.1 の可視条件)。 */
347
+ getIsDraggingAny: () => boolean
348
+ /**
349
+ * Fill machine's OWN drag probe (§8.1 — one composite value cannot tell the fill's own
350
+ * drag from other machines': the mandatory press repaint would display:none the captured
351
+ * handle and kill the drag via lostpointercapture).
352
+ * fill 機械自身のドラッグ判定 (§8.1 — 合成値 1 本では fill 自身と他機械を判別できず、
353
+ * 押下直後の義務 repaint が capture 中のハンドルを display:none にしてドラッグを即死させる)。
354
+ */
355
+ getIsFillDragging: () => boolean
356
+ /** Fill-preview writer registration (the fill machine + its autoscrollers write through this). / fill プレビュー writer の登録先。 */
357
+ fillFrameRef: RefObject<((range: CellRange | null) => void) | null>
358
+ /** Resize-guideline writer registration (useHeaderResize writes through this). / リサイズガイドライン writer の登録先。 */
359
+ resizeGuideFrameRef: RefObject<((guide: ResizeGuide | null) => void) | null>
360
+ /** Fill-handle node registration (useFillHandle wires its pointerdown here). / fill ハンドルノードの登録先 (useFillHandle が結線)。 */
361
+ fillHandleNodeRef: RefObject<HTMLDivElement | null>
362
+ /** §11 opt-out: false suppresses the handle node entirely (machine inert too). / §11 の opt-out (false はノードごと抑止)。 */
363
+ enableFillHandle: boolean
364
+ /** Frozen leading columns (v0.5 — drives the horizontal split; freshness reads still use the handle). / 先頭凍結列数 (v0.5 — 横分割の構造判定。鮮度読みはハンドル)。 */
365
+ frozenCols: number
366
+ /** Frozen leading rows (v0.6 — drives the vertical split into 2D quadrants). / 先頭凍結行数 (v0.6 — 縦分割で 2D 象限化)。 */
367
+ frozenRows: number
368
+ sinkAriaLabel: string
369
+ onSinkMouseDown: (event: MouseEvent) => void
370
+ /**
371
+ * v0.8 capability: the editing sink's paint (line box + vertical alignment) owned by the
372
+ * editor. Absent ⇒ the v0.7 13 px centred padding.
373
+ * v0.8 能力: エディタ所有のシンク塗り (行箱 + 垂直揃え)。未指定 ⇒ v0.7 の 13px 中央パディング。
374
+ */
375
+ editPaintRef?: RefObject<EditPaint>
376
+ /**
377
+ * v0.8 capability: highlight switches (root-owned, render-time assigned; the root repaints on
378
+ * change). Absent or all off ⇒ zero band nodes (v0.7 DOM identity).
379
+ * v0.8 能力: 強調スイッチ (root 所有・レンダー時代入、変更時は root が repaint)。未指定 / 全 off ⇒
380
+ * 帯ノード 0 (v0.7 の DOM 恒等)。
381
+ */
382
+ highlightRef?: RefObject<HighlightState>
383
+ /** v0.8 capability: identity-stable band palette written to `--aqsp-band-*` by a layout effect; the variables are removed again when the palette retires (undefined / identity change / unmount). Absent ⇒ no variable writes (CSS defaults apply — the v0.7 style attribute). / v0.8 能力: identity 安定の帯パレット (layout effect が `--aqsp-band-*` へ書き、パレットの退役 (undefined / identity 交代 / unmount) で変数を外す)。未指定 ⇒ 変数書込なし (CSS 既定 = v0.7 の style 属性)。 */
384
+ highlightColors?: HighlightColors
385
+ /** v0.8 capability: popover anchor cells to position (interactive + tip). Absent ⇒ anchors untouched. / v0.8 能力: 配置するポップオーバーアンカーのセル (interactive + tip)。未指定 ⇒ アンカーに触れない。 */
386
+ popoverAnchors?: RefObject<PopoverAnchorState>
387
+ /** Interactive anchor node (owned by the popover layer; note / comment / link / list / message). / interactive アンカーノード (ポップオーバー層所有)。 */
388
+ interactiveAnchorRef?: RefObject<HTMLDivElement | null>
389
+ /** Note-tip anchor node (owned by the popover layer). / note-tip アンカーノード (ポップオーバー層所有)。 */
390
+ tipAnchorRef?: RefObject<HTMLDivElement | null>
391
+ }
392
+
393
+ /** Live resize guideline descriptor (logical axis px). / リサイズガイドラインの記述子 (論理軸 px)。 */
394
+ export type ResizeGuide = { axis: "row" | "col"; position: number }
395
+
396
+ /** Resident node set of one overlay layer (v0.5 layer split; every layer owns a dedicated container). / overlay 1 層ぶんの常設ノード束 (v0.5 の層分割。全層が専用コンテナを持つ)。 */
397
+ type LayerNodes = {
398
+ container: HTMLDivElement | null
399
+ active: HTMLDivElement | null
400
+ dragRect: HTMLDivElement | null
401
+ marquee: HTMLDivElement | null
402
+ fillPreview: HTMLDivElement | null
403
+ resizeGuide: HTMLDivElement | null
404
+ fillPool: HTMLElement[]
405
+ /** Band groups per confirmed range (tail BAND_RANGE_CAP), inserted under the fills. / 確定レンジ毎の帯組 (末尾 BAND_RANGE_CAP 件)、fill の下。 */
406
+ bandPool: BandNodes[]
407
+ /** Band group of the live drag rect (null until a drag needs one). / ライブドラッグ矩形の帯組 (必要になるまで null)。 */
408
+ dragBand: BandNodes | null
409
+ /** Last healed clamp extents of this layer's bands (-1 = never healed). / この層の帯の直近 heal 済みクランプ範囲 (-1 = 未 heal)。 */
410
+ bandClampWidth: number
411
+ bandClampHeight: number
412
+ }
413
+
414
+ /** Inline container geometry of one x-split ("" clears the property). / x 分割 1 本のコンテナインライン形状 ("" はプロパティ解除)。 */
415
+ type XSplitStyle = { left: string; right: string; width: string }
416
+
417
+ /** Inline container geometry of one y-split ("" clears the property). / y 分割 1 本のコンテナインライン形状 ("" はプロパティ解除)。 */
418
+ type YSplitStyle = { top: string; bottom: string; height: string }
419
+
420
+ /** One write-pass layer context: nodes + tree→viewport maps + clamp extents + container inline geometry on both axes (v0.6 2D quadrants). / 1 書込パスの層コンテキスト (ノード束 + 両軸の木→viewport 写像 + クランプ範囲 + コンテナのインライン形状。v0.6 の 2D 象限)。 */
421
+ type LayerCtx = { nodes: LayerNodes; mapX: (treeX: number) => number; clampWidth: number; xStyle: XSplitStyle; mapY: (treeY: number) => number; clampHeight: number; yStyle: YSplitStyle }
422
+
423
+ /** One-axis split segment (kind + tree→viewport map + clamp extent + container inline geometry). / 片軸の分割セグメント (種別 + 木→viewport 写像 + クランプ範囲 + コンテナのインライン形状)。 */
424
+ type AxisSplit<S> = { kind: "m" | "f" | "s"; map: (tree: number) => number; clamp: number; style: S }
425
+
426
+ /** Split-descriptor cache: quadrant contexts keyed on the freeze/viewport geometry (scroll-free — the split maps read the live scroll snapshot). / 分割記述子キャッシュ: 凍結 / viewport 幾何をキーにした象限コンテキスト (スクロール非依存 — 写像は生スクロール快照を読む)。 */
427
+ type SplitCache = { frozenCols: number; frozenWidth: number; frozenRows: number; frozenHeight: number; bodyWidth: number; bodyHeight: number; ctxs: LayerCtx[] }
428
+
429
+ /** Main-band container geometry per axis (no freeze — module constants so a cache rebuild allocates none). / 各軸の主帯コンテナ形状 (非凍結 — キャッシュ再構築でも確保しないモジュール定数)。 */
430
+ const X_MAIN_STYLE: XSplitStyle = { left: "0px", right: "0px", width: "" }
431
+ const Y_MAIN_STYLE: YSplitStyle = { top: "0px", bottom: "0px", height: "" }
432
+
433
+ /** Empty resident-node set. / 空の常設ノード束。 */
434
+ const emptyLayerNodes = (): LayerNodes => ({ container: null, active: null, dragRect: null, marquee: null, fillPreview: null, resizeGuide: null, fillPool: [], bandPool: [], dragBand: null, bandClampWidth: -1, bandClampHeight: -1 })
435
+
436
+ /** All quadrant keys ever rendered (xKind + yKind; m = no freeze on that axis). / 描画され得る全象限キー (xKind + yKind、m = 当該軸非凍結)。 */
437
+ const QUADRANT_KEYS = ["mm", "fm", "sm", "mf", "ms", "ff", "sf", "fs", "ss"] as const
438
+
439
+ /** Fill-handle square size in px (§8.1). / fill ハンドルの一辺 (px)。 */
440
+ const FILL_HANDLE_SIZE = 6
441
+
442
+ /**
443
+ * Renders the overlay chrome (fill / active frame / drag rect / cut marquee / sink).
444
+ * オーバーレイ一式 (確定塗り / アクティブ枠 / ドラッグ矩形 / cut マーキー / シンク) を描画。
445
+ */
446
+ export const SelectionOverlay = ({
447
+ rowAxis,
448
+ colAxis,
449
+ handleRef,
450
+ sinkRef,
451
+ getSelection,
452
+ editing,
453
+ virtualKeyboard,
454
+ repaintRef,
455
+ dragFrameRef,
456
+ getClip,
457
+ getIsDraggingAny,
458
+ getIsFillDragging,
459
+ fillFrameRef,
460
+ resizeGuideFrameRef,
461
+ fillHandleNodeRef,
462
+ enableFillHandle,
463
+ frozenCols,
464
+ frozenRows,
465
+ sinkAriaLabel,
466
+ onSinkMouseDown,
467
+ editPaintRef,
468
+ highlightRef,
469
+ highlightColors,
470
+ popoverAnchors,
471
+ interactiveAnchorRef,
472
+ tipAnchorRef,
473
+ }: SelectionOverlayProps): ReactNode => {
474
+ const overlayRef = useRef<HTMLDivElement | null>(null)
475
+ /** Live fill-preview rect (source ∪ band) for scroll-follow repositioning (§8.2). / 生存中 fill プレビュー矩形 (スクロール追随用)。 */
476
+ const liveFillPreviewRef = useRef<CellRange | null>(null)
477
+ /** Live resize guideline for scroll-follow repositioning (§7.2). / 生存中ガイドライン (スクロール追随用)。 */
478
+ const liveResizeGuideRef = useRef<ResizeGuide | null>(null)
479
+
480
+ // ---- 凍結 2D 象限 (v0.6 計画 §3-4 — v0.5 の 2 層を両軸へ拡張): 各軸を凍結帯 / スクロール帯
481
+ // (または非凍結の主帯) へ分割し、その**デカルト積**の象限ごとに常設ノード一式を複製する。
482
+ // 最大 4 象限 (コーナー / 行凍結×列スクロール / 行スクロール×列凍結 / 両スクロール)。
483
+ // 境界跨ぎ矩形は各象限のクリップが切る (標準の凍結ペイン技法)。全象限が専用コンテナを
484
+ // 持つ一様な生命構造 — JSX 分岐切替で React がコンテナごと退役させ ref detach がプールを
485
+ // 空にする (R1 是正: 孤児プールの幽霊塗り防止)。ノードはキー付きレジストリで索引 ----
486
+ const layerRegistryRef = useRef<Map<string, LayerNodes>>(new Map(QUADRANT_KEYS.map((key) => [key, emptyLayerNodes()])))
487
+ const nodeSetFor = useCallback((key: string): LayerNodes => {
488
+ const existing = layerRegistryRef.current.get(key)
489
+ if (existing !== undefined) {
490
+ return existing
491
+ }
492
+ const created = emptyLayerNodes()
493
+ layerRegistryRef.current.set(key, created)
494
+ return created
495
+ }, [])
496
+ /** Heals a container's inline geometry to fresh values (renderless resize/freeze — §7-4 の器版); fixed-key writes, no per-call pair arrays. / コンテナのインライン形状を鮮度値へヒール (固定キー書込 — 呼び毎のペア配列なし)。 */
497
+ const healContainer = useCallback((node: HTMLDivElement | null, xStyle: XSplitStyle, yStyle: YSplitStyle) => {
498
+ if (node === null) {
499
+ return
500
+ }
501
+ // 固定キー書込 + 同値抑止 (Object.entries のペア配列を毎 repaint 確保しない。書込パス毎の
502
+ // ヒールが毎フレーム setStyle を撃たない)
503
+ const style = node.style
504
+ if (style.left !== xStyle.left) {
505
+ style.left = xStyle.left
506
+ }
507
+ if (style.right !== xStyle.right) {
508
+ style.right = xStyle.right
509
+ }
510
+ if (style.width !== xStyle.width) {
511
+ style.width = xStyle.width
512
+ }
513
+ if (style.top !== yStyle.top) {
514
+ style.top = yStyle.top
515
+ }
516
+ if (style.bottom !== yStyle.bottom) {
517
+ style.bottom = yStyle.bottom
518
+ }
519
+ if (style.height !== yStyle.height) {
520
+ style.height = yStyle.height
521
+ }
522
+ }, [])
523
+ /**
524
+ * Band heal pass (§2.4): when a layer's clamp extents change (viewport / frozen geometry /
525
+ * layer switch) every band segment of that layer receives its main-axis size again. An empty
526
+ * pool (default off) touches nothing — v0.7 identity with zero new style writes.
527
+ * 帯の heal パス (§2.4): 層のクランプ範囲が変わったとき (viewport / 凍結幾何 / 層切替) その層の全帯
528
+ * セグメントへ主軸サイズを書き直す。空プール (既定 off) は何にも触れない — 新規 style 書込 0 の v0.7 恒等。
529
+ */
530
+ const healLayerBands = useCallback((nodes: LayerNodes, clampWidth: number, clampHeight: number) => {
531
+ // 同値抑止 (書込パス毎の heal が毎フレーム size を撃たない)
532
+ if (nodes.bandClampWidth === clampWidth && nodes.bandClampHeight === clampHeight) {
533
+ return
534
+ }
535
+ nodes.bandClampWidth = clampWidth
536
+ nodes.bandClampHeight = clampHeight
537
+ for (const group of nodes.bandPool) {
538
+ healBandGeometry(group, clampWidth, clampHeight)
539
+ }
540
+ if (nodes.dragBand !== null) {
541
+ healBandGeometry(nodes.dragBand, clampWidth, clampHeight)
542
+ }
543
+ }, [])
544
+ /** Live scroll snapshot the cached split maps read (`layersFor` refreshes it on every call). / キャッシュ済み分割写像が読む生スクロール快照 (`layersFor` が毎呼び更新)。 */
545
+ const scrollSnapshotRef = useRef({ x: 0, y: 0 })
546
+ /** Split-descriptor cache — invalidated by freeze / viewport geometry change, never by scroll. / 分割記述子キャッシュ — 凍結 / viewport 幾何の変化で無効化 (スクロールでは無効化しない)。 */
547
+ const splitCacheRef = useRef<SplitCache | null>(null)
548
+ /**
549
+ * Resolves the active quadrant layer contexts for one write pass (fresh W_F/H_F/hx/vy — §7-4).
550
+ * The Cartesian product of the x-split and y-split; each quadrant heals its container geometry.
551
+ * The descriptors are cached per freeze/viewport geometry and the maps read hx/vy through the
552
+ * scroll snapshot, so a steady-state scroll frame allocates nothing here.
553
+ * 1 書込パスぶんの象限コンテキストを解決 (§7-4 の鮮度 W_F / H_F / hx / vy)。x 分割 × y 分割の
554
+ * デカルト積で、各象限は自コンテナの形状をヒールする。記述子は凍結 / viewport 幾何ごとに
555
+ * キャッシュし、写像は hx / vy をスクロール快照経由で読む — 定常スクロールフレームはここで
556
+ * 何も確保しない。
557
+ */
558
+ const layersFor = useCallback(
559
+ (hx: number, vy: number): LayerCtx[] => {
560
+ const handle = handleRef.current
561
+ const frozen = handle?.getFrozenSize() ?? { cols: 0, width: 0, rows: 0, height: 0 }
562
+ const viewport = handle?.getViewportSize()
563
+ const bodyWidth = viewport?.width ?? -1
564
+ const bodyHeight = viewport?.height ?? -1
565
+ // スクロール快照の鮮度書き (キャッシュ済み写像の読み先 — 記述子は hx / vy 非依存)
566
+ const scroll = scrollSnapshotRef.current
567
+ scroll.x = hx
568
+ scroll.y = vy
569
+ let cache = splitCacheRef.current
570
+ if (cache === null || cache.frozenCols !== frozen.cols || cache.frozenWidth !== frozen.width || cache.frozenRows !== frozen.rows || cache.frozenHeight !== frozen.height || cache.bodyWidth !== bodyWidth || cache.bodyHeight !== bodyHeight) {
571
+ // 幾何が変わったときだけ記述子を再構築 (最熱経路の毎 repaint 閉包 / spread / 配列確保を
572
+ // 断つ GC 圧対策)。x 軸分割は非凍結が主帯 1 本、凍結は 凍結帯 [0, W_F) + スクロール帯
573
+ // [W_F, →) — y 軸も同型
574
+ const xSplits: AxisSplit<XSplitStyle>[] =
575
+ frozen.cols <= 0
576
+ ? [{ kind: "m", map: (treeX) => treeX - scroll.x, clamp: bodyWidth, style: X_MAIN_STYLE }]
577
+ : [
578
+ { kind: "f", map: (treeX) => treeX, clamp: frozen.width, style: { left: "0px", width: `${frozen.width}px`, right: "" } },
579
+ { kind: "s", map: (treeX) => treeX - scroll.x - frozen.width, clamp: Math.max(0, bodyWidth - frozen.width), style: { left: `${frozen.width}px`, right: "0px", width: "" } },
580
+ ]
581
+ const ySplits: AxisSplit<YSplitStyle>[] =
582
+ frozen.rows <= 0
583
+ ? [{ kind: "m", map: (treeY) => treeY - scroll.y, clamp: bodyHeight, style: Y_MAIN_STYLE }]
584
+ : [
585
+ { kind: "f", map: (treeY) => treeY, clamp: frozen.height, style: { top: "0px", height: `${frozen.height}px`, bottom: "" } },
586
+ { kind: "s", map: (treeY) => treeY - scroll.y - frozen.height, clamp: Math.max(0, bodyHeight - frozen.height), style: { top: `${frozen.height}px`, bottom: "0px", height: "" } },
587
+ ]
588
+ const ctxs: LayerCtx[] = []
589
+ for (const xs of xSplits) {
590
+ for (const ys of ySplits) {
591
+ ctxs.push({ nodes: nodeSetFor(`${xs.kind}${ys.kind}`), mapX: xs.map, clampWidth: xs.clamp, xStyle: xs.style, mapY: ys.map, clampHeight: ys.clamp, yStyle: ys.style })
592
+ }
593
+ }
594
+ cache = { frozenCols: frozen.cols, frozenWidth: frozen.width, frozenRows: frozen.rows, frozenHeight: frozen.height, bodyWidth, bodyHeight, ctxs }
595
+ splitCacheRef.current = cache
596
+ }
597
+ for (const ctx of cache.ctxs) {
598
+ // ヒールは毎呼び (遅延 attach / 象限切替の新コンテナへも形状が届く — 同値抑止つき)
599
+ healContainer(ctx.nodes.container, ctx.xStyle, ctx.yStyle)
600
+ // 帯セグメントの主軸サイズはコンテナと同じ契機でヒール (§2.4 — 空プールなら無書込)
601
+ healLayerBands(ctx.nodes, ctx.clampWidth, ctx.clampHeight)
602
+ }
603
+ return cache.ctxs
604
+ },
605
+ [handleRef, nodeSetFor, healContainer, healLayerBands],
606
+ )
607
+ /**
608
+ * Stable ref-callback set for one layer (❗ identity-stable via useMemo — a per-render
609
+ * factory would make React detach/re-attach every render, clearing the fill pool while its
610
+ * DOM nodes survive and duplicating fills on the next repaint).
611
+ * 層 1 つぶんの安定 ref コールバック束 (❗ useMemo で identity 安定 — 毎レンダー生成だと
612
+ * React が毎回 detach / attach し、プールだけが空になって DOM ノードが残り、次の repaint で
613
+ * 塗りが二重化する)。
614
+ */
615
+ const makeLayerRefs = useCallback(
616
+ (key: string) => {
617
+ const nodes = () => nodeSetFor(key)
618
+ return {
619
+ container: (el: HTMLDivElement | null) => {
620
+ const n = nodes()
621
+ n.container = el
622
+ if (el === null) {
623
+ n.fillPool.length = 0
624
+ // 帯もコンテナごと退役 (孤児プールの幽霊塗り防止 — fill プールと同型)
625
+ n.bandPool.length = 0
626
+ n.dragBand = null
627
+ n.bandClampWidth = -1
628
+ n.bandClampHeight = -1
629
+ }
630
+ },
631
+ active: (el: HTMLDivElement | null) => {
632
+ nodes().active = el
633
+ },
634
+ dragRect: (el: HTMLDivElement | null) => {
635
+ nodes().dragRect = el
636
+ },
637
+ marquee: (el: HTMLDivElement | null) => {
638
+ nodes().marquee = el
639
+ },
640
+ fillPreview: (el: HTMLDivElement | null) => {
641
+ nodes().fillPreview = el
642
+ },
643
+ resizeGuide: (el: HTMLDivElement | null) => {
644
+ nodes().resizeGuide = el
645
+ },
646
+ }
647
+ },
648
+ [nodeSetFor],
649
+ )
650
+ // 象限キーごとに identity 安定な ref 束を用意 (空 deps — 毎レンダー生成だと React が detach /
651
+ // attach してプールが空になり塗りが二重化する)。JSX は実効 F / R に応じて必要な象限だけ描く
652
+ const quadRefs = useMemo(() => Object.fromEntries(QUADRANT_KEYS.map((key) => [key, makeLayerRefs(key)])) as Record<string, ReturnType<typeof makeLayerRefs>>, [makeLayerRefs])
653
+ // render 面の実効 F / W_F (JSX 構造判定 — 機械の鮮度読みはハンドル §7-4)
654
+ const effectiveFrozenCols = Math.min(Math.max(0, frozenCols), colAxis.count)
655
+ const overlayFrozenWidth = frozenWidthOf(colAxis, effectiveFrozenCols)
656
+ const effectiveFrozenRows = Math.min(Math.max(0, frozenRows), rowAxis.count)
657
+ const overlayFrozenHeight = frozenHeightOf(rowAxis, effectiveFrozenRows)
658
+ // 生存中のドラッグ矩形レンジ (null = 非表示)。writeDragFrame が書き、repaintOverlay がスクロール
659
+ // 追随の再配置に読む — ドラッグ中ポインタ静止のままホイールすると、fill / アクティブ枠だけが
660
+ // onScroll repaint で動き矩形が取り残される desync の対策
661
+ const liveDragRangeRef = useRef<CellRange | null>(null)
662
+ /** Mode of the live drag rect (read by the scroll-follow repaint — bands follow add / subtract only). / ライブドラッグ矩形のモード (スクロール追随 repaint が読む — 帯は add / subtract だけ)。 */
663
+ const liveDragModeRef = useRef<DragFrameMode>("add")
664
+ const editingRef = useRef(editing)
665
+ editingRef.current = editing
666
+ /** Last written editing-attribute state (same-value setAttribute suppression). / 直近書込の編集属性状態 (同値 setAttribute 抑止)。 */
667
+ const lastEditingAttrRef = useRef(false)
668
+
669
+ // シンクは共有 sinkRef と wheel bridge の RefCallback の 2 消費者を持つ唯一の要素 — マージ ref
670
+ const bridgeRef = useWheelBridge(handleRef, { enableBridge: !editing })
671
+ const mergedSinkRef = useCallback(
672
+ (el: HTMLTextAreaElement | null) => {
673
+ sinkRef.current = el
674
+ // bridge の戻り値 (クリーンアップ) をそのまま返す — useWheelBridge の正準形 (React 19)
675
+ return bridgeRef(el)
676
+ },
677
+ [sinkRef, bridgeRef],
678
+ )
679
+
680
+ /** Raw quadrant-local edges of a range (both axes through the layer's maps). / レンジの象限局所の生の辺 (両軸とも層の写像経由)。 */
681
+ const layerEdgesOf = useCallback(
682
+ (range: CellRange, ctx: LayerCtx): { top: number; bottom: number; left: number; right: number } => ({
683
+ top: ctx.mapY(rowAxis.offsetOf(range.startRow)),
684
+ bottom: ctx.mapY(rowAxis.offsetOf(range.endRow) + rowAxis.sizeOf(range.endRow)),
685
+ left: ctx.mapX(colAxis.offsetOf(range.startCol)),
686
+ right: ctx.mapX(colAxis.offsetOf(range.endCol) + colAxis.sizeOf(range.endCol)),
687
+ }),
688
+ [rowAxis, colAxis],
689
+ )
690
+
691
+ /** Logical range to a clamped QUADRANT-local rect (null = fully offscreen for this quadrant). / 論理レンジ → 象限局所の矩形 (null = この象限で完全画面外)。 */
692
+ const rangeToLayerRect = useCallback(
693
+ (range: CellRange, ctx: LayerCtx): ViewportRect | null => {
694
+ // 両軸とも象限のクランプ範囲で判定 (v0.6 — y も x と対称に mapY / clampHeight で写す)
695
+ if (ctx.clampHeight <= 0 || ctx.clampWidth <= 0) {
696
+ return null
697
+ }
698
+ const { top, bottom, left, right } = layerEdgesOf(range, ctx)
699
+ // 可視域 ± 1 画面マージンでクランプ (列 / 行全選択 = 24M px 級も画面サイズの div になる)
700
+ const clampTop = -ctx.clampHeight * CLAMP_MARGIN_SCREENS
701
+ const clampBottom = ctx.clampHeight * (1 + CLAMP_MARGIN_SCREENS)
702
+ const clampLeft = -ctx.clampWidth * CLAMP_MARGIN_SCREENS
703
+ const clampRight = ctx.clampWidth * (1 + CLAMP_MARGIN_SCREENS)
704
+ if (bottom < clampTop || top > clampBottom || right < clampLeft || left > clampRight) {
705
+ return null // 完全に画面外 — DOM を触らない
706
+ }
707
+ const t = Math.max(top, clampTop)
708
+ const l = Math.max(left, clampLeft)
709
+ return { top: t, left: l, width: Math.min(right, clampRight) - l, height: Math.min(bottom, clampBottom) - t }
710
+ },
711
+ [layerEdgesOf],
712
+ )
713
+
714
+ /**
715
+ * Logical range to the band rect of a quadrant (§2.4): every edge is clamped into the ± 1
716
+ * screen margin but a range off-screen on ONE axis still yields a rect — its row band must
717
+ * keep covering the viewport (Excel keeps the row highlight when the selection scrolls out).
718
+ * Null only when the quadrant has no extent.
719
+ * 論理レンジ → 象限の帯矩形 (§2.4): 各辺を ± 1 画面マージンへクランプするが、片軸だけ画面外の
720
+ * レンジも矩形を返す — 行帯は viewport を覆い続けなければならない (Excel は選択が画面外へ出ても行の
721
+ * 強調を保つ)。null は象限に範囲が無いときだけ。
722
+ */
723
+ const rangeToBandRect = useCallback(
724
+ (range: CellRange, ctx: LayerCtx): ViewportRect | null => {
725
+ if (ctx.clampHeight <= 0 || ctx.clampWidth <= 0) {
726
+ return null
727
+ }
728
+ const edges = layerEdgesOf(range, ctx)
729
+ // 各辺を独立にクランプ (完全画面外でも幅 0 / 高さ 0 の矩形として位置は保つ)
730
+ const clampTop = -ctx.clampHeight * CLAMP_MARGIN_SCREENS
731
+ const clampBottom = ctx.clampHeight * (1 + CLAMP_MARGIN_SCREENS)
732
+ const clampLeft = -ctx.clampWidth * CLAMP_MARGIN_SCREENS
733
+ const clampRight = ctx.clampWidth * (1 + CLAMP_MARGIN_SCREENS)
734
+ const t = Math.min(Math.max(edges.top, clampTop), clampBottom)
735
+ const l = Math.min(Math.max(edges.left, clampLeft), clampRight)
736
+ return { top: t, left: l, width: Math.min(Math.max(edges.right, clampLeft), clampRight) - l, height: Math.min(Math.max(edges.bottom, clampTop), clampBottom) - t }
737
+ },
738
+ [layerEdgesOf],
739
+ )
740
+
741
+ /** Active highlight state, or null when the capability is absent / every switch is off. / 有効な強調状態 (能力なし / 全 off は null)。 */
742
+ const activeHighlight = useCallback((): HighlightState | null => {
743
+ // 能力分岐: highlightRef 未指定は v0.7 恒等 (帯なし)
744
+ if (highlightRef === undefined) {
745
+ return null
746
+ }
747
+ const highlight = highlightRef.current
748
+ return highlight.row || highlight.column || highlight.intersection ? highlight : null
749
+ }, [highlightRef])
750
+
751
+ /** Inserts a band group under the layer's fills (before the first fill, else before the active frame). / 帯組を層の fill の下へ挿入 (先頭 fill の手前、無ければアクティブ枠の手前)。 */
752
+ const insertBandGroup = useCallback((container: HTMLDivElement, nodes: LayerNodes, group: BandNodes) => {
753
+ const before = nodes.fillPool.length > 0 ? nodes.fillPool[0] : nodes.active
754
+ for (const node of bandNodeList(group)) {
755
+ container.insertBefore(node, before)
756
+ }
757
+ }, [])
758
+
759
+ /** Places one band group for a range in a layer (hidden when the quadrant has no extent). / 層内のレンジ 1 つに帯組を配置 (象限に範囲が無ければ消灯)。 */
760
+ const placeBandGroup = useCallback(
761
+ (group: BandNodes, range: CellRange, ctx: LayerCtx, highlight: HighlightState) => {
762
+ const rect = rangeToBandRect(range, ctx)
763
+ if (rect === null) {
764
+ hideBandNodes(group)
765
+ return
766
+ }
767
+ // 全列スパン (行ヘッダー選択) も全行スパン (列ヘッダー選択) も両族省略 — 交点だけ残す
768
+ placeBandSegments(group, rect, ctx, highlight, isWholeRows(range, colAxis.count) || isWholeCols(range, rowAxis.count))
769
+ },
770
+ [rangeToBandRect, rowAxis, colAxis],
771
+ )
772
+
773
+ /**
774
+ * Per-layer band pool sync of one repaint (§2.4): the pool shrinks to the band range count
775
+ * (retired groups are removed — all off ⇒ zero band nodes = the v0.7 DOM identity), the
776
+ * drag band is removed when highlighting is off, and each band range gets a group (created
777
+ * with the layer's clamp extents, inserted under the fills) placed for this frame.
778
+ * 1 repaint の層毎帯プール同期 (§2.4): プールは帯レンジ数へ収縮 (退役組は除去 — 全 off ⇒ 帯ノード 0
779
+ * = v0.7 の DOM 恒等)、強調 off ならドラッグ帯も除去、各帯レンジは組 (層のクランプ範囲で生成し fill
780
+ * の下へ挿入) をこのフレームぶん配置する。
781
+ */
782
+ const syncLayerBands = useCallback(
783
+ (ctx: LayerCtx, container: HTMLDivElement, bandRanges: readonly CellRange[], highlight: HighlightState | null) => {
784
+ const pool = ctx.nodes.bandPool
785
+ while (pool.length > bandRanges.length) {
786
+ const retired = pool.pop()
787
+ if (retired !== undefined) {
788
+ removeBandNodes(retired)
789
+ }
790
+ }
791
+ if (highlight === null) {
792
+ if (ctx.nodes.dragBand !== null) {
793
+ removeBandNodes(ctx.nodes.dragBand)
794
+ ctx.nodes.dragBand = null
795
+ }
796
+ return
797
+ }
798
+ for (let i = 0; i < bandRanges.length; i += 1) {
799
+ let group = pool[i]
800
+ if (group === undefined) {
801
+ group = createBandNodes()
802
+ // 新規セグメントは追加時に層のクランプ範囲を受ける (以後は heal パスだけが書く)
803
+ healBandGeometry(group, ctx.clampWidth, ctx.clampHeight)
804
+ insertBandGroup(container, ctx.nodes, group)
805
+ pool.push(group)
806
+ }
807
+ placeBandGroup(group, bandRanges[i], ctx, highlight)
808
+ }
809
+ },
810
+ [insertBandGroup, placeBandGroup],
811
+ )
812
+
813
+ /** Places (or hides) the live drag rect's band group in one layer. / ライブドラッグ矩形の帯組を層 1 つに配置 (または消灯)。 */
814
+ const placeDragBand = useCallback(
815
+ (ctx: LayerCtx, range: CellRange | null, highlight: HighlightState | null) => {
816
+ const container = ctx.nodes.container
817
+ if (container === null) {
818
+ return
819
+ }
820
+ if (range === null || highlight === null) {
821
+ if (ctx.nodes.dragBand !== null) {
822
+ hideBandNodes(ctx.nodes.dragBand)
823
+ }
824
+ return
825
+ }
826
+ let group = ctx.nodes.dragBand
827
+ if (group === null) {
828
+ group = createBandNodes()
829
+ healBandGeometry(group, ctx.clampWidth, ctx.clampHeight)
830
+ insertBandGroup(container, ctx.nodes, group)
831
+ ctx.nodes.dragBand = group
832
+ }
833
+ placeBandGroup(group, range, ctx, highlight)
834
+ },
835
+ [insertBandGroup, placeBandGroup],
836
+ )
837
+
838
+ /** Applies a rect (or hides on null) to one resident node. / 常設ノード 1 個への矩形適用 (null は消灯)。 */
839
+ const applyRect = useCallback((node: HTMLDivElement | null, rect: ViewportRect | null) => {
840
+ if (node === null) {
841
+ return
842
+ }
843
+ if (rect === null) {
844
+ node.style.display = "none"
845
+ return
846
+ }
847
+ node.style.display = "block"
848
+ node.style.transform = `translate(${rect.left}px, ${rect.top}px)`
849
+ node.style.width = `${rect.width}px`
850
+ node.style.height = `${rect.height}px`
851
+ }, [])
852
+
853
+ /** Positions the resident drag-rect nodes (all quadrants) for a range at (vy, hx); add / subtract drags also carry their bands. / 常設矩形ノード (全象限) の配置。add / subtract のドラッグは帯も伴う。 */
854
+ const positionDragRect = useCallback(
855
+ (range: CellRange, vy: number, hx: number, mode: DragFrameMode) => {
856
+ // 参照ドラッグ (point) は選択でないため帯を描かない
857
+ const highlight = mode === "point" ? null : activeHighlight()
858
+ for (const ctx of layersFor(hx, vy)) {
859
+ applyRect(ctx.nodes.dragRect, rangeToLayerRect(range, ctx))
860
+ placeDragBand(ctx, range, highlight)
861
+ }
862
+ },
863
+ [rangeToLayerRect, layersFor, applyRect, activeHighlight, placeDragBand],
864
+ )
865
+
866
+ /** Positions the fill-preview resident nodes (all quadrants). / fill プレビュー常設ノード (全象限) の配置。 */
867
+ const positionFillPreview = useCallback(
868
+ (range: CellRange | null, vy: number, hx: number) => {
869
+ for (const ctx of layersFor(hx, vy)) {
870
+ applyRect(ctx.nodes.fillPreview, range === null ? null : rangeToLayerRect(range, ctx))
871
+ }
872
+ },
873
+ [rangeToLayerRect, layersFor, applyRect],
874
+ )
875
+
876
+ /** Positions the resize guideline nodes (all quadrants — the line is a tree boundary each quadrant maps/clips). / ガイドライン (全象限 — 線は各象限が写像 / クリップする木境界)。 */
877
+ const positionResizeGuide = useCallback(
878
+ (guide: ResizeGuide | null, vy: number, hx: number) => {
879
+ for (const ctx of layersFor(hx, vy)) {
880
+ const node = ctx.nodes.resizeGuide
881
+ if (node === null) {
882
+ continue
883
+ }
884
+ if (guide === null) {
885
+ node.style.display = "none"
886
+ continue
887
+ }
888
+ node.style.display = "block"
889
+ if (guide.axis === "col") {
890
+ node.style.transform = `translate(${ctx.mapX(guide.position)}px, 0px)`
891
+ node.style.width = "1px"
892
+ node.style.height = "100%"
893
+ } else {
894
+ // 行線は各象限の mapY で写す (v0.6 — 凍結行帯では静的、スクロール帯では −vy−H_F)
895
+ node.style.transform = `translate(0px, ${ctx.mapY(guide.position)}px)`
896
+ node.style.width = "100%"
897
+ node.style.height = "1px"
898
+ }
899
+ }
900
+ },
901
+ [layersFor],
902
+ )
903
+
904
+ /**
905
+ * Places one popover anchor node on its cell with the fill handle's frozen-partition mapping
906
+ * (§2.5): hidden without a cell, when the cell's origin lies beyond the viewport's right /
907
+ * bottom edge, or when a scroll-axis cell's origin sits under the frozen band — with no
908
+ * frozen tracks the band is the axis origin, so a cell scrolled past the left / top edge is
909
+ * hidden too (the sink's visibility rule); otherwise `transform` per frame and the cell size
910
+ * only when it changes (dataset signature). "Hidden" is `ANCHOR_HIDDEN_CLIP` (`clip-path`),
911
+ * never `display: none` / `visibility: hidden`: the anchor may hold a mounted, FOCUSED editor
912
+ * (the note textarea) while it hides, and un-rendering it would trigger the browser's focus
913
+ * fixup — the editor's focus-leave rule would then save-and-close, whereas plan §5.7-13 is
914
+ * hide-and-restore. A clipped anchor paints nothing and takes no hits (clip-path clips hit
915
+ * testing too), so it vanishes under the frozen band exactly as `display: none` did, and
916
+ * clearing the same property on the next visible frame brings it back with its editor,
917
+ * draft and focus intact. A closed anchor is additionally `inert` (PopoverLayer), so the
918
+ * closed-layer DOM is unchanged by this rule.
919
+ * ポップオーバーアンカー 1 ノードを fill ハンドルと同じ凍結分割写像でセルへ配置 (§2.5): セルなし /
920
+ * セル原点が viewport の右 / 下端の外 / スクロール軸のセル原点が凍結帯の下 (凍結なしなら帯 = 軸原点
921
+ * なので左 / 上へ出たセルも同じ) は非表示 (シンクの可視規則と同型)。それ以外は `transform` を
922
+ * 毎フレーム、セル寸法は変化時だけ (dataset 署名)。「非表示」は `ANCHOR_HIDDEN_CLIP` (`clip-path`)
923
+ * であり `display: none` / `visibility: hidden` ではない: アンカーはマウント済みで**フォーカス中**の
924
+ * エディタ (note の textarea) を抱えたまま隠れ得るので、描画から外すとブラウザの focus fixup が走り
925
+ * エディタの focus-leave 規則が保存して閉じてしまう (計画 §5.7-13 は隠して戻す)。clip した
926
+ * アンカーは何も描かずヒットも取らず (clip-path はヒットテストも切る) `display: none` と同じく
927
+ * 凍結帯の下へ消え、次の可視フレームで同じプロパティを空へ戻せばエディタ・下書き・フォーカスを
928
+ * 保ったまま戻る。閉じたアンカーは加えて `inert` (PopoverLayer) なので閉じた層の DOM は不変。
929
+ */
930
+ const placeAnchor = useCallback(
931
+ (node: HTMLDivElement | null, cell: CellCoord | null, vy: number, hx: number, frozen: { cols: number; width: number; rows: number; height: number }, bodyWidth: number, viewportH: number) => {
932
+ if (node === null) {
933
+ return
934
+ }
935
+ if (cell === null || viewportH <= 0 || bodyWidth <= 0) {
936
+ node.style.clipPath = ANCHOR_HIDDEN_CLIP
937
+ return
938
+ }
939
+ const rawTop = rowToViewportY(rowAxis, cell.row, vy, frozen.rows)
940
+ const rawLeft = colToViewportX(colAxis, cell.col, hx, frozen.cols)
941
+ // 原点が viewport の右 / 下の外、またはスクロール軸のセルが凍結帯の下 (凍結なしは左 / 上の外) ⇒ 非表示
942
+ const beyondViewport = rawLeft >= bodyWidth || rawTop >= viewportH
943
+ const underFrozenCol = cell.col >= frozen.cols && rawLeft < frozen.width
944
+ const underFrozenRow = cell.row >= frozen.rows && rawTop < frozen.height
945
+ if (beyondViewport || underFrozenCol || underFrozenRow) {
946
+ node.style.clipPath = ANCHOR_HIDDEN_CLIP
947
+ return
948
+ }
949
+ // 可視: clip を外す (display / visibility には触れない — フォーカス中のエディタを壊さない)
950
+ node.style.clipPath = ""
951
+ node.style.transform = `translate(${rawLeft}px, ${rawTop}px)`
952
+ // 寸法は変化時だけ (署名比較 — 毎フレームは transform のみ)
953
+ const width = colAxis.sizeOf(cell.col)
954
+ const height = rowAxis.sizeOf(cell.row)
955
+ const signature = `${width} ${height}`
956
+ if (node.dataset.aqspAnchorSig !== signature) {
957
+ node.dataset.aqspAnchorSig = signature
958
+ node.style.width = `${width}px`
959
+ node.style.height = `${height}px`
960
+ }
961
+ },
962
+ [rowAxis, colAxis],
963
+ )
964
+
965
+ /**
966
+ * Places both popover anchors for one frame (ADR-15) — a capability step: without
967
+ * `popoverAnchors` no anchor node is touched (the v0.7 identity); an absent anchor ref is
968
+ * skipped by `placeAnchor`.
969
+ * ポップオーバーアンカー 2 つを 1 フレームぶん配置 (ADR-15) — 能力ステップ: `popoverAnchors` 未指定
970
+ * ならアンカーノードに一切触れない (v0.7 恒等)、アンカー ref の欠落は `placeAnchor` が飛ばす。
971
+ */
972
+ const placePopoverAnchors = useCallback(
973
+ (handle: VirtualGridHandle, vy: number, hx: number) => {
974
+ if (popoverAnchors === undefined) {
975
+ return
976
+ }
977
+ const anchors = popoverAnchors.current
978
+ const { width: bodyWidth, height: viewportH } = handle.getViewportSize()
979
+ const frozen = handle.getFrozenSize()
980
+ placeAnchor(interactiveAnchorRef === undefined ? null : interactiveAnchorRef.current, anchors.interactive, vy, hx, frozen, bodyWidth, viewportH)
981
+ placeAnchor(tipAnchorRef === undefined ? null : tipAnchorRef.current, anchors.tip, vy, hx, frozen, bodyWidth, viewportH)
982
+ },
983
+ [popoverAnchors, interactiveAnchorRef, tipAnchorRef, placeAnchor],
984
+ )
985
+
986
+ /**
987
+ * The single repaint function (both paths — §6.7.2-3). No getBoundingClientRect inside:
988
+ * viewport metrics come from cached numbers only.
989
+ * 唯一の再描画関数 (両経路共有)。内部で gBCR を呼ばない (キャッシュ数値のみ)。
990
+ */
991
+ const repaintOverlay = useCallback(() => {
992
+ const overlay = overlayRef.current
993
+ const handle = handleRef.current
994
+ if (overlay === null || handle === null) {
995
+ return
996
+ }
997
+ // 両軸ともハンドルの同期読み (§7-4 — ストアに位置は存在しない)
998
+ const position = handle.getScrollPosition()
999
+ const vy = Math.max(0, position.y)
1000
+ const hx = Math.max(0, position.x)
1001
+ // 命令読み (props docstring 参照) — シート切替コミットでも常にリセット済みの最新確定選択
1002
+ const state = getSelection()
1003
+
1004
+ // 強調帯の対象レンジ (能力なし / 全 off は空 — 確保もしない)
1005
+ const highlight = activeHighlight()
1006
+ const bandRanges = highlight === null ? NO_RANGES : state.ranges.slice(-BAND_RANGE_CAP)
1007
+
1008
+ // 確定塗り (fills) — 層ごとの常設プール (ref 配列) を使い回す (毎 repaint の DOM 生成 /
1009
+ // クエリを避ける)。F = 0 は主プール単層で従来恒等
1010
+ const layers = layersFor(hx, vy)
1011
+ for (const ctx of layers) {
1012
+ const container = ctx.nodes.container
1013
+ if (container === null) {
1014
+ continue
1015
+ }
1016
+ const fills = ctx.nodes.fillPool
1017
+ for (let i = 0; i < Math.max(fills.length, state.ranges.length); i += 1) {
1018
+ let node = fills[i]
1019
+ if (node === undefined) {
1020
+ node = document.createElement("div")
1021
+ node.className = "aqsp-selection-fill"
1022
+ node.setAttribute("data-aqsp-fill", "")
1023
+ // 静的ノード群 (アクティブ枠 / ドラッグ矩形 / マーキー / シンク) の手前へ挿入 —
1024
+ // append だと DOM 順でシンクより上に描かれ、Phase 3 のセル面積エディタが半透明
1025
+ // fill の下に沈む
1026
+ container.insertBefore(node, ctx.nodes.active)
1027
+ fills.push(node)
1028
+ }
1029
+ const range = state.ranges[i]
1030
+ const rect = range === undefined ? null : rangeToLayerRect(range, ctx)
1031
+ if (rect === null) {
1032
+ node.style.display = "none"
1033
+ } else {
1034
+ node.style.display = "block"
1035
+ node.style.transform = `translate(${rect.left}px, ${rect.top}px)`
1036
+ node.style.width = `${rect.width}px`
1037
+ node.style.height = `${rect.height}px`
1038
+ }
1039
+ }
1040
+ // プールを生存レンジ数へ収縮 (多レンジ選択の残骸ノードを毎 repaint 走査し続けない —
1041
+ // 最熱ループは常に live 選択規模に比例させる)
1042
+ while (fills.length > state.ranges.length) {
1043
+ fills.pop()?.remove()
1044
+ }
1045
+
1046
+ // アクティブセル枠 (常設ノードは ref 直読み — dragRect と同じ規律)
1047
+ applyRect(ctx.nodes.active, rangeToLayerRect(makeRange(state.active, state.active), ctx))
1048
+
1049
+ // 強調帯 (v0.8 §2.4): 末尾 BAND_RANGE_CAP レンジに 5 ノードずつ、fill の下。全 off は
1050
+ // プール縮退 (ノード除去) で帯 0 = v0.7 恒等
1051
+ syncLayerBands(ctx, container, bandRanges, highlight)
1052
+ }
1053
+
1054
+ // クリップマーキー (copy / cut 装填中のみ — v0.3.2 でマーチングアンツ統一)。層ごとに配置
1055
+ // (署名 / kind 抑止は層ノード単位 — dataset に直近値を持つ)。foreign (旧シートの cut) は
1056
+ // 描かない — レンジは切替前の座標で、新軸では意味を持たない (v0.8 §4.2.5)
1057
+ const armed = getClip()
1058
+ const clip = armed === null || armed.foreign ? null : armed
1059
+ for (const ctx of layers) {
1060
+ const marquee = ctx.nodes.marquee
1061
+ if (marquee === null) {
1062
+ continue
1063
+ }
1064
+ const rect = clip === null ? null : rangeToLayerRect(clip.range, ctx)
1065
+ if (rect === null) {
1066
+ marquee.style.display = "none"
1067
+ } else {
1068
+ marquee.style.display = "block"
1069
+ marquee.style.transform = `translate(${rect.left}px, ${rect.top}px)`
1070
+ marquee.style.width = `${rect.width}px`
1071
+ marquee.style.height = `${rect.height}px`
1072
+ // 端クランプ (R1(H) blocking → R2(H) で漸進化): 外側 3px のアンツ帯は層の
1073
+ // overflow: hidden に切られるため、各辺の余白ぶんだけ外へ出す (-min(3, max(0,
1074
+ // 余白))px)。二値クランプだと閾値跨ぎで帯が 3px 跳ね、境界 3px 圏の静止位置でも
1075
+ // 同色重なりが再発する — 漸進なら連続で、原点 (A1) は境界上に載る (Excel 同型)。
1076
+ // 整数 floor で文字列が安定し、上・左は絶対にはみ出さない。横の余白基準は層の
1077
+ // クランプ幅 (v0.5 — F = 0 は bodyWidth で従来恒等)。署名比較が同値 setProperty を抑止
1078
+ // 上下 / 左右とも象限のクランプ範囲基準 (v0.6 — outBottom を viewportH でなく
1079
+ // ctx.clampHeight へ。凍結行帯のマーキー帯が帯下限で正しく切られる。横は従来どおり)
1080
+ const outTop = clipMarqueeOutset(rect.top)
1081
+ const outLeft = clipMarqueeOutset(rect.left)
1082
+ const outBottom = clipMarqueeOutset(ctx.clampHeight - (rect.top + rect.height))
1083
+ const outRight = clipMarqueeOutset(ctx.clampWidth - (rect.left + rect.width))
1084
+ const signature = `${outTop} ${outRight} ${outBottom} ${outLeft}`
1085
+ if (signature !== marquee.dataset.aqspOutsetSig) {
1086
+ marquee.dataset.aqspOutsetSig = signature
1087
+ marquee.style.setProperty("--aqsp-clip-outset-top", outTop)
1088
+ marquee.style.setProperty("--aqsp-clip-outset-right", outRight)
1089
+ marquee.style.setProperty("--aqsp-clip-outset-bottom", outBottom)
1090
+ marquee.style.setProperty("--aqsp-clip-outset-left", outLeft)
1091
+ }
1092
+ }
1093
+ // kind 属性はスクロール毎フレームの repaint で書かれる — 同値 setAttribute 抑止
1094
+ // (v0.1 の同値再書込規律。テスト / スタイル分岐のフックで、見た目は両種同一の Excel 準拠)
1095
+ const kind = rect === null ? null : (clip?.kind ?? null)
1096
+ const lastKind = marquee.dataset.aqspClipKindLast ?? null
1097
+ if (kind !== lastKind) {
1098
+ if (kind === null) {
1099
+ delete marquee.dataset.aqspClipKindLast
1100
+ marquee.removeAttribute("data-aqsp-clip-kind")
1101
+ } else {
1102
+ marquee.dataset.aqspClipKindLast = kind
1103
+ marquee.setAttribute("data-aqsp-clip-kind", kind)
1104
+ }
1105
+ }
1106
+ }
1107
+
1108
+ // 生存中ドラッグ矩形のスクロール追随: ポインタ静止のままホイールすると fill / 枠だけが
1109
+ // 動いて矩形が取り残される — writer は writeDragFrame と共有の positionDragRect 1 本
1110
+ if (liveDragRangeRef.current !== null) {
1111
+ positionDragRect(liveDragRangeRef.current, vy, hx, liveDragModeRef.current)
1112
+ }
1113
+
1114
+ // リサイズガイドライン / fill プレビューのスクロール追随 (§7.2 / §8.2 — writer と共有の
1115
+ // positioner 1 本)
1116
+ if (liveResizeGuideRef.current !== null) {
1117
+ positionResizeGuide(liveResizeGuideRef.current, vy, hx)
1118
+ }
1119
+ if (liveFillPreviewRef.current !== null) {
1120
+ positionFillPreview(liveFillPreviewRef.current, vy, hx)
1121
+ }
1122
+
1123
+ // fill ハンドル (§8.1)。飛行中は可視条件の適用を凍結する — エッジスクロールで隅が画面外へ
1124
+ // 出た瞬間に capture ノードが display:none になり lostpointercapture で正当なドラッグが
1125
+ // 死ぬ事故の遮断
1126
+ const fillHandle = fillHandleNodeRef.current
1127
+ if (fillHandle !== null && !getIsFillDragging()) {
1128
+ const { width: bodyWidth, height: viewportH } = handle.getViewportSize()
1129
+ const tail = state.ranges[state.ranges.length - 1]
1130
+ // 単一レンジ + 非編集 + ドラッグ非飛行 (§8.1)。fill 自身の飛行はこのブロックの外側の
1131
+ // 凍結ゲートが既に除外している — ここに来た時点で isFillDragging は常に false のため
1132
+ // 合成値の素読みで足りる
1133
+ const eligible = state.ranges.length === 1 && !editingRef.current && !getIsDraggingAny()
1134
+ if (!eligible || viewportH <= 0 || bodyWidth <= 0) {
1135
+ fillHandle.style.display = "none"
1136
+ } else {
1137
+ // 行 / 列とも凍結パーティション順写像 (v0.6 / v0.5 計画 §2)。スクロール軸の隅が
1138
+ // 凍結帯の下へ潜った場合は不可視 (minLeft = W_F / minTop = H_F — 凍結軸自身の隅は
1139
+ // 0 が下限)
1140
+ const frozen = handle.getFrozenSize()
1141
+ const endColIsFrozen = tail.endCol < frozen.cols
1142
+ const endRowIsFrozen = tail.endRow < frozen.rows
1143
+ const left = colToViewportX(colAxis, tail.endCol, hx, frozen.cols) + colAxis.sizeOf(tail.endCol)
1144
+ const top = rowToViewportY(rowAxis, tail.endRow, vy, frozen.rows) + rowAxis.sizeOf(tail.endRow)
1145
+ const minLeft = endColIsFrozen ? 0 : frozen.width
1146
+ const minTop = endRowIsFrozen ? 0 : frozen.height
1147
+ if (top <= minTop || top > viewportH || left <= minLeft || left > bodyWidth) {
1148
+ fillHandle.style.display = "none" // 右下隅が viewport 外 / 凍結帯の下 (§8.1)
1149
+ } else {
1150
+ fillHandle.style.display = "block"
1151
+ fillHandle.style.transform = `translate(${left - FILL_HANDLE_SIZE + 1}px, ${top - FILL_HANDLE_SIZE + 1}px)`
1152
+ }
1153
+ }
1154
+ }
1155
+
1156
+ // シンク位置: active セルへ追随、画面外ならビューポート内縁へクランプ (display:none 禁止 —
1157
+ // フォーカスと IME が死ぬ)。編集中はセル面積、idle は 1px (§6.7.2-4)
1158
+ const sink = sinkRef.current
1159
+ if (sink !== null) {
1160
+ const a = state.active
1161
+ const { width: bodyWidth, height: viewportH } = handle.getViewportSize()
1162
+ // 行 / 列とも凍結パーティション順写像 (v0.6 / v0.5 計画 §2 — 凍結軸の active は静的座標)
1163
+ const sinkFrozen = handle.getFrozenSize()
1164
+ const rawTop = rowToViewportY(rowAxis, a.row, vy, sinkFrozen.rows)
1165
+ const rawLeft = colToViewportX(colAxis, a.col, hx, sinkFrozen.cols)
1166
+ const top = viewportH > 0 ? Math.min(Math.max(rawTop, 0), Math.max(0, viewportH - 1)) : 0
1167
+ const left = bodyWidth > 0 ? Math.min(Math.max(rawLeft, 0), Math.max(0, bodyWidth - 1)) : 0
1168
+ sink.style.top = `${top}px`
1169
+ sink.style.left = `${left}px`
1170
+ if (editingRef.current) {
1171
+ sink.style.width = `${colAxis.sizeOf(a.col)}px`
1172
+ sink.style.height = `${rowAxis.sizeOf(a.row)}px`
1173
+ // セル連続の縦位置: セル本文は flex 中央 = (行高 − 下罫 1 − 行箱) / 2。固定値は既定
1174
+ // 24px でしか一致しない (R1(E) 実証 — defaultRowHeight / 行リサイズで段差)。行箱と
1175
+ // 垂直揃えはエディタ塗り (v0.8 §2.3 — 能力なしは IDLE = 13px 中央で v0.7 恒等)
1176
+ sink.style.paddingTop = `${sinkPaddingTop(rowAxis.sizeOf(a.row), editPaintRef === undefined ? IDLE_EDIT_PAINT : editPaintRef.current)}px`
1177
+ } else {
1178
+ sink.style.width = "1px"
1179
+ sink.style.height = "1px"
1180
+ sink.style.paddingTop = "0px"
1181
+ }
1182
+ // 編集セッションの可視化属性 (§6.7.2-4 補): 編集中だけシンクを可視エディタ化する
1183
+ // (常時 opacity 0 では本文もキャレットも見えない — v0.3 要望改善)。**クランプ中は
1184
+ // 可視化しない** — active がビューポート外へスクロールした間、内縁固定の等身大
1185
+ // エディタが無関係なセルの上に描かれる (R1(E) 実証。Excel はセルと一緒に画面外へ)。
1186
+ // セッションと IME は生存し、スクロールバックで再可視化される。repaint は高頻度の
1187
+ // ため同値 setAttribute は抑止する (v0.1 の同値再書込規律)
1188
+ // スクロール列の active が凍結帯の下へ潜った場合もエディタは降ろす (誤位置描画の
1189
+ // 遮断 — 「画面外スクロールで降ろす」既存規律の凍結境界版)
1190
+ // スクロール軸の active が凍結帯の下へ潜った場合もエディタを降ろす (両軸)
1191
+ const underFrozenCol = a.col >= sinkFrozen.cols && rawLeft < sinkFrozen.width
1192
+ const underFrozenRow = a.row >= sinkFrozen.rows && rawTop < sinkFrozen.height
1193
+ const editorVisible = editingRef.current && rawTop === top && rawLeft === left && !underFrozenCol && !underFrozenRow
1194
+ if (editorVisible !== lastEditingAttrRef.current) {
1195
+ lastEditingAttrRef.current = editorVisible
1196
+ if (editorVisible) {
1197
+ sink.setAttribute("data-aqsp-editing", "")
1198
+ } else {
1199
+ sink.removeAttribute("data-aqsp-editing")
1200
+ }
1201
+ }
1202
+ }
1203
+
1204
+ // ポップオーバーアンカー (v0.8 ADR-15): 能力があるときだけ 2 ノードをセルへ配置する
1205
+ placePopoverAnchors(handle, vy, hx)
1206
+ }, [
1207
+ handleRef,
1208
+ rangeToLayerRect,
1209
+ layersFor,
1210
+ applyRect,
1211
+ positionDragRect,
1212
+ positionResizeGuide,
1213
+ positionFillPreview,
1214
+ getClip,
1215
+ getSelection,
1216
+ getIsDraggingAny,
1217
+ getIsFillDragging,
1218
+ fillHandleNodeRef,
1219
+ sinkRef,
1220
+ rowAxis,
1221
+ colAxis,
1222
+ activeHighlight,
1223
+ syncLayerBands,
1224
+ editPaintRef,
1225
+ placePopoverAnchors,
1226
+ ])
1227
+
1228
+ /** Drag-rect direct writer (resident node — §6.4.3-5); mode is REQUIRED (§3.2 — a defaulted mode would let a future caller silently clear the subtract styling mid-flight); "point" paints the reference rect. / ドラッグ矩形の直書き。mode は必須引数 (§3.2 — 既定値は将来の呼び忘れを無音 clear に化けさせる)。"point" は参照矩形を描く。 */
1229
+ const writeDragFrame = useCallback(
1230
+ (range: CellRange | null, mode: DragFrameMode) => {
1231
+ liveDragRangeRef.current = range
1232
+ liveDragModeRef.current = mode
1233
+ // 減算スタイル属性 (§3.2): null 呼びで必ず clear (消灯とスタイル残留遮断)。抑止は
1234
+ // ノード単位の dataset (マーキーの aqspClipKindLast と同型 — R2 是正: コンポーネント
1235
+ // 全域 ref だと層切替で再マウントした新ノードが同値スキップされ subtract 表示が落ちる)。
1236
+ // オートスクロール tick の毎フレーム同値 setAttribute はノード単位でも抑止される
1237
+ const effectiveMode = range === null ? "add" : mode
1238
+ for (const ctx of layersFor(0, 0)) {
1239
+ const modeNode = ctx.nodes.dragRect
1240
+ if (modeNode === null) {
1241
+ continue
1242
+ }
1243
+ const lastMode = modeNode.dataset.aqspDragModeLast ?? "add"
1244
+ if (effectiveMode !== lastMode) {
1245
+ modeNode.dataset.aqspDragModeLast = effectiveMode
1246
+ // subtract / point は属性で描き分け、add は属性なし (v0.7 恒等)
1247
+ if (effectiveMode === "add") {
1248
+ modeNode.removeAttribute("data-aqsp-drag-mode")
1249
+ } else {
1250
+ modeNode.setAttribute("data-aqsp-drag-mode", effectiveMode)
1251
+ }
1252
+ }
1253
+ }
1254
+ const handle = handleRef.current
1255
+ if (range === null || handle === null) {
1256
+ for (const ctx of layersFor(0, 0)) {
1257
+ if (ctx.nodes.dragRect !== null) {
1258
+ ctx.nodes.dragRect.style.display = "none"
1259
+ }
1260
+ // ドラッグ矩形の帯も消灯 (null 呼びで必ず — 取り残し防止)
1261
+ placeDragBand(ctx, null, null)
1262
+ }
1263
+ return
1264
+ }
1265
+ const position = handle.getScrollPosition()
1266
+ positionDragRect(range, Math.max(0, position.y), Math.max(0, position.x), mode)
1267
+ },
1268
+ [handleRef, positionDragRect, layersFor, placeDragBand],
1269
+ )
1270
+
1271
+ /** Fill-preview direct writer (§8.2 — the fill machine + both fill autoscrollers write through the relay). / fill プレビューの直書き (fill 機械 + 軸別 2 スクローラーがリレー経由で書く)。 */
1272
+ const writeFillFrame = useCallback(
1273
+ (range: CellRange | null) => {
1274
+ liveFillPreviewRef.current = range
1275
+ const handle = handleRef.current
1276
+ if (handle === null) {
1277
+ // writeDragFrame の null 分岐と対称の消灯 (取り残しプレビューの防御 — 現状この
1278
+ // 分岐はグリッド同時アンマウント時のみで実害なしだが、非対称は退行の温床)
1279
+ for (const ctx of layersFor(0, 0)) {
1280
+ if (ctx.nodes.fillPreview !== null) {
1281
+ ctx.nodes.fillPreview.style.display = "none"
1282
+ }
1283
+ }
1284
+ return
1285
+ }
1286
+ const position = handle.getScrollPosition()
1287
+ positionFillPreview(range, Math.max(0, position.y), Math.max(0, position.x))
1288
+ },
1289
+ [handleRef, positionFillPreview, layersFor],
1290
+ )
1291
+
1292
+ /** Resize-guideline direct writer (§7.2 — useHeaderResize writes rAF-coalesced through the relay). / ガイドラインの直書き (useHeaderResize が rAF 合流で書く)。 */
1293
+ const writeResizeGuide = useCallback(
1294
+ (guide: ResizeGuide | null) => {
1295
+ liveResizeGuideRef.current = guide
1296
+ const handle = handleRef.current
1297
+ if (handle === null) {
1298
+ // writeDragFrame / writeFillFrame の null 分岐と対称の消灯 (3 writer 同型 —
1299
+ // 到達はグリッド同時アンマウント時のみで実害なしだが、非対称は退行の温床)
1300
+ for (const ctx of layersFor(0, 0)) {
1301
+ if (ctx.nodes.resizeGuide !== null) {
1302
+ ctx.nodes.resizeGuide.style.display = "none"
1303
+ }
1304
+ }
1305
+ return
1306
+ }
1307
+ const position = handle.getScrollPosition()
1308
+ positionResizeGuide(guide, Math.max(0, position.y), Math.max(0, position.x))
1309
+ },
1310
+ [handleRef, positionResizeGuide, layersFor],
1311
+ )
1312
+
1313
+ // 帯パレット (v0.8 §5): --aqsp-band-* は overlay ルートへ layout effect が書く唯一の書き手 —
1314
+ // 毎フレームの repaint は決して書かない。能力なし (undefined) は書込 0 で CSS 既定のまま
1315
+ useLayoutEffect(() => {
1316
+ const overlay = overlayRef.current
1317
+ if (overlay === null || highlightColors === undefined) {
1318
+ return
1319
+ }
1320
+ overlay.style.setProperty("--aqsp-band-row", highlightColors.row)
1321
+ overlay.style.setProperty("--aqsp-band-column", highlightColors.column)
1322
+ overlay.style.setProperty("--aqsp-band-intersection", highlightColors.intersection)
1323
+ overlay.style.setProperty("--aqsp-band-opacity", String(highlightColors.opacity))
1324
+ // パレットの退役 (undefined へ戻る / identity 交代 / unmount) では変数を外し、style を v0.7 の形へ戻す
1325
+ return () => {
1326
+ overlay.style.removeProperty("--aqsp-band-row")
1327
+ overlay.style.removeProperty("--aqsp-band-column")
1328
+ overlay.style.removeProperty("--aqsp-band-intersection")
1329
+ overlay.style.removeProperty("--aqsp-band-opacity")
1330
+ }
1331
+ }, [highlightColors])
1332
+
1333
+ // 登録 (所有点は Spreadsheet — §6.9-7)。React 経路の再描画 (§6.7.2-3 (a)) は分担所有:
1334
+ // SelectionState 変化は Spreadsheet の layout effect ([selection] キー)、編集状態変化と
1335
+ // 軸再構築 (repaintOverlay の identity 変化 = シート切替 / リサイズ) は下の effect。
1336
+ // ここで毎レンダー repaint すると同一コミットで二重描画になるため登録のみ行う
1337
+ useEffect(() => {
1338
+ repaintRef.current = repaintOverlay
1339
+ dragFrameRef.current = writeDragFrame
1340
+ fillFrameRef.current = writeFillFrame
1341
+ resizeGuideFrameRef.current = writeResizeGuide
1342
+ return () => {
1343
+ repaintRef.current = null
1344
+ dragFrameRef.current = null
1345
+ fillFrameRef.current = null
1346
+ resizeGuideFrameRef.current = null
1347
+ }
1348
+ })
1349
+ // editing 反転 (Phase 3 の F2 は選択不変でシンク面積だけ変わる) + 軸 / ストア identity 変化
1350
+ // (repaintOverlay の deps) + 層分岐切替 (0 ↔ N トグル — 新コンテナは空でマウントされるため即
1351
+ // 再塗りが必須) で描く — [selection] は Spreadsheet 側の担当。初回マウントでも走るが、その時点の
1352
+ // グリッドは viewport 未計測 ({0,0} / range null — 計測は layout effect の setState で 1 レンダー
1353
+ // 遅れる) で何も描かない: マウント時の A1 枠はグリッドの rAF 遅延 onScroll / onRangeChange 中継
1354
+ // (Spreadsheet の repaintOverlay 呼び) が描く (post-B F2 実測)
1355
+ useEffect(() => {
1356
+ repaintOverlay()
1357
+ }, [repaintOverlay, editing, effectiveFrozenCols, effectiveFrozenRows])
1358
+
1359
+ // シンクの mousedown ガード (idle の 1px 直クリックから armed 選択を守る — §4.2 ADR-4。
1360
+ // 所有は SelectionOverlay — §6.10 の変異検証対象)
1361
+ useEffect(() => {
1362
+ const sink = sinkRef.current
1363
+ if (sink === null) {
1364
+ return
1365
+ }
1366
+ const handler = (event: MouseEvent) => onSinkMouseDown(event)
1367
+ sink.addEventListener("mousedown", handler)
1368
+ return () => sink.removeEventListener("mousedown", handler)
1369
+ }, [sinkRef, onSinkMouseDown])
1370
+
1371
+ // ---- 象限 JSX の構築 (v0.6 計画 §3-4): 各軸の凍結種 × をデカルト積し、必要な象限だけ描く。
1372
+ // DOM 順は「後置 = 境界での描画上位」— 主 → 行凍結 → 列凍結 → コーナー の順で凍結ほど後置。
1373
+ // v0.5 互換の data-attr フック (main / frozen / scroll) を R = 0 の 3 象限へ温存する ----
1374
+ const xKinds =
1375
+ effectiveFrozenCols > 0
1376
+ ? ([
1377
+ { k: "f", style: { left: 0, width: overlayFrozenWidth } },
1378
+ { k: "s", style: { left: overlayFrozenWidth, right: 0 } },
1379
+ ] as const)
1380
+ : ([{ k: "m", style: { left: 0, right: 0 } }] as const)
1381
+ const yKinds =
1382
+ effectiveFrozenRows > 0
1383
+ ? ([
1384
+ { k: "f", style: { top: 0, height: overlayFrozenHeight } },
1385
+ { k: "s", style: { top: overlayFrozenHeight, bottom: 0 } },
1386
+ ] as const)
1387
+ : ([{ k: "m", style: { top: 0, bottom: 0 } }] as const)
1388
+ // 描画上位の並べ替え (凍結種 f を後置): 主(m) < スクロール(s) < 凍結(f)
1389
+ const rank = (k: string): number => (k === "m" ? 0 : k === "s" ? 1 : 2)
1390
+ const quadrants = xKinds.flatMap((x) => yKinds.map((y) => ({ key: `${x.k}${y.k}`, style: { ...x.style, ...y.style } }))).sort((a, b) => rank(a.key[0]) + rank(a.key[1]) - (rank(b.key[0]) + rank(b.key[1])))
1391
+ // R = 0 の 3 象限は v0.5 の data-attr を温存 (E2E / 単体フック互換)。行凍結が入る象限は
1392
+ // 汎用 data-aqsp-overlay-quad のみ
1393
+ const legacyAttr = (key: string): Record<string, string> => (key === "mm" ? { "data-aqsp-overlay-main": "" } : key === "fm" ? { "data-aqsp-overlay-frozen": "" } : key === "sm" ? { "data-aqsp-overlay-scroll": "" } : {})
1394
+
1395
+ return (
1396
+ // 右 / 下オフセットは JSX インライン (CSS は TS 定数を読めない — SSOT は SCROLLBAR_WIDTH
1397
+ // 1 本 §6.7.2-1)。下は v0.4 で追加: 横バー帯がグリッド内在化し、オーバーレイはセル
1398
+ // ビューポート (バー帯を除く) だけを覆う
1399
+ <div ref={overlayRef} className="aqsp-overlay" data-aqsp-overlay="" style={{ right: SCROLLBAR_WIDTH, bottom: SCROLLBAR_WIDTH }}>
1400
+ {quadrants.map(({ key, style }) => {
1401
+ const refs = quadRefs[key]
1402
+ return (
1403
+ <div key={key} className="aqsp-overlay-quad" data-aqsp-overlay-quad={key} {...legacyAttr(key)} style={style} ref={refs.container}>
1404
+ <div className="aqsp-active-frame" data-aqsp-active="" style={{ display: "none" }} ref={refs.active} />
1405
+ <div className="aqsp-drag-rect" data-aqsp-drag-rect="" style={{ display: "none" }} ref={refs.dragRect} />
1406
+ <div className="aqsp-clip-marquee" data-aqsp-clip-marquee="" style={{ display: "none" }} ref={refs.marquee} />
1407
+ <div className="aqsp-autofill-preview" data-aqsp-autofill-preview="" style={{ display: "none" }} ref={refs.fillPreview} />
1408
+ <div className="aqsp-resize-guide" data-aqsp-resize-guide="" style={{ display: "none" }} ref={refs.resizeGuide} />
1409
+ </div>
1410
+ )
1411
+ })}
1412
+ {/* inputMode (R4 / R5 / R7 是正): 仮想キーボード招待フラグの純粋な描画 — "none" = 非招待 (touch / ペンは
1413
+ スクロール専用で選択も編集もしない。タップの compat mousedown 既定で root → シンクへ焦点が戻っても OSK は
1414
+ 出ない)、"text" = 招待中 (ポインタでエディタを開いた — キーボード無し端末が OSK を出せる。Enter / Tab の
1415
+ 確定では下ろさず、グリッド脱出か touch / ペン押下で下ろす)。キーボード / IME 利用者には常に "none" で
1416
+ 一度も変化しない — 変換中の属性変更 (Windows TSF が変換を強制終了する) を構造的に排除する。
1417
+ OSK の実際の表示は UA 依存: WebKit はフォーカス済み要素の変更を再評価する、Chromium はフォーカス取得時に
1418
+ しか評価しない。ハードウェアキーボードとデスクトップ IME は無影響 */}
1419
+ <textarea ref={mergedSinkRef} className="aqsp-sink" aria-label={sinkAriaLabel} tabIndex={-1} defaultValue=" " inputMode={virtualKeyboard ? "text" : "none"} spellCheck={false} autoCorrect="off" autoCapitalize="off" autoComplete="off" data-aqsp-sink="" />
1420
+ {/* pointer-events: auto の例外第 2 号 (§8.1) — シンクより後置 = 最上層。enableFillHandle=false は
1421
+ DOM ごと抑止 (機械も不活性) */}
1422
+ {enableFillHandle ? <div ref={fillHandleNodeRef} className="aqsp-autofill-handle" data-aqsp-autofill-handle="" style={{ display: "none" }} /> : null}
1423
+ </div>
1424
+ )
1425
+ }