@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,598 @@
1
+ /**
2
+ * @fileoverview Header resize / unhide machine (§7 PT-1). ONE hook wires BOTH bands
3
+ * (machine count in the §7.4 composite = 1). The band gets ONE wire-once native
4
+ * capture-phase pointerdown (strips carry no listeners — virtual re-render unmounts would
5
+ * kill captures) which records lastPointerTypeRef FIRST, gates on primary mouse, dispatches
6
+ * via closest() and steals from the selection machine ONLY on a hit. Drags write a
7
+ * rAF-coalesced guideline through the overlay writer; the cell sizes change ONLY at the up
8
+ * commit (Excel-like). Commits run through the resizeTracks batch seam after the pure
9
+ * `gateResizeEntries` (resizeGates.ts — the §7.2 post-application zero-guard, the ADR-12
10
+ * track-count gate and the §5.4(b) row-axis hidden-total gate in one place) plus the §7.2
11
+ * fan-out (full-span selection). Band dblclick = column autofit (canvas measureText over the
12
+ * visible window, measured-cell gate; v0.8 §4.2.10 — every cell is measured in its PAINTED
13
+ * font (base ⊕ width-relevant conditional formats: bold / italic setters plus every `stopIfTrue`
14
+ * barrier so the sliced walk stops where paint stops, sliced per column),
15
+ * bucketed by font key so `ctx.font` is assigned once per distinct font) / row default-height
16
+ * restore / unhide marker restore. While the editor is pointing at a formula reference
17
+ * (v0.8 §4.2.10) a strip press is consumed (focus back to the sink, no gesture, no commit).
18
+ *
19
+ * ヘッダーリサイズ / unhide 機械 (§7 PT-1)。単一フックが両帯を結線 (§7.4 の機械勘定 = 1)。
20
+ * 帯へ wire-once の native capture pointerdown を 1 本 (ストリップはリスナーを持たない —
21
+ * 仮想再レンダーの unmount が capture を殺す)。lastPointerTypeRef の記録が最初、主ボタン
22
+ * mouse ゲート、closest() ディスパッチ、命中時のみ選択機械から奪う。ドラッグは overlay
23
+ * writer 経由の rAF 合流ガイドラインのみ描き、セル実サイズは up 確定でだけ変わる。確定は
24
+ * 純関数 `gateResizeEntries` (resizeGates.ts — §7.2 の適用後 0 化ガード + ADR-12 トラック数
25
+ * ゲート + §5.4(b) の行軸非表示総数ゲートを 1 箇所に) を通した resizeTracks バッチシーム +
26
+ * ファンアウト (全スパン選択)。帯 dblclick = 列 autofit (可視ウィンドウの canvas measureText +
27
+ * 計測セル数ゲート。v0.8 §4.2.10 — 各セルを**塗り書式**のフォント (base ⊕ 幅に効く条件付き書式 =
28
+ * bold / italic 設定規則と stopIfTrue の障壁 — 走査が塗りと同じ位置で止まる — を列毎にスライス) で
29
+ * 計り、フォントキーでバケット化して `ctx.font` の
30
+ * 代入を異なりフォント毎 1 回にする) / 行は既定行高復元 / unhide マーカーは既定サイズ復元。
31
+ * 参照ポインティング中 (v0.8 §4.2.10) のストリップ押下は consume (シンクへ焦点、ジェスチャも
32
+ * 確定もなし)。
33
+ */
34
+
35
+ import type { VirtualGridHandle } from "@aiquants/virtualscroll"
36
+ import { type RefObject, useCallback, useEffect, useMemo, useRef } from "react"
37
+ import type { AxisModel } from "../model/axisModel.ts"
38
+ import type { SpreadsheetOperation } from "../model/cellCoord.ts"
39
+ import { type CellFormat, formatCellValue } from "../model/cellFormat.ts"
40
+ import { buildCfIndex, createCfMemo, resolvePaintedFormat, widthRelevantRules } from "../model/conditionalFormat.ts"
41
+ import type { SpreadsheetDataAdapter } from "../model/dataAdapter.ts"
42
+ import type { ViewStore } from "../model/viewStore.ts"
43
+ import type { ResizeGuide } from "../overlay/SelectionOverlay.tsx"
44
+ import type { SelectionState } from "../selection/selectionState.ts"
45
+ import type { SelectionEngine } from "../selection/useSelectionEngine.ts"
46
+ import { gateResizeEntries, type ResizeEntry, ROW_FANOUT_DIVISOR, ROW_HIDDEN_LIMIT } from "./resizeGates.ts"
47
+
48
+ /** Autofit horizontal padding: cell padding 4+4 + right border 1 (px). / autofit の水平パディング (セル padding 4+4 + 右罫 1)。 */
49
+ const AUTOFIT_PADDING = 9
50
+ /**
51
+ * Autofit measured-cell limit divisor (§13 budget row (e) DESIGN RETURN, measured): 100k
52
+ * DISTINCT-string measureText runs ~794ms in headless Chromium (the 50-string rotation that
53
+ * flattered the first bench sits at ~186ms via Blink's shape cache) — the 300ms class holds
54
+ * only up to ~25k distinct measurements, so autofit gates at maxOperationCells / 4.
55
+ * autofit 計測上限の除数 (§13 予算行 (e) の設計返却 — 実測起点)。異なり文字列 10 万件の
56
+ * measureText は headless Chromium で ~794ms (初回ベンチの 50 文字列巡回は shape cache で
57
+ * ~186ms に化けていた)。300ms 級は異なり ~2.5 万計測まで — autofit は maxOperationCells / 4
58
+ * でゲートする。
59
+ */
60
+ const AUTOFIT_MEASURE_DIVISOR = 4
61
+
62
+ type UseHeaderResizeParams = {
63
+ /** Zoom scale reader (v0.3): client px = logical px × zoom — divide client deltas once at this boundary. / ズーム倍率 reader (v0.3)。client px = 論理 px × zoom — client 差分はこの境界で 1 回だけ除算する。 */
64
+ zoomRef: RefObject<number>
65
+ /** Inert gate: false = no listeners at all (§11 enableResizeUI opt-out / empty grid). / 不活性ゲート (§11 の opt-out / 空グリッド)。 */
66
+ enabled: boolean
67
+ colBandRef: RefObject<HTMLElement | null>
68
+ rowBandRef: RefObject<HTMLElement | null>
69
+ colAxis: AxisModel
70
+ rowAxis: AxisModel
71
+ viewStore: ViewStore
72
+ handleRef: RefObject<VirtualGridHandle | null>
73
+ isEditing: () => boolean
74
+ requestCommit: () => boolean
75
+ focusSink: () => void
76
+ getSelection: () => SelectionState
77
+ adapter: SpreadsheetDataAdapter
78
+ /** Autofit adoption condition: renderCell unspecified (custom DOM defeats measureText — §10 deviation). / autofit 採用条件 (renderCell 未指定)。 */
79
+ autofitEnabled: boolean
80
+ defaultRowHeight: number
81
+ defaultColumnWidth: number
82
+ maxOperationCells: number
83
+ onOperationRejected?: (op: SpreadsheetOperation, cellCount: number, reason: "limit" | "bounds") => void
84
+ /** The batch application seam (Spreadsheet-owned — bump/repaint once per batch). / バッチ適用シーム (Spreadsheet 所有)。 */
85
+ resizeTracks: (axis: "row" | "col", entries: readonly ResizeEntry[]) => void
86
+ /** Guideline writer relay (overlay-owned positioner). / ガイドライン writer リレー (positioner は overlay 所有)。 */
87
+ writeResizeGuide: (guide: ResizeGuide | null) => void
88
+ /** §8.2 repaint edges: called after the press flag set and at cleanup end. / §8.2 の repaint エッジ (press 直後 + cleanup 末尾)。 */
89
+ repaintOverlay: () => void
90
+ /**
91
+ * The editor's live pointing predicate (v0.8 §4.2.10): true ⇒ a strip press is consumed
92
+ * (stopPropagation + preventDefault + focusSink) without a gesture or a commit gate.
93
+ * エディタの生きたポインティング述語 (v0.8 §4.2.10): true ならストリップ押下は consume
94
+ * (stopPropagation + preventDefault + focusSink) でジェスチャも commit ゲートも走らない。
95
+ */
96
+ isPointing: () => boolean
97
+ }
98
+
99
+ /**
100
+ * The canvas `font` shorthand of a painted format over the band's computed style: italic /
101
+ * bold / pt size (× 4/3 px, like cellPaint) / family from the format, the band's values
102
+ * otherwise. Interned per format object (the paint intern keeps one object per distinct
103
+ * painted format, so this is one string per distinct font).
104
+ * 塗り書式の canvas `font` 短縮形 (帯の computed style の上に italic / bold / pt サイズ (× 4/3 px —
105
+ * cellPaint と同式) / フォント族を重ねる)。書式 object 毎に intern (塗り intern が書式毎に 1 object を
106
+ * 保つので、異なりフォント毎に 1 文字列)。
107
+ */
108
+ const fontKeyOf = (painted: CellFormat, fontKeys: WeakMap<CellFormat, string>, bandStyle: CSSStyleDeclaration): string => {
109
+ const cached = fontKeys.get(painted)
110
+ if (cached !== undefined) {
111
+ return cached
112
+ }
113
+ // 真偽値は厳密 true のみ (fail-soft — cellPaint と同じ判定)
114
+ const style = painted.italic === true ? "italic" : bandStyle.fontStyle
115
+ const weight = painted.bold === true ? "bold" : bandStyle.fontWeight
116
+ // pt は cellPaint と同じ × 4/3 で px へ (非有限 / 非正は帯の既定のまま)
117
+ const size = typeof painted.fontSize === "number" && Number.isFinite(painted.fontSize) && painted.fontSize > 0 ? `${(painted.fontSize * 4) / 3}px` : bandStyle.fontSize
118
+ const family = typeof painted.fontFamily === "string" && painted.fontFamily !== "" ? painted.fontFamily : bandStyle.fontFamily
119
+ const font = `${style} ${weight} ${size} ${family}`
120
+ fontKeys.set(painted, font)
121
+ return font
122
+ }
123
+
124
+ /** One in-flight gesture. / 飛行中ジェスチャ 1 件。 */
125
+ type ResizeGesture = {
126
+ axis: "row" | "col"
127
+ kind: "resize" | "unhide"
128
+ index: number
129
+ initialSize: number
130
+ startClientX: number
131
+ startClientY: number
132
+ }
133
+
134
+ /**
135
+ * Wires the resize/unhide machine onto both header bands; returns the §7.4 composite surface.
136
+ * 両ヘッダー帯へリサイズ / unhide 機械を結線し、§7.4 の合成面を返す。
137
+ */
138
+ export const useHeaderResize = (params: UseHeaderResizeParams): SelectionEngine => {
139
+ // paramsRef パターン: リスナーは結線 1 回、最新パラメータはレンダー毎にここへ写す
140
+ const paramsRef = useRef(params)
141
+ paramsRef.current = params
142
+
143
+ const draggingRef = useRef(false)
144
+ const gestureRef = useRef<ResizeGesture | null>(null)
145
+ const pendingSizeRef = useRef(0)
146
+ const capturedPointerRef = useRef<number | null>(null)
147
+ /** `buttons` reported by the press — the primary bit vanishing on a captured move is the chorded primary release (R4). / 押下時の `buttons` (主ビットの消失 = chord 中の主ボタン解放)。 */
148
+ const pressButtonsRef = useRef(0)
149
+ /** The band that holds the capture (release target). / capture を持つ帯 (解放先)。 */
150
+ const capturedBandRef = useRef<HTMLElement | null>(null)
151
+ const guideRafRef = useRef<number | null>(null)
152
+ /** Last pointerdown's pointer type (dblclick carries no pointerType — v0.1 実測). / 直近 pointerdown の種別 (dblclick の mouse 判定用)。 */
153
+ const lastPointerTypeRef = useRef<string>("")
154
+ /** Attached window listeners for the drag duration. / ドラッグ期間だけ張る window リスナー。 */
155
+ const windowListenersRef = useRef<{ move: (e: PointerEvent) => void; up: (e: PointerEvent) => void } | null>(null)
156
+
157
+ /** Whether the event belongs to the capturing pointer (mouse-only contract on all phases). / 所有ポインタ照合 (mouse 限定契約の全フェーズ維持)。 */
158
+ const isOwnPointer = useCallback((event: PointerEvent): boolean => (capturedPointerRef.current !== null ? event.pointerId === capturedPointerRef.current : event.pointerType === "mouse"), [])
159
+
160
+ /** Idempotent cleanup: guideline off + capture release + window listeners off + the §8.2 cleanup repaint edge. / 冪等な後始末 (ガイドライン消灯 + §8.2 の cleanup repaint エッジ)。 */
161
+ const cleanupDrag = useCallback(() => {
162
+ const p = paramsRef.current
163
+ draggingRef.current = false
164
+ gestureRef.current = null
165
+ if (guideRafRef.current !== null) {
166
+ cancelAnimationFrame(guideRafRef.current)
167
+ guideRafRef.current = null
168
+ }
169
+ p.writeResizeGuide(null)
170
+ const listeners = windowListenersRef.current
171
+ if (listeners !== null) {
172
+ window.removeEventListener("pointermove", listeners.move)
173
+ window.removeEventListener("pointerup", listeners.up)
174
+ windowListenersRef.current = null
175
+ }
176
+ const band = capturedBandRef.current
177
+ if (band !== null && capturedPointerRef.current !== null && band.hasPointerCapture?.(capturedPointerRef.current)) {
178
+ band.releasePointerCapture(capturedPointerRef.current)
179
+ }
180
+ capturedBandRef.current = null
181
+ capturedPointerRef.current = null
182
+ p.repaintOverlay()
183
+ }, [])
184
+
185
+ /** Full-span predicate: SOME single range covers the whole cross axis and contains the index (§3.3 と同一述語). / 全交差軸スパン述語。 */
186
+ const isFullSpanRange = useCallback((axis: "row" | "col", range: SelectionState["ranges"][number]): boolean => {
187
+ const p = paramsRef.current
188
+ return axis === "col" ? range.startRow === 0 && range.endRow === p.rowAxis.count - 1 : range.startCol === 0 && range.endCol === p.colAxis.count - 1
189
+ }, [])
190
+
191
+ /**
192
+ * Fan-out targets (§7.2): when the pressed track belongs to a full-span range, ALL tracks
193
+ * of all full-span ranges (hidden included — implicit unhide, Excel-compatible); the
194
+ * ADR-12 gate counts BEFORE materializing (duplicate spans counted as-is, conservative).
195
+ * Returns null when rejected.
196
+ * ファンアウト対象 (§7.2)。押下トラックが全スパンレンジ所属なら全スパンレンジ群の全トラック
197
+ * (非表示含む — 暗黙再表示)。ADR-12 ゲートは実体化前に保守側で数える。拒否時は null。
198
+ */
199
+ const collectTargets = useCallback(
200
+ (axis: "row" | "col", pressedIndex: number): number[] | null => {
201
+ const p = paramsRef.current
202
+ const ranges = p.getSelection().ranges.filter((range) => isFullSpanRange(axis, range))
203
+ const inFanOut = ranges.some((range) => (axis === "col" ? pressedIndex >= range.startCol && pressedIndex <= range.endCol : pressedIndex >= range.startRow && pressedIndex <= range.endRow))
204
+ if (!inFanOut) {
205
+ return [pressedIndex]
206
+ }
207
+ // 実体化前の保守カウント (重複スパンは重複のまま数え、上限判定を保守側に倒す —
208
+ // countRangeCells と同じ流儀。帰結: 重複全スパンレンジでは実対象数が上限内でも
209
+ // 膨張カウントで拒否され得る + 通知 count も膨張値 — 意図した保守側逸脱)。
210
+ // 行軸は (f) の設計返却で 1/10 上限 (updateItemSize の per-call 補償コスト)
211
+ const count = ranges.reduce((sum, range) => sum + (axis === "col" ? range.endCol - range.startCol + 1 : range.endRow - range.startRow + 1), 0)
212
+ const limit = axis === "row" ? Math.floor(p.maxOperationCells / ROW_FANOUT_DIVISOR) : p.maxOperationCells
213
+ if (count > limit) {
214
+ p.onOperationRejected?.("resize", count, "limit")
215
+ return null
216
+ }
217
+ const targets = new Set<number>()
218
+ for (const range of ranges) {
219
+ const from = axis === "col" ? range.startCol : range.startRow
220
+ const to = axis === "col" ? range.endCol : range.endRow
221
+ for (let index = from; index <= to; index += 1) {
222
+ targets.add(index)
223
+ }
224
+ }
225
+ return [...targets]
226
+ },
227
+ [isFullSpanRange],
228
+ )
229
+
230
+ /**
231
+ * Applies an entry batch through the pure gate (resizeGates.ts: count gate, rounding, the
232
+ * §7.2 post-application zero-guard on the pressed track, the §5.4(b) row-axis hidden-total
233
+ * gate) and then the resizeTracks seam once; a refusal goes to onOperationRejected("resize").
234
+ * 純関数ゲート (resizeGates.ts: 件数ゲート・丸め・§7.2 の押下トラック 0 化ガード・§5.4(b) の行軸
235
+ * 非表示総数ゲート) を通してから resizeTracks を 1 回呼ぶ。拒否は onOperationRejected("resize")。
236
+ */
237
+ const applyEntries = useCallback((axis: "row" | "col", rawEntries: readonly ResizeEntry[], pressedIndex: number | null): void => {
238
+ const p = paramsRef.current
239
+ const axisModel = axis === "col" ? p.colAxis : p.rowAxis
240
+ // ゲートは純関数 (§4.2.10 の委譲) — ヘッダー由来のサイズは常に有限 ≥ 0 なので RangeError 枝は到達しない
241
+ const result = gateResizeEntries(axisModel, rawEntries, pressedIndex, { maxOperationCells: p.maxOperationCells, rowHiddenLimit: ROW_HIDDEN_LIMIT, axisKind: axis })
242
+ if (!result.ok) {
243
+ p.onOperationRejected?.("resize", result.count, result.reason)
244
+ return
245
+ }
246
+ p.resizeTracks(axis, result.entries)
247
+ }, [])
248
+
249
+ /** The up commit: same-value skip → fan-out targets → batch apply. / up 確定 (同値スキップ → ファンアウト → バッチ適用)。 */
250
+ const commitResize = useCallback(
251
+ (gesture: ResizeGesture, newSize: number): void => {
252
+ if (newSize === gesture.initialSize) {
253
+ return // 同値スキップ (§7.2 — Excel の同値リリース均等化は非対応の逸脱)
254
+ }
255
+
256
+ // unhide ドラッグは対象 1 本 (境界に近い側から 1 本ずつ広げる — §7.3)
257
+ const targets = gesture.kind === "unhide" ? [gesture.index] : collectTargets(gesture.axis, gesture.index)
258
+ if (targets === null) {
259
+ return // ADR-12 拒否済み
260
+ }
261
+ applyEntries(
262
+ gesture.axis,
263
+ targets.map((index) => ({ index, size: newSize })),
264
+ gesture.index,
265
+ )
266
+ },
267
+ [collectTargets, applyEntries],
268
+ )
269
+
270
+ /** Column autofit (§7.3 / v0.8 §4.2.10): per-column canvas measurement over the visible window in each cell's painted font, bucketed by font key. / 列 autofit (可視ウィンドウをセルの塗りフォントで計測、フォントキーでバケット化)。 */
271
+ const autofitColumns = useCallback(
272
+ (band: HTMLElement, pressedIndex: number): void => {
273
+ const p = paramsRef.current
274
+ const targets = collectTargets("col", pressedIndex)
275
+ if (targets === null) {
276
+ return
277
+ }
278
+ // 可視ウィンドウ内の可視行 (幅 0 行は計測しない)
279
+ const { rowStart, rowEnd } = p.viewStore.get()
280
+ const last = Math.min(rowEnd, p.rowAxis.count - 1)
281
+ const visibleRows: number[] = []
282
+ for (let row = rowStart; row <= last; row += 1) {
283
+ if (p.rowAxis.sizeOf(row) > 0) {
284
+ visibleRows.push(row)
285
+ }
286
+ }
287
+ // 計測対象の可視行がゼロ (命令 API 経由の全窓内非表示) は no-op — Excel も計測対象
288
+ // なしでは幅を変えない (0 計測 → 9px 潰しの破壊を作らない。R1(B) 所見)
289
+ if (visibleRows.length === 0) {
290
+ return
291
+ }
292
+ // 上限ゲートは計測セル数単位 (§7.3 — トラック数単位では 16k 列 × 可視 40 行が素通り
293
+ // する)。上限は autofit 専用の低い係数 (§13 (e) の設計返却 — 実測根拠は定数 docstring)
294
+ const measuredCells = targets.length * visibleRows.length
295
+ const measureLimit = Math.floor(p.maxOperationCells / AUTOFIT_MEASURE_DIVISOR)
296
+ if (measuredCells > measureLimit) {
297
+ p.onOperationRejected?.("resize", measuredCells, "limit")
298
+ return
299
+ }
300
+ // canvas 生成 + computed font 取得はジェスチャ 1 回につき 1 度 (§7.3)。font は
301
+ // ロングハンド合成の単一経路 (Firefox は font ショートハンドの computed 値を空で
302
+ // 返すため、合成だけが全ブラウザで決定的)
303
+ const context = document.createElement("canvas").getContext("2d")
304
+ if (context === null) {
305
+ return // 実ブラウザでは常に成立 (jsdom は 2d 未実装 — 計測面は E2E がピン)
306
+ }
307
+ const bandStyle = getComputedStyle(band)
308
+ // 既定フォント (書式なしセル) — 帯の computed style のロングハンド合成
309
+ const baseFont = `${bandStyle.fontStyle} ${bandStyle.fontWeight} ${bandStyle.fontSize} ${bandStyle.fontFamily}`
310
+ // CF は幅に効く規則 (bold / italic 設定) と stopIfTrue の障壁だけを 1 ジェスチャ 1 回抽出し列毎に
311
+ // スライスする (実測 155 → 56 ms)。障壁を残すため走査は塗りと同じ位置で止まり bold / italic が
312
+ // 厳密一致する。getConditionalFormats は getCellFormat を要する (root がマウント時に保証)
313
+ const cfRules = p.adapter.getConditionalFormats === undefined ? null : widthRelevantRules(p.adapter.getConditionalFormats())
314
+ const memo = createCfMemo()
315
+ const fontKeys = new WeakMap<CellFormat, string>()
316
+ // フォントキー毎のバケット (列 index + 表示文字列): ctx.font の代入は異なりフォント毎に 1 回
317
+ const buckets = new Map<string, { col: number; text: string }[]>()
318
+ for (const col of targets) {
319
+ const colIndex = cfRules === null ? null : buildCfIndex(cfRules.filter((rule) => rule.ranges.some((range) => col >= range.startCol && col <= range.endCol)))
320
+ for (const row of visibleRows) {
321
+ // 計測対象は**画面に描かれる表示文字列** (v0.7 — 値書式適用後)。素の getCellText を測ると
322
+ // 通貨 "¥1,234,567" が素値 "1234567" の幅に潰されて最上位桁ごと左クリップされる (誤読事故)。
323
+ // 書式なしセルは formatCellValue が素通しするため従来と同一。interned フォーマッタにより
324
+ // 計測ループ内の整形は O(1)/セル (キャッシュ前提 — 25k セル実測 32ms)
325
+ const raw = p.adapter.getCellText(row, col)
326
+ // 塗り書式 (base ⊕ 幅に効く CF) — raw は既読なので thunk は再読しない
327
+ const painted = p.adapter.getCellFormat === undefined ? undefined : resolvePaintedFormat(p.adapter, colIndex, memo, row, col, () => raw)
328
+ const font = painted === undefined ? baseFont : fontKeyOf(painted, fontKeys, bandStyle)
329
+ const text = formatCellValue(raw, painted === undefined ? undefined : painted.value)
330
+ const bucket = buckets.get(font)
331
+ if (bucket === undefined) {
332
+ buckets.set(font, [{ col, text }])
333
+ } else {
334
+ bucket.push({ col, text })
335
+ }
336
+ }
337
+ }
338
+ // バケット毎にフォントを 1 回設定して計測 (E2E は ctx.font 代入回数 ≤ 異なりフォント数 + 1 を数える)
339
+ const widestByCol = new Map<number, number>()
340
+ for (const [font, cells] of buckets) {
341
+ context.font = font
342
+ for (const cell of cells) {
343
+ const width = context.measureText(cell.text).width
344
+ const widest = widestByCol.get(cell.col)
345
+ if (widest === undefined || width > widest) {
346
+ widestByCol.set(cell.col, width)
347
+ }
348
+ }
349
+ }
350
+ const entries: ResizeEntry[] = []
351
+ for (const col of targets) {
352
+ const widest = widestByCol.get(col)
353
+ // 全空列は対象から外す (Excel の空列境界 dblclick は no-op — 9px 潰しを作らない)
354
+ if (widest !== undefined && widest > 0) {
355
+ entries.push({ index: col, size: Math.ceil(widest) + AUTOFIT_PADDING })
356
+ }
357
+ }
358
+ if (entries.length === 0) {
359
+ return
360
+ }
361
+ applyEntries("col", entries, pressedIndex)
362
+ },
363
+ [collectTargets, applyEntries],
364
+ )
365
+
366
+ const { enabled, colBandRef, rowBandRef } = params
367
+ useEffect(() => {
368
+ const colBand = colBandRef.current
369
+ const rowBand = rowBandRef.current
370
+ if (!enabled || colBand === null || rowBand === null) {
371
+ return
372
+ }
373
+
374
+ /** Per-axis closest() selector (structural exclusivity of the two hooks). / 軸別 closest() セレクタ。 */
375
+ const selectorFor = (axis: "row" | "col"): string => (axis === "col" ? "[data-aqsp-col-resize], [data-aqsp-unhide]" : "[data-aqsp-row-resize], [data-aqsp-unhide]")
376
+
377
+ /** Resolves a strip hit to a gesture seed. / ストリップ命中のジェスチャ種解決。 */
378
+ const resolveHit = (axis: "row" | "col", target: EventTarget | null): { kind: "resize" | "unhide"; index: number } | null => {
379
+ if (!(target instanceof Element)) {
380
+ return null
381
+ }
382
+ const hit = target.closest(selectorFor(axis))
383
+ if (hit === null) {
384
+ return null
385
+ }
386
+ const isUnhide = hit.hasAttribute("data-aqsp-unhide")
387
+ const index = Number(hit.getAttribute(isUnhide ? "data-aqsp-unhide" : axis === "col" ? "data-aqsp-col-resize" : "data-aqsp-row-resize"))
388
+ return { kind: isUnhide ? "unhide" : "resize", index }
389
+ }
390
+
391
+ const applyWindowPointerMove = (event: PointerEvent) => {
392
+ if (!(draggingRef.current && isOwnPointer(event))) {
393
+ return
394
+ }
395
+ const gesture = gestureRef.current
396
+ if (gesture === null) {
397
+ return
398
+ }
399
+ // ズーム境界 (v0.3): ドラッグ差分は視覚 px — 論理 px へ 1 回だけ除算して適用する
400
+ const delta = (gesture.axis === "col" ? event.clientX - gesture.startClientX : event.clientY - gesture.startClientY) / paramsRef.current.zoomRef.current
401
+ pendingSizeRef.current = Math.max(0, Math.round(gesture.initialSize + delta))
402
+ // ガイドラインは rAF 合流の直書き 1 ノードのみ (§7.2 — セル実サイズは確定まで不変)
403
+ if (guideRafRef.current === null) {
404
+ guideRafRef.current = requestAnimationFrame(() => {
405
+ guideRafRef.current = null
406
+ const live = gestureRef.current
407
+ if (!draggingRef.current || live === null) {
408
+ return
409
+ }
410
+ const p = paramsRef.current
411
+ const axisModel = live.axis === "col" ? p.colAxis : p.rowAxis
412
+ p.writeResizeGuide({ axis: live.axis, position: axisModel.offsetOf(live.index) + pendingSizeRef.current })
413
+ })
414
+ }
415
+ }
416
+
417
+ /** Confirms the in-flight resize at the pending size (primary release — pointerup or chorded move). / 飛行中リサイズの確定 (主ボタン解放 — pointerup または chord 中の move)。 */
418
+ const confirmResize = () => {
419
+ const gesture = gestureRef.current
420
+ const newSize = pendingSizeRef.current
421
+ cleanupDrag()
422
+ if (gesture !== null) {
423
+ commitResize(gesture, newSize)
424
+ }
425
+ }
426
+
427
+ const onWindowPointerMove = (event: PointerEvent) => {
428
+ applyWindowPointerMove(event)
429
+ // chord 中の主ボタン解放 (R4 是正 — 選択機械と同型): 主ビットを失った own move で、その move の
430
+ // 差分を反映した pendingSize で確定する
431
+ if (draggingRef.current && isOwnPointer(event) && (pressButtonsRef.current & 1) !== 0 && (event.buttons & 1) === 0) {
432
+ confirmResize()
433
+ }
434
+ }
435
+
436
+ const onWindowPointerUp = (event: PointerEvent) => {
437
+ // 主ボタンのリリースのみ確定 (選択機械と同じ閉塞 — 非主ボタン up は isOwnPointer を通過する)
438
+ if (event.button !== 0 || !(draggingRef.current && isOwnPointer(event))) {
439
+ return
440
+ }
441
+ confirmResize()
442
+ }
443
+
444
+ /** Capture-phase press: record type → primary gate → closest dispatch → steal on hit (§7.1). / capture 押下 (記録 → 主ボタンゲート → closest → 命中時のみ奪取)。 */
445
+ const makePointerDown = (axis: "row" | "col", band: HTMLElement) => (event: PointerEvent) => {
446
+ const p = paramsRef.current
447
+ // 記録が最初 (記録は steal しない — touch 押下も記録してから弾く v0.1 同順)
448
+ lastPointerTypeRef.current = event.pointerType
449
+ if (event.pointerType !== "mouse") {
450
+ return // touch を steal すると 6px の死帯が virtualscroll のドラッグスクロールを壊す
451
+ }
452
+ if (event.button !== 0) {
453
+ // 非主ボタン: 既定のフォーカス移動だけ止め、奪わず (stopPropagation なし) 素通し (R2 是正)
454
+ event.preventDefault()
455
+ return
456
+ }
457
+ const hit = resolveHit(axis, event.target)
458
+ if (hit === null) {
459
+ return // 命中なしは選択機械へ (奪わない)
460
+ }
461
+ event.stopPropagation()
462
+ event.preventDefault()
463
+ // 参照ポインティング中 (v0.8 §4.2.10): consume してシンクへ焦点を戻すだけ (挿入なし・ジェスチャなし・
464
+ // commit ゲートも走らない — セッションと部分数式を保つ)
465
+ if (p.isPointing()) {
466
+ p.focusSink()
467
+ return
468
+ }
469
+ // §6.8-3 の第 6 サイト: 編集中は requestCommit ゲート (成功後に開始・失敗で中断)
470
+ if (p.isEditing() && !p.requestCommit()) {
471
+ p.focusSink()
472
+ return
473
+ }
474
+ p.focusSink()
475
+ const axisModel = axis === "col" ? p.colAxis : p.rowAxis
476
+ gestureRef.current = { axis, kind: hit.kind, index: hit.index, initialSize: axisModel.sizeOf(hit.index), startClientX: event.clientX, startClientY: event.clientY }
477
+ pendingSizeRef.current = gestureRef.current.initialSize
478
+ pressButtonsRef.current = event.buttons
479
+ draggingRef.current = true
480
+ // 自機フラグセット後の義務 press repaint (§8.2 の repaint エッジ — fill ハンドル消灯等)
481
+ p.repaintOverlay()
482
+ capturedPointerRef.current = event.pointerId
483
+ capturedBandRef.current = band
484
+ try {
485
+ // jsdom は InvalidPointerId を投げうる — 失敗時は mouse フォールバック (v0.1 両機械と同型)
486
+ band.setPointerCapture(event.pointerId)
487
+ } catch {
488
+ capturedPointerRef.current = null
489
+ }
490
+ const move = onWindowPointerMove
491
+ const up = onWindowPointerUp
492
+ windowListenersRef.current = { move, up }
493
+ window.addEventListener("pointermove", move)
494
+ window.addEventListener("pointerup", up)
495
+ // 押下位置の境界にガイドラインを即時表示
496
+ p.writeResizeGuide({ axis, position: axisModel.offsetOf(hit.index) + pendingSizeRef.current })
497
+ }
498
+
499
+ /** Band dblclick: autofit / unhide-restore (mouse via lastPointerTypeRef — dblclick has no pointerType). / 帯 dblclick (autofit / 復元)。 */
500
+ const makeDblClick = (axis: "row" | "col", band: HTMLElement) => (event: MouseEvent) => {
501
+ const p = paramsRef.current
502
+ if (lastPointerTypeRef.current !== "mouse") {
503
+ return
504
+ }
505
+ // 実ブラウザの dblclick はストリップに届かない: 押下でこの機械が帯へ pointer capture を
506
+ // 取るため up のターゲットは帯へ合成され、click / dblclick のターゲット (down / up の
507
+ // 共通祖先) も帯になる (Pointer Events / UI Events 仕様 — E2E 実証)。event.target が
508
+ // ストリップ外なら座標の実要素で解決し直す。ピッカーは帯の rootNode — shadow DOM ホスト
509
+ // では document 版がホスト要素を返し恒久 null になる (R1(D) 実証)。非 shadow は
510
+ // band.ownerDocument (別 realm 埋め込みでもグローバル document に依存しない)。
511
+ // elementFromPoint 非実装環境 (jsdom — レイアウトなし) は座標解決不能 = 命中なし扱い
512
+ // (throw させると consumer の jsdom テストが帯 dblclick で全滅する — R2(D) 実証)
513
+ const bandRoot = band.getRootNode()
514
+ const picker = bandRoot instanceof ShadowRoot ? bandRoot : band.ownerDocument
515
+ const direct = event.target instanceof Element && event.target.closest(selectorFor(axis)) !== null ? event.target : typeof picker.elementFromPoint === "function" ? picker.elementFromPoint(event.clientX, event.clientY) : null
516
+ const hit = resolveHit(axis, direct)
517
+ if (hit === null) {
518
+ return
519
+ }
520
+ event.stopPropagation()
521
+ // §6.8-3 の編集ゲートは dblclick 経路にも要る (R1(B) 実証): press の requestCommit が
522
+ // 拒否 (applyEdits throw でセッション維持) でも ブラウザは dblclick を合成配送する —
523
+ // 未確定セッションを跨いだ構造変異 (autofit / 復元) を作らない。ここで requestCommit を
524
+ // 再試行はしない: 同一ジェスチャの押下ゲート (2 押下) が既に確定を試みて拒否されたセッションで、
525
+ // 再試行は onEditRejected を 3 発目として重複発火させるだけ (R2 是正 — 1 押下 = 1 拒否)
526
+ if (p.isEditing()) {
527
+ p.focusSink()
528
+ return
529
+ }
530
+ if (hit.kind === "unhide") {
531
+ // 復元 = 既定サイズ (§7.3 — Excel の境界 dblclick autofit とは異なる単純化逸脱)
532
+ applyEntries(axis, [{ index: hit.index, size: axis === "col" ? p.defaultColumnWidth : p.defaultRowHeight }], null)
533
+ return
534
+ }
535
+ if (!p.autofitEnabled) {
536
+ return // renderCell 指定時は autofit 全体 no-op (§10 逸脱)
537
+ }
538
+ if (axis === "row") {
539
+ // 行 autofit = 既定行高への復元 (セルは単一行クリップ描画 — §10 逸脱)。ファンアウトは §7.2 と同じ対象規則
540
+ const targets = collectTargets("row", hit.index)
541
+ if (targets !== null) {
542
+ applyEntries(
543
+ "row",
544
+ targets.map((index) => ({ index, size: p.defaultRowHeight })),
545
+ hit.index,
546
+ )
547
+ }
548
+ return
549
+ }
550
+ autofitColumns(band, hit.index)
551
+ }
552
+
553
+ const onLostPointerCapture = (event: PointerEvent) => {
554
+ // capture 喪失は中断 (pointerId 所有照合つき — v0.1 の両機械と同型。サイズ不変)
555
+ if (!draggingRef.current || capturedPointerRef.current === null || event.pointerId !== capturedPointerRef.current) {
556
+ return
557
+ }
558
+ cleanupDrag()
559
+ }
560
+
561
+ const colDown = makePointerDown("col", colBand)
562
+ const rowDown = makePointerDown("row", rowBand)
563
+ const colDbl = makeDblClick("col", colBand)
564
+ const rowDbl = makeDblClick("row", rowBand)
565
+ // capture: true — 選択機械の bubble リスナーより先に発火し、命中時のみ stopPropagation で奪う
566
+ colBand.addEventListener("pointerdown", colDown, { capture: true })
567
+ rowBand.addEventListener("pointerdown", rowDown, { capture: true })
568
+ colBand.addEventListener("dblclick", colDbl)
569
+ rowBand.addEventListener("dblclick", rowDbl)
570
+ colBand.addEventListener("lostpointercapture", onLostPointerCapture)
571
+ rowBand.addEventListener("lostpointercapture", onLostPointerCapture)
572
+ return () => {
573
+ colBand.removeEventListener("pointerdown", colDown, { capture: true })
574
+ rowBand.removeEventListener("pointerdown", rowDown, { capture: true })
575
+ colBand.removeEventListener("dblclick", colDbl)
576
+ rowBand.removeEventListener("dblclick", rowDbl)
577
+ colBand.removeEventListener("lostpointercapture", onLostPointerCapture)
578
+ rowBand.removeEventListener("lostpointercapture", onLostPointerCapture)
579
+ cleanupDrag()
580
+ }
581
+ }, [enabled, colBandRef, rowBandRef, isOwnPointer, cleanupDrag, commitResize, applyEntries, collectTargets, autofitColumns])
582
+
583
+ return useMemo(
584
+ () => ({
585
+ isDragging: () => draggingRef.current,
586
+ // リサイズ / unhide は選択矩形を配送しない — 配送モードは常に加算
587
+ getDragMode: (): "add" | "subtract" | "point" => "add",
588
+ // ドラッグ中は何も書いていない — 中断 = 消灯のみ (サイズ不変。復元対象なし)
589
+ abortDrag: () => {
590
+ if (draggingRef.current) {
591
+ cleanupDrag()
592
+ }
593
+ },
594
+ cancelDrag: cleanupDrag,
595
+ }),
596
+ [cleanupDrag],
597
+ )
598
+ }