@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,454 @@
1
+ /**
2
+ * @fileoverview Header selection machine — the cell engine's "one-axis-pinned" application
3
+ * (§6.8-1/-2). One shared implementation, transposed by an axis argument; press composes the
4
+ * two reducers into ONE setSelection (no transient 1x1 leaks), header drags drive the
5
+ * axis-limited auto-scroller with span-promoted drag frames. A Ctrl-press on a fully-selected
6
+ * index enters the subtract RESERVATION (§3.3): nothing is written and nothing is delivered
7
+ * until either the up subtracts that index's full span or a pointer move that BOTH really
8
+ * changes the client coordinates AND lands on a different header index converts the gesture
9
+ * into the normal Ctrl append drag (in-index jitter keeps the reservation). Exposes the same isDragging/abortDrag surface as
10
+ * the cell engine so the Escape priority chain (§6.6.1) covers header drags too. Listeners
11
+ * are attached ONCE and read live params through a ref (paramsRef pattern) — effect re-runs
12
+ * mid-drag would call cleanupDrag and kill the drag. v0.8 (plan §4.2.9): while the editor is
13
+ * POINTING, a primary header press inserts the whole-axis reference (`A:A` / `1:1`) into the
14
+ * formula instead of selecting — no commit, no selection write, no drag; a capability param,
15
+ * absent = the v0.7 press path.
16
+ *
17
+ * ヘッダー選択機械 — セル版選択機械の「片軸全域固定」適用 (§6.8-1/-2)。axis 引数で転置する
18
+ * 共通実装。press は reducer 合成の setSelection 1 回 (過渡 1×1 を漏らさない)、ドラッグは
19
+ * 軸制限オートスクローラーをスパン昇格つき buildRange で駆動する。全スパン選択済み index への
20
+ * Ctrl+押下は減算予約 (§3.3) — up で全スパン帯を減算するか、実ポインタ移動で通常の Ctrl 追加
21
+ * ドラッグへ変換するまで無書込・無配送。Escape 優先順 (§6.6.1) がヘッダードラッグにも効くよう、
22
+ * セルエンジンと同じ isDragging / abortDrag 面を公開する。リスナーは 1 回だけ結線し、最新
23
+ * パラメータは ref 経由で読む。v0.8 (計画 §4.2.9): エディタがポインティング中の主ボタン
24
+ * ヘッダー押下は選択でなく全軸参照 (`A:A` / `1:1`) を数式へ挿入する — commit も選択書込も
25
+ * ドラッグも無し。能力 param で、不在 = v0.7 の押下経路。
26
+ */
27
+
28
+ import type { VirtualGridHandle } from "@aiquants/virtualscroll"
29
+ import { type RefObject, useCallback, useEffect, useMemo, useRef } from "react"
30
+ import type { PointingSeams } from "../editor/useCellEditor.ts"
31
+ import type { AxisModel } from "../model/axisModel.ts"
32
+ import { type CellCoord, type CellRange, makeRange } from "../model/cellCoord.ts"
33
+ import { viewportXToCol, viewportYToRow } from "../model/frozenPartition.ts"
34
+ import { reduceSelectionExtend, reduceSelectionPress, reduceSelectionSubtract, type SelectionState } from "../selection/selectionState.ts"
35
+ import { type AutoScroller, SCROLLER_ARM_THRESHOLD } from "../selection/useAutoScroller.ts"
36
+ import type { SelectionEngine } from "../selection/useSelectionEngine.ts"
37
+
38
+ type UseHeaderSelectionParams = {
39
+ /** 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 回だけ除算する。 */
40
+ zoomRef: RefObject<number>
41
+ /** Rewire gate: wiring sleeps while the grid is empty and re-attaches on the non-empty transition (§6.9-1 empty branch). / 空グリッド間は結線を眠らせ、非空遷移で再結線するゲート。 */
42
+ enabled: boolean
43
+ /** Header axis: "col" = column header (full-row span), "row" = row header (full-column span). / "col" = 列ヘッダー (全行スパン)、"row" = 行ヘッダー (全列スパン)。 */
44
+ axis: "col" | "row"
45
+ headerRef: RefObject<HTMLElement | null>
46
+ mainAxis: AxisModel
47
+ crossCount: number
48
+ handleRef: RefObject<VirtualGridHandle | null>
49
+ isEditing: () => boolean
50
+ requestCommit: () => boolean
51
+ getSelection: () => SelectionState
52
+ setSelection: (next: SelectionState) => void
53
+ focusSink: () => void
54
+ onDragFrame: (range: CellRange | null) => void
55
+ /** Axis-limited auto-scroller (columns = "x", rows = "y" — §6.8). / 軸制限つきオートスクローラー。 */
56
+ autoScroller: AutoScroller
57
+ /** §8.2 repaint edges: press-after-flag + cleanup-end. / §8.2 の repaint エッジ (press 直後 + cleanup 末尾)。 */
58
+ repaintOverlay: () => void
59
+ /**
60
+ * v0.8 capability (plan §4.2.9): the editor's pointing seams (live predicate + reference
61
+ * writer — the root wires them through refs because the editor is built after the header
62
+ * machines). Undefined = no pointing press path (v0.7 identity).
63
+ * v0.8 能力 (計画 §4.2.9): エディタのポインティングシーム (live 述語 + 参照書込 — エディタは
64
+ * ヘッダー機械より後に構築されるため root が ref 経由で結線)。undefined = ポインティング押下
65
+ * 経路なし (v0.7 恒等)。
66
+ */
67
+ pointing?: Pick<PointingSeams, "getPointing" | "insertReference">
68
+ }
69
+
70
+ /**
71
+ * Wires column/row header selection onto the band element. Returns the drag surface for the
72
+ * keyboard Escape split (same shape as the cell engine).
73
+ * ヘッダー帯へ列 / 行選択を結線し、Escape 分担用のドラッグ面 (セルエンジンと同形) を返す。
74
+ */
75
+ export const useHeaderSelection = (params: UseHeaderSelectionParams): SelectionEngine => {
76
+ // paramsRef パターン: リスナーは結線 1 回、最新パラメータはレンダー毎にここへ写す
77
+ const paramsRef = useRef(params)
78
+ paramsRef.current = params
79
+
80
+ const draggingRef = useRef(false)
81
+ const dragBeforeRef = useRef<SelectionState | null>(null)
82
+ const anchorIndexRef = useRef(0)
83
+ const headerRectRef = useRef<DOMRect | null>(null)
84
+ const pointerRef = useRef({ clientX: 0, clientY: 0 })
85
+ const dragRafRef = useRef<number | null>(null)
86
+ const capturedPointerRef = useRef<number | null>(null)
87
+ /** `buttons` reported by the press — the primary bit vanishing on a captured move is the chorded primary release (R4). / 押下時の `buttons` (主ビットの消失 = chord 中の主ボタン解放)。 */
88
+ const pressButtonsRef = useRef(0)
89
+ /** Per-drag span-building closure shared by the move rAF and the autoscroller tick (single construction point). / ドラッグ毎のスパン構築クロージャ (move rAF と tick の共有 — 単一構築点)。 */
90
+ const buildRangeRef = useRef<((hover: CellCoord) => CellRange) | null>(null)
91
+ /**
92
+ * Subtract-reservation flag (§3.3): a Ctrl+press on a fully-selected index writes NOTHING
93
+ * at press. Up with the reservation intact = subtract that index's full span; the FIRST
94
+ * hitIndex change WITH a real client-coordinate change converts to a normal Ctrl append
95
+ * drag (deferred press write). While reserved: no autoscroller, no drag-rect delivery.
96
+ * 減算予約フラグ (§3.3): 全スパン選択済み index への Ctrl+press は press 非書込。予約のまま
97
+ * up = 当該 index の全スパン帯を減算。初回 hitIndex 変化 + client 座標の実変化で通常の
98
+ * Ctrl 追加ドラッグへ変換 (遅延 press 書込)。予約中は autoscroller 停止・矩形無配送。
99
+ */
100
+ const subtractReservationRef = useRef(false)
101
+ /** Reserved press index ((a) subtract target / (b) conversion anchor seed). / 予約押下 index ((a) の減算対象 / (b) 変換時の anchor 種)。 */
102
+ const reservedIndexRef = useRef(0)
103
+
104
+ /** Band coordinate to main-axis index (columns add hx, rows add vy). / 帯座標 → 主軸 index (列は hx、行は vy を加算)。 */
105
+ const hitIndex = useCallback((clientX: number, clientY: number): number | null => {
106
+ const { axis, mainAxis, handleRef, zoomRef } = paramsRef.current
107
+ const rect = headerRectRef.current
108
+ if (rect === null || mainAxis.totalSize() === 0) {
109
+ return null
110
+ }
111
+ // ズーム境界 (v0.3): client 差分を z で 1 回だけ除算して論理 px へ。
112
+ // 位置は両軸ともハンドルの同期読み (§7-4 — ドラッグ中オートスクロールでも最新の hx で
113
+ // 着地列が決まる。旧 rAF 写像読みは列ヘッダー帯ドラッグの鮮度非対称の現存証拠だった)
114
+ const z = zoomRef.current
115
+ if (axis === "col") {
116
+ const hx = handleRef.current?.getScrollPosition().x ?? -1
117
+ if (hx < 0) {
118
+ return null
119
+ }
120
+ // 凍結パーティション逆写像 (v0.5 計画 §2) — 凍結ヘッダー帯上の押下は静的座標で解決
121
+ const frozenWidth = handleRef.current?.getFrozenSize().width ?? 0
122
+ return Math.min(Math.max(viewportXToCol(mainAxis, (clientX - rect.left) / z, hx, frozenWidth), 0), mainAxis.count - 1)
123
+ }
124
+ const vy = handleRef.current?.getScrollPosition().y ?? -1
125
+ if (vy < 0) {
126
+ return null
127
+ }
128
+ // 行ヘッダーも凍結パーティション逆写像 (v0.6 計画 §2 — 凍結行ヘッダー帯上の押下は静的解決)
129
+ const frozenHeight = handleRef.current?.getFrozenSize().height ?? 0
130
+ return Math.min(Math.max(viewportYToRow(mainAxis, (clientY - rect.top) / z, vy, frozenHeight), 0), mainAxis.count - 1)
131
+ }, [])
132
+
133
+ /** Index to axis-full span corner cells. / index → 片軸全域スパンの両端セル。 */
134
+ const spanCorners = useCallback((fromIndex: number, toIndex: number): { press: CellCoord; extend: CellCoord } => {
135
+ const { axis, crossCount } = paramsRef.current
136
+ if (axis === "col") {
137
+ return { press: { row: 0, col: fromIndex }, extend: { row: crossCount - 1, col: toIndex } }
138
+ }
139
+ return { press: { row: fromIndex, col: 0 }, extend: { row: toIndex, col: crossCount - 1 } }
140
+ }, [])
141
+
142
+ /**
143
+ * The §3.3 toggle predicate (operational): SOME SINGLE range is a full cross-axis span
144
+ * containing the index. A union-only cover or a partial-span selection stays "add" (v0.1).
145
+ * §3.3 の判定述語 (操作的定義): ある単一レンジが当該 index を含む全交差軸スパンであること。
146
+ * 合併でのみ覆われる場合や部分スパン選択は従来どおり追加。
147
+ */
148
+ const isIndexFullySelected = useCallback((state: SelectionState, index: number): boolean => {
149
+ const { axis, crossCount } = paramsRef.current
150
+ return state.ranges.some((range) =>
151
+ axis === "col" ? range.startRow === 0 && range.endRow === crossCount - 1 && index >= range.startCol && index <= range.endCol : range.startCol === 0 && range.endCol === crossCount - 1 && index >= range.startRow && index <= range.endRow,
152
+ )
153
+ }, [])
154
+
155
+ /** Scroller armed flag (armed after >= SCROLLER_ARM_THRESHOLD px of real movement; press-time self-heal resets it). / スクローラー武装フラグ (累計 SCROLLER_ARM_THRESHOLD px 以上の実移動で武装。押下時自己修復でリセット)。 */
156
+ const scrollerArmedRef = useRef(false)
157
+ /** Press point for the arming distance gate. / 武装距離ゲート用の押下点。 */
158
+ const scrollerPressPointRef = useRef({ clientX: 0, clientY: 0 })
159
+
160
+ /** Idempotent drag cleanup (the cell engine's §6.4.3-8 twin — resets the armed flag too). / 冪等な後始末 (セルエンジン §6.4.3-8 の双子 — 武装フラグもリセット)。 */
161
+ const cleanupDrag = useCallback(() => {
162
+ scrollerArmedRef.current = false
163
+ const { autoScroller, headerRef, onDragFrame } = paramsRef.current
164
+ draggingRef.current = false
165
+ dragBeforeRef.current = null
166
+ buildRangeRef.current = null
167
+ subtractReservationRef.current = false
168
+ reservedIndexRef.current = 0
169
+ if (dragRafRef.current !== null) {
170
+ cancelAnimationFrame(dragRafRef.current)
171
+ dragRafRef.current = null
172
+ }
173
+ autoScroller.stop()
174
+ const header = headerRef.current
175
+ if (header !== null && capturedPointerRef.current !== null && header.hasPointerCapture?.(capturedPointerRef.current)) {
176
+ header.releasePointerCapture(capturedPointerRef.current)
177
+ }
178
+ capturedPointerRef.current = null
179
+ onDragFrame(null)
180
+ // cleanup 末尾の repaint エッジ (§8.2)
181
+ paramsRef.current.repaintOverlay()
182
+ }, [])
183
+
184
+ /** Aborts an in-flight drag, restoring the pre-press state (§6.4.3-8 mirror). / 押下前復元つき中断。 */
185
+ const abortDrag = useCallback(() => {
186
+ if (!draggingRef.current) {
187
+ return
188
+ }
189
+ const before = dragBeforeRef.current
190
+ if (before !== null) {
191
+ paramsRef.current.setSelection(before)
192
+ }
193
+ cleanupDrag()
194
+ }, [cleanupDrag])
195
+
196
+ const { enabled, headerRef } = params
197
+ useEffect(() => {
198
+ const header = headerRef.current
199
+ // enabled は空グリッドの間 false — 非空遷移で deps が変わり、実 DOM が揃った後に再結線される
200
+ if (!enabled || header === null) {
201
+ return
202
+ }
203
+
204
+ const onPointerDown = (event: PointerEvent) => {
205
+ const p = paramsRef.current
206
+ if (event.pointerType !== "mouse") {
207
+ return
208
+ }
209
+ // 非主ボタン (ヘッダー右クリック = ネイティブメニュー素通し — ADR-9) も押下の既定動作
210
+ // (フォーカス移動) だけは止める: 止めないと root group へ逃げたフォーカスで右クリック後の
211
+ // キーボードが全滅する。ネイティブメニューは preventDefault でも届く (R2 是正)
212
+ if (event.button !== 0) {
213
+ event.preventDefault()
214
+ return
215
+ }
216
+ // 予約フラグの掃除は主ボタンゲート直後の最上流 (編集ゲート拒否 / hitIndex null で
217
+ // 中断される押下でも必ず走る — 宙吊りジェスチャ (capture 失敗 + 帯外リリース) の
218
+ // stale true が後続 up を予約分岐へ誤誘導し全スパン選択を Name Box 1×1 へ潰す事故の
219
+ // 閉塞。セル機械の subtractModeRef 掃除と同型)
220
+ subtractReservationRef.current = false
221
+ // ポインティング中のヘッダー押下は A:A / 1:1 の挿入 (Excel — v0.8 §4.2.9)。選択は動かさず
222
+ // commit もしない (能力分岐: 未配線 / 述語不成立は従来の押下経路へ)
223
+ if (p.pointing?.getPointing()) {
224
+ headerRectRef.current = header.getBoundingClientRect()
225
+ const pointedIndex = hitIndex(event.clientX, event.clientY)
226
+ if (pointedIndex === null) {
227
+ return
228
+ }
229
+ event.preventDefault()
230
+ p.focusSink()
231
+ // 交差軸成分は formatRangeReference が読まない (列帯は列、行帯は行だけ) — 0 で座標化
232
+ p.pointing.insertReference(p.axis === "col" ? { row: 0, col: pointedIndex } : { row: pointedIndex, col: 0 }, p.axis === "col" ? "columns" : "rows")
233
+ return
234
+ }
235
+ // 編集中の requestCommit ゲート (§6.8-1 — ヘッダーはフォーカスが動かず focusout が
236
+ // 発火しないため、このゲートが編集確定の唯一の経路)。拒否時は preventDefault で押下の
237
+ // 既定フォーカス移動 (root group へ) を止める — セッション生存中は onRootFocus が
238
+ // 再進入 rearm をしないため、止めないとシンクへ Escape が届かない宙吊りになる
239
+ if (p.isEditing() && !p.requestCommit()) {
240
+ event.preventDefault()
241
+ p.focusSink()
242
+ return
243
+ }
244
+ headerRectRef.current = header.getBoundingClientRect()
245
+ const index = hitIndex(event.clientX, event.clientY)
246
+ if (index === null) {
247
+ return
248
+ }
249
+ event.preventDefault()
250
+ p.focusSink()
251
+ const state = p.getSelection()
252
+ dragBeforeRef.current = state
253
+ const append = event.ctrlKey || event.metaKey
254
+ if (event.shiftKey) {
255
+ // Shift+click: anchor の交差軸成分を全域へ昇格してから extend (部分高さ矩形を作らない —
256
+ // §6.8-1 の anchor 軸全域昇格。転置側は列成分 0 昇格)
257
+ const promotedAnchor: CellCoord = p.axis === "col" ? { row: 0, col: state.anchor.col } : { row: state.anchor.row, col: 0 }
258
+ const { extend } = spanCorners(index, index)
259
+ const promoted: SelectionState = { ...state, anchor: promotedAnchor }
260
+ p.setSelection(reduceSelectionExtend(promoted, extend))
261
+ anchorIndexRef.current = p.axis === "col" ? promotedAnchor.col : promotedAnchor.row
262
+ } else if (append && isIndexFullySelected(state, index)) {
263
+ // 減算予約モード (§3.3): press では書き込まない (単一書込規律の維持)。
264
+ // dragBeforeRef は上で捕捉済み — 変換後の Escape / lostpointercapture 復元が依存
265
+ subtractReservationRef.current = true
266
+ reservedIndexRef.current = index
267
+ anchorIndexRef.current = index
268
+ } else {
269
+ // press は reducer 合成の setSelection 1 回 (2 回呼びは過渡 1×1 が発火判定へ漏れる)
270
+ const { press, extend } = spanCorners(index, index)
271
+ const pressed = reduceSelectionPress(state, press, { extend: false, append })
272
+ p.setSelection(reduceSelectionExtend(pressed, extend))
273
+ anchorIndexRef.current = index
274
+ }
275
+ draggingRef.current = true
276
+ // press 直後の repaint エッジ (§8.2)
277
+ p.repaintOverlay()
278
+ pointerRef.current = { clientX: event.clientX, clientY: event.clientY }
279
+ capturedPointerRef.current = event.pointerId
280
+ pressButtonsRef.current = event.buttons
281
+ try {
282
+ // jsdom は合成イベントの pointerId で InvalidPointerId を投げうる — 実ブラウザでは成功。
283
+ // capture 失敗時も pointermove/up は帯リスナーで受かるため機能劣化は端外 drop のみ
284
+ header.setPointerCapture(event.pointerId)
285
+ } catch {
286
+ capturedPointerRef.current = null
287
+ }
288
+ // buildRange は hover の主軸成分だけを読んで片軸全域スパンへ昇格する (§6.8-1 —
289
+ // オートスクローラーの hover は body 基準のヒットテストで交差軸成分が無意味なため)。
290
+ // move rAF もこの 1 クロージャを共有する (単一構築点 — 二重実装のドリフト防止)
291
+ buildRangeRef.current = (hover) => {
292
+ const hoverIndex = paramsRef.current.axis === "col" ? hover.col : hover.row
293
+ const { press } = spanCorners(anchorIndexRef.current, anchorIndexRef.current)
294
+ const { extend } = spanCorners(anchorIndexRef.current, hoverIndex)
295
+ return makeRange(press, extend)
296
+ }
297
+ // スクローラーは押下では起動しない (累計 4px 以上の実移動で武装 — fill / セル
298
+ // エンジンと同ポリシー。押下起動は左ゾーン内の press だけで毎フレームのドレインに
299
+ // なり、凍結列ヘッダーのクリックが可動帯の勝手な左スクロールに化ける — R5 是正)。
300
+ // 減算予約中の非武装 (§3.3) も同じ「move まで待つ」構造に吸収される (変換は move 時)。
301
+ // 押下時自己修復 (subtractModeRef 規約): 宙吊りジェスチャの旧スクローラーと stale
302
+ // 武装フラグを毎押下で掃除する
303
+ p.autoScroller.stop()
304
+ scrollerArmedRef.current = false
305
+ scrollerPressPointRef.current = { clientX: event.clientX, clientY: event.clientY }
306
+ }
307
+
308
+ /**
309
+ * Whether the event belongs to the pointer that started the drag (mouse-only §6.4.1
310
+ * on all drag phases — see the cell engine's twin for the concurrency rationale).
311
+ * ドラッグを開始したポインタ本人か (mouse 限定契約を全フェーズで保つ — 根拠はセル
312
+ * エンジン側の同名ガード参照)。
313
+ */
314
+ const isOwnPointer = (event: PointerEvent): boolean => (capturedPointerRef.current !== null ? event.pointerId === capturedPointerRef.current : event.pointerType === "mouse")
315
+
316
+ const applyPointerMove = (event: PointerEvent) => {
317
+ if (!(draggingRef.current && isOwnPointer(event))) {
318
+ return
319
+ }
320
+ if (subtractReservationRef.current) {
321
+ // 変換判定 (§3.3): 初回 hitIndex 変化 **かつ** client 座標の実変化のみが引き金。
322
+ // 比較は pointerRef 更新前の既存値と行う — ホイール後にブラウザが静止カーソルへ
323
+ // 配送する合成 pointermove は clientX/Y 不変のため予約を維持する
324
+ const moved = event.clientX !== pointerRef.current.clientX || event.clientY !== pointerRef.current.clientY
325
+ pointerRef.current = { clientX: event.clientX, clientY: event.clientY }
326
+ if (!moved) {
327
+ return
328
+ }
329
+ const hit = hitIndex(event.clientX, event.clientY)
330
+ if (hit === null || hit === reservedIndexRef.current) {
331
+ return // index 不変の実移動は予約維持 (矩形も配送しない)
332
+ }
333
+ // 予約破棄 → 通常の Ctrl 追加ドラッグへ変換: press 時の追加合成を setSelection 1 回で
334
+ // 遅延実行し、autoscroller 起動 + 矩形配送を再開して v0.1 の up 確定フローへ合流
335
+ subtractReservationRef.current = false
336
+ const p = paramsRef.current
337
+ const { press, extend } = spanCorners(reservedIndexRef.current, reservedIndexRef.current)
338
+ const pressed = reduceSelectionPress(p.getSelection(), press, { extend: false, append: true })
339
+ p.setSelection(reduceSelectionExtend(pressed, extend))
340
+ // 変換はスクローラーを即武装しない (R6 是正): トラック境界に近い押下は 1〜2px の
341
+ // ジッタでも hitIndex が変わり変換が成立する — 武装だけは §4.2 の距離ゲートへ委ねる
342
+ // (押下点は予約押下で記録済み。閾値未達なら以後の move の通常ゲートが武装する)
343
+ const buildRange = buildRangeRef.current
344
+ const convDx = Math.abs(event.clientX - scrollerPressPointRef.current.clientX)
345
+ const convDy = Math.abs(event.clientY - scrollerPressPointRef.current.clientY)
346
+ if (buildRange !== null && Math.max(convDx, convDy) >= SCROLLER_ARM_THRESHOLD) {
347
+ scrollerArmedRef.current = true
348
+ p.autoScroller.start({ getPointerPosition: () => pointerRef.current, buildRange, anchorCol: p.axis === "col" ? anchorIndexRef.current : undefined, anchorRow: p.axis === "row" ? anchorIndexRef.current : undefined })
349
+ }
350
+ } else {
351
+ pointerRef.current = { clientX: event.clientX, clientY: event.clientY }
352
+ // 距離ゲートつき武装 (press 起動の廃止 + fill の FILL_ARM_THRESHOLD 双子 —
353
+ // R5 是正: ジッタ 1 発では武装させない)。列ヘッダー機械はアンカー列を
354
+ // ホームテリトリー規則へ渡す (行機械は縦専用で横駆動なし — 省略)
355
+ if (!scrollerArmedRef.current && buildRangeRef.current !== null) {
356
+ const armDx = Math.abs(event.clientX - scrollerPressPointRef.current.clientX)
357
+ const armDy = Math.abs(event.clientY - scrollerPressPointRef.current.clientY)
358
+ if (Math.max(armDx, armDy) >= SCROLLER_ARM_THRESHOLD) {
359
+ scrollerArmedRef.current = true
360
+ paramsRef.current.autoScroller.start({
361
+ getPointerPosition: () => pointerRef.current,
362
+ buildRange: buildRangeRef.current,
363
+ anchorCol: paramsRef.current.axis === "col" ? anchorIndexRef.current : undefined,
364
+ anchorRow: paramsRef.current.axis === "row" ? anchorIndexRef.current : undefined,
365
+ })
366
+ }
367
+ }
368
+ }
369
+ if (dragRafRef.current === null) {
370
+ dragRafRef.current = requestAnimationFrame(() => {
371
+ dragRafRef.current = null
372
+ const buildRange = buildRangeRef.current
373
+ if (!draggingRef.current || buildRange === null) {
374
+ return
375
+ }
376
+ // hitIndex は主軸 index — buildRange は主軸成分しか読まないため転置座標で包んで渡す
377
+ const hover = hitIndex(pointerRef.current.clientX, pointerRef.current.clientY)
378
+ if (hover !== null) {
379
+ paramsRef.current.onDragFrame(buildRange(paramsRef.current.axis === "col" ? { row: 0, col: hover } : { row: hover, col: 0 }))
380
+ }
381
+ })
382
+ }
383
+ }
384
+
385
+ const onPointerMove = (event: PointerEvent) => {
386
+ applyPointerMove(event)
387
+ // chord 中の主ボタン解放 (R4 是正 — セル機械と同型): 主ビットを失った own move で確定する
388
+ if (draggingRef.current && isOwnPointer(event) && (pressButtonsRef.current & 1) !== 0 && (event.buttons & 1) === 0) {
389
+ confirmAt(event)
390
+ }
391
+ }
392
+
393
+ const onPointerUp = (event: PointerEvent) => {
394
+ // 主ボタンのリリースのみ確定 (非主ボタンの up は同一 pointerId で isOwnPointer を通過
395
+ // してしまう — セル機械と同じ閉塞。右リリースでの早期確定 / 宙吊り誤確定の両遮断)
396
+ if (event.button !== 0 || !(draggingRef.current && isOwnPointer(event))) {
397
+ return
398
+ }
399
+ confirmAt(event)
400
+ }
401
+
402
+ /** Confirms the in-flight gesture at the event position (primary release — pointerup or chorded move). / 飛行中ジェスチャの確定 (主ボタン解放 — pointerup または chord 中の move)。 */
403
+ const confirmAt = (event: PointerEvent) => {
404
+ const p = paramsRef.current
405
+ if (subtractReservationRef.current) {
406
+ // (a) 予約のまま up → 押下 index の全スパン帯を減算 (書込 1 回)。判定は up 時点の
407
+ // 予約フラグ — ホイールで hitIndex が動いても予約は維持され押下 index を減算する
408
+ // (§3.3)。pressCell = Name Box セル ({0, col} / {row, 0}) = スパンの press 隅
409
+ const { press, extend } = spanCorners(reservedIndexRef.current, reservedIndexRef.current)
410
+ p.setSelection(reduceSelectionSubtract(p.getSelection(), makeRange(press, extend), press))
411
+ cleanupDrag()
412
+ return
413
+ }
414
+ const hover = hitIndex(event.clientX, event.clientY)
415
+ if (hover !== null) {
416
+ const { extend } = spanCorners(anchorIndexRef.current, hover)
417
+ p.setSelection(reduceSelectionExtend(p.getSelection(), extend))
418
+ }
419
+ cleanupDrag()
420
+ }
421
+
422
+ const onLostPointerCapture = (event: PointerEvent) => {
423
+ // capture 喪失 (ウィンドウ外 drop 等) は押下前状態へ復元 (§6.4.3-8 のミラー)。所有
424
+ // pointerId 限定 — 他ポインタ (touch 等) の capture 解放で巻き戻さない
425
+ if (!draggingRef.current || capturedPointerRef.current === null || event.pointerId !== capturedPointerRef.current) {
426
+ return
427
+ }
428
+ abortDrag()
429
+ }
430
+
431
+ header.addEventListener("pointerdown", onPointerDown)
432
+ header.addEventListener("pointermove", onPointerMove)
433
+ header.addEventListener("pointerup", onPointerUp)
434
+ header.addEventListener("lostpointercapture", onLostPointerCapture)
435
+ return () => {
436
+ header.removeEventListener("pointerdown", onPointerDown)
437
+ header.removeEventListener("pointermove", onPointerMove)
438
+ header.removeEventListener("pointerup", onPointerUp)
439
+ header.removeEventListener("lostpointercapture", onLostPointerCapture)
440
+ cleanupDrag()
441
+ }
442
+ }, [enabled, headerRef, hitIndex, spanCorners, cleanupDrag, abortDrag])
443
+
444
+ return useMemo(
445
+ () => ({
446
+ isDragging: () => draggingRef.current,
447
+ // ヘッダー面の減算は予約方式で矩形を配送しない (§3.3) — 配送モードは常に加算
448
+ getDragMode: (): "add" | "subtract" | "point" => "add",
449
+ abortDrag,
450
+ cancelDrag: cleanupDrag,
451
+ }),
452
+ [abortDrag, cleanupDrag],
453
+ )
454
+ }
package/src/index.ts ADDED
@@ -0,0 +1,122 @@
1
+ /**
2
+ * @aiquants/spreadsheet
3
+ *
4
+ * Excel-grade spreadsheet grid for React on top of @aiquants/virtualscroll.
5
+ * @aiquants/virtualscroll 上に構築する React 用 Excel 級スプレッドシートグリッド。
6
+ *
7
+ * The public surface (§6.11 + the named unions SpreadsheetDirection / SpreadsheetOperation,
8
+ * extended by the v0.8 rich-cell vocabulary — plan §1.11). Internal hooks
9
+ * (useSelectionEngine / useGridKeyboard / useCellEditor / useClipboard / useCommands /
10
+ * usePopovers etc.) stay private — exporting them would freeze their parameter shapes. The
11
+ * pure engines (clipboard TSV / payload / HTML interop, cell format canonicalisation,
12
+ * validation, formula reference rewriting, chord parsing, labels) are exported as the reuse
13
+ * surface for consumers building their own paste UIs, toolbars, import features or popovers.
14
+ *
15
+ * Nameability rule (spec 00 §5): every own named type that an exported declaration reaches —
16
+ * parameter / return annotations, union arms, property types — is exported as well, so a
17
+ * consumer can spell it by name instead of extracting it with `Awaited<ReturnType<>>` /
18
+ * indexed access / `Extract<>` (v0.7 precedent ScrollToCellOptions / EditRejection).
19
+ * api-extractor rolls an unexported referenced type up as a bare local `declare type`, so the
20
+ * build stays green while the name is unreachable from the package (TS2459) — `index.spec.ts`
21
+ * is the machine gate. The single exception is the test-only seam SpreadsheetTestProps
22
+ * (spec 00 §5.2).
23
+ *
24
+ * 公開面 (§6.11 + 名前つき union SpreadsheetDirection / SpreadsheetOperation に v0.8 のリッチセル
25
+ * 語彙を加えたもの — 計画 §1.11)。内部フック (useSelectionEngine / useGridKeyboard / useCellEditor /
26
+ * useClipboard / useCommands / usePopovers 等) は公開しない — 公開した瞬間に引数形が凍結される。
27
+ * 純関数エンジン (クリップボードの TSV / payload / HTML 相互運用、書式の正準化、検証、数式参照の
28
+ * 書換え、チョード解析、ラベル) はコンシューマーが独自の貼り付け UI・ツールバー・インポート機能・
29
+ * ポップオーバーを組むための再利用面として公開する。
30
+ *
31
+ * 名指し可能性の規則 (分冊 00 §5): 公開宣言から到達する自前の名前つき型 (引数 / 戻り値注釈、union の
32
+ * 腕、プロパティ型) は全て export する — consumer が `Awaited<ReturnType<>>` / indexed access /
33
+ * `Extract<>` 抽出を強いられない (v0.7 の ScrollToCellOptions / EditRejection の前例)。api-extractor は
34
+ * 未公開の参照型をローカル `declare type` として同梱するのでビルドは緑のまま名前だけが到達不能
35
+ * (TS2459) になる — 機械ゲートは `index.spec.ts`。唯一の例外はテスト専用シーム SpreadsheetTestProps
36
+ * (分冊 00 §5.2)。
37
+ */
38
+
39
+ export { type ClipCell, type ClipChannel, type ClipComment, type ClipFlavours, type ClipPayload, type ClipSource, detectClipSource, parseClipPayload, serializeClipPayload } from "./clipboard/clipPayload.ts"
40
+ export { type HtmlTableKind, type HtmlTableResult, parseHtmlTable, serializeHtmlTable } from "./clipboard/htmlInterop.ts"
41
+ // parseHtmlTable の第 3 引数型 — 復号器のホスト宣言 (codec 本体は非公開のまま、引数型だけ名指しできるようにする)
42
+ export type { NumberFormatDecodeOptions } from "./clipboard/numberFormatCodec.ts"
43
+ export type { PasteMode, PasteOperation, PasteOptions } from "./clipboard/pasteSpecial.ts"
44
+ export { estimatePasteCellCount, expandPaste } from "./clipboard/tiling.ts"
45
+ export { parseTsv, serializeTsv } from "./clipboard/tsv.ts"
46
+ export type { ClipInfo, PasteOutcome } from "./clipboard/useClipboard.ts"
47
+ export type { BorderPreset } from "./commands/useCommands.ts"
48
+ // renderCell の ctx 型 — prop が公開面にある以上、その引数型も名指しできなければならない
49
+ // (未公開だと consumer は Parameters<...> 抽出を強いられる)。型のみの追加で非破壊
50
+ export type { RenderCellContext } from "./grid/CellView.tsx"
51
+ export { columnIndexToName, columnNameToIndex } from "./header/columnName.ts"
52
+ export { DEFAULT_SHORTCUTS, type ParsedChord, parseChord, type ShortcutMap, type ShortcutOverrides, SPREADSHEET_RESERVED_SHORTCUTS, type SpreadsheetCommand } from "./keyboard/shortcuts.ts"
53
+ export { type CellComment, type CellCommentInput, normalizeHyperlink } from "./model/cellAnnotations.ts"
54
+ export { type CellCoord, type CellRange, enumerateRangeCells, makeRange, rangeContains, rangeSize, type SpreadsheetDirection, type SpreadsheetOperation } from "./model/cellCoord.ts"
55
+ // 表示形式 (v0.7) + リッチ書式 (v0.8): getCellFormat 能力の型 + 純関数エンジン (整形 / 正準化 / 合成 —
56
+ // 組み込みだが consumer が直接呼べるよう公開: renderCell 内の再利用、ツールバー、独自整形の基盤)
57
+ export {
58
+ BORDER_DEFAULT_COLOR,
59
+ type CellAlign,
60
+ type CellBorder,
61
+ type CellBorderStyle,
62
+ type CellBorders,
63
+ type CellFormat,
64
+ type CellFormatPatch,
65
+ type CellValueFormat,
66
+ type CellVerticalAlign,
67
+ canonicalizeFormat,
68
+ formatCellValue,
69
+ formatEquals,
70
+ mergeFormat,
71
+ } from "./model/cellFormat.ts"
72
+ // 条件付き書式の語彙 — 規則 / 入力 / スタイルに加え、CfCondition の 6 つの腕と text 条件の op 語彙も名指しできる
73
+ export type {
74
+ CellValueCondition,
75
+ CfCondition,
76
+ CfTextOp,
77
+ ColorScaleCondition,
78
+ CompareOp,
79
+ ConditionalFormatRule,
80
+ ConditionalFormatRuleInput,
81
+ ConditionalFormatStyle,
82
+ CustomFormulaCondition,
83
+ EmptyCondition,
84
+ FormulaValue,
85
+ NotEmptyCondition,
86
+ RangeExtrema,
87
+ ScalePoint,
88
+ TextCondition,
89
+ } from "./model/conditionalFormat.ts"
90
+ export type { CellEdit, CellPatch, EditContext, EditRejection, EditSource, PasteEditContext, PlainEditContext, SpreadsheetDataAdapter } from "./model/dataAdapter.ts"
91
+ // データ検証の語彙 — 規則 union / 判定に加え、共通オプション・7 つの腕・op 語彙・validateCellText の評価器型も名指しできる
92
+ export {
93
+ type CheckboxValidationRule,
94
+ type CustomValidationRule,
95
+ type DataValidationRule,
96
+ type DateValidationRule,
97
+ type ListValidationRule,
98
+ type NumberValidationRule,
99
+ type TextLengthValidationRule,
100
+ type TextValidationRule,
101
+ type ValidationCompareOp,
102
+ type ValidationEvaluate,
103
+ type ValidationRuleBase,
104
+ type ValidationTextOp,
105
+ type ValidationVerdict,
106
+ validateCellText,
107
+ } from "./model/dataValidation.ts"
108
+ export { fromR1C1, shiftFormula, toR1C1 } from "./model/formulaRefs.ts"
109
+ export { DEFAULT_LABELS, resolveLabel, type SpreadsheetLabelOverrides, type SpreadsheetLabels } from "./model/labels.ts"
110
+ export { DEFAULT_HIGHLIGHT_COLORS, type HighlightColors, type HighlightState } from "./overlay/SelectionOverlay.tsx"
111
+ // 既定ポップオーバー 6 種 — renderPopover の部品 (kind 別に差し替え、残りは既定を組み合わせる)
112
+ export { CommentPopover, type CommentPopoverProps } from "./popover/CommentPopover.tsx"
113
+ export { LinkPopover, type LinkPopoverProps } from "./popover/LinkPopover.tsx"
114
+ export { ListPopover, type ListPopoverProps } from "./popover/ListPopover.tsx"
115
+ export { MessagePopover, type MessagePopoverProps } from "./popover/MessagePopover.tsx"
116
+ export { NotePopover, type NotePopoverProps } from "./popover/NotePopover.tsx"
117
+ export { NoteTip, type NoteTipProps } from "./popover/NoteTip.tsx"
118
+ // PopoverContext.anchorRect の型 (層座標系の矩形) — 配置エンジン placePopover 本体は非公開のまま
119
+ export type { PopoverRect } from "./popover/placement.ts"
120
+ export type { CommentPopoverContext, LinkPopoverContext, ListPopoverContext, MessagePopoverContext, NotePopoverContext, NoteTipContext, PopoverContext, PopoverKind, PopoverOverrides } from "./popover/usePopovers.ts"
121
+ export { DEFAULT_CUSTOM_COLORS, type ScrollToCellOptions, Spreadsheet, type SpreadsheetHandle, type SpreadsheetProps } from "./Spreadsheet.tsx"
122
+ export type { SelectionState } from "./selection/selectionState.ts"
@@ -0,0 +1,53 @@
1
+ /**
2
+ * @fileoverview IME composition keydown guard (v0.8 §4.1.10) — the single source of truth
3
+ * for the "must this keydown be ignored?" question that the grid keymap and every popover
4
+ * keydown handler ask first. Two clauses, extracted verbatim from useGridKeyboard: (1) a
5
+ * keydown that arrives DURING composition (isComposing, or the legacy keyCode 229 that
6
+ * Chromium / Safari deliver for IME-processed keys) is never a command; (2) Safari fires
7
+ * compositionend BEFORE the confirming Enter / Escape keydown, so those two keys are skipped
8
+ * inside a 50 ms window after the last compositionend. The window is deliberately limited to
9
+ * Enter / Escape — applying it to every key would eat the first post-confirmation keystroke on
10
+ * Chrome / Firefox, a measured regression.
11
+ *
12
+ * IME 変換中 keydown ガード (v0.8 §4.1.10) — グリッドキーマップと全ポップオーバーの keydown が
13
+ * 冒頭で問う「この keydown は無視すべきか」の単一真実源。useGridKeyboard から逐語抽出した 2 条:
14
+ * (1) 変換中の keydown (isComposing、または Chromium / Safari が IME 処理キーへ与えるレガシー
15
+ * keyCode 229) はコマンドではない、(2) Safari は確定 Enter / Escape の keydown より先に
16
+ * compositionend を発火するため、直近 compositionend から 50 ms 窓内の Enter / Escape をスキップ。
17
+ * 窓の対象を Enter / Escape に限定するのは必須 — 全キー対象にすると Chrome / Firefox で確定後の
18
+ * 1 打鍵を食う退行 (実測)。
19
+ */
20
+
21
+ /** Safari compositionend→keydown skip window in ms. / Safari 順序差スキップ窓 (ms)。 */
22
+ export const COMPOSITION_SKIP_WINDOW_MS = 50
23
+
24
+ /**
25
+ * Guard-clear insurance timer in ms: the compositionend stamp is dropped after this delay
26
+ * (a setTimeout(0) clear is task-boundary dependent and can erase the stamp before the
27
+ * skipped keydown arrives). Must exceed COMPOSITION_SKIP_WINDOW_MS.
28
+ * ガード解除の保険タイマ (ms)。compositionend の刻印はこの遅延後に破棄 (setTimeout(0) は
29
+ * タスク境界依存でスキップ対象の keydown より先に刻印が消え得る)。COMPOSITION_SKIP_WINDOW_MS
30
+ * より長いことが必須。
31
+ */
32
+ export const COMPOSITION_CLEAR_MS = 100
33
+
34
+ /**
35
+ * True when the keydown must be ignored: isComposing / keyCode 229, or Enter / Escape within
36
+ * COMPOSITION_SKIP_WINDOW_MS after the last compositionend (`compositionEndedAt` is that
37
+ * event's timeStamp, or null when no compositionend is pending). Pure — reads only the event.
38
+ * 無視すべき keydown なら true: isComposing / keyCode 229、または直近 compositionend
39
+ * (`compositionEndedAt` = その timeStamp、未保留なら null) から COMPOSITION_SKIP_WINDOW_MS
40
+ * 以内の Enter / Escape。純関数 — event しか読まない。
41
+ */
42
+ export const isCompositionKeydown = (event: KeyboardEvent, compositionEndedAt: number | null): boolean => {
43
+ // 変換中の keydown と IME 処理キー (keyCode 229) は無条件に無視 (§6.6.2-1 の冒頭ガード)
44
+ if (event.isComposing || event.keyCode === 229) {
45
+ return true
46
+ }
47
+ // Safari 順序差: compositionend 直後 (< 50ms) の Enter / Escape のみスキップ (対象限定必須 —
48
+ // 全キー対象にすると Chrome / Firefox で確定後の 1 打鍵を食う退行になる)
49
+ if ((event.key === "Enter" || event.key === "Escape") && compositionEndedAt !== null) {
50
+ return event.timeStamp - compositionEndedAt < COMPOSITION_SKIP_WINDOW_MS
51
+ }
52
+ return false
53
+ }