@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,273 @@
1
+ /**
2
+ * @fileoverview Reference-drag owner for formula pointing (v0.8 plan §4.1.18 / design v5) — a
3
+ * DISTINCT drag owner beside useFillHandle with its own draggingRef, buildRange, pointer
4
+ * capture and autoscroller context. The cell engine consults `onPointerDown` BEFORE its
5
+ * commit gate; an accepted press inserts the pointed cell into the formula, captures the
6
+ * pointer on the body and thereafter replaces that token with the rectangle `press ∪ hover`
7
+ * on every rAF-coalesced move and every autoscroller tick (ONE construction point shared by
8
+ * both writers). The selection, anchor and active cell are frozen: this owner has no
9
+ * setSelection seam at all. Escape (`abortDrag`) restores the sink snapshot taken before the
10
+ * press — the token reverts, the editing session stays (cancelEdit is never called); the
11
+ * primary release / capture loss keep the last replaced token (`cleanupDrag`). The drag rect
12
+ * is painted through the root's onDragFrame relay, which reads the composite drag mode
13
+ * (`"point"` while this owner is in flight).
14
+ *
15
+ * 数式ポインティングの参照ドラッグ所有者 (v0.8 計画 §4.1.18 / 設計 v5) — useFillHandle の兄弟
16
+ * となる独立所有者で、自前の draggingRef / buildRange / pointer capture / autoscroller
17
+ * コンテキストを持つ。セルエンジンは commit ゲートより**先**に `onPointerDown` に諮る。受理
18
+ * された押下は指したセルを数式へ挿入し body へ capture、以後は rAF 合流の move と autoscroller
19
+ * の tick (両書き手が共有する単一構築点) のたびに矩形 `押下 ∪ hover` でそのトークンを置換する。
20
+ * 選択 / anchor / active は凍結 — この所有者は setSelection シームをそもそも持たない。Escape
21
+ * (`abortDrag`) は押下前のシンク断面を復元 (トークンは戻り、セッションは残る — cancelEdit は
22
+ * 呼ばない)。主ボタン解放 / capture 喪失は最後に置換したトークンを残す (`cleanupDrag`)。矩形は
23
+ * root の onDragFrame リレー経由で描く (リレーは合成ドラッグモードを読み、飛行中は "point")。
24
+ */
25
+
26
+ import type { VirtualGridHandle } from "@aiquants/virtualscroll"
27
+ import { type RefObject, useCallback, useEffect, useMemo, useRef } from "react"
28
+ import type { SinkSnapshot } from "../editor/useCellEditor.ts"
29
+ import type { AxisModel } from "../model/axisModel.ts"
30
+ import { type CellCoord, type CellRange, makeRange } from "../model/cellCoord.ts"
31
+ import type { ReferenceAxis } from "../model/formulaRefs.ts"
32
+ import { type AutoScroller, SCROLLER_ARM_THRESHOLD } from "./useAutoScroller.ts"
33
+ import { hitTestCell, type SelectionEngine } from "./useSelectionEngine.ts"
34
+
35
+ type UsePointDragParams = {
36
+ /** 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 回だけ除算する。 */
37
+ zoomRef: RefObject<number>
38
+ /** Rewire gate (empty grid) — also refuses presses while false. / 再結線ゲート (空グリッド) — false の間は押下も拒む。 */
39
+ enabled: boolean
40
+ bodyRef: RefObject<HTMLElement | null>
41
+ handleRef: RefObject<VirtualGridHandle | null>
42
+ rowAxis: AxisModel
43
+ colAxis: AxisModel
44
+ /** The editor's live pointing predicate (evaluated at the press — never cached here). / エディタの live ポインティング述語 (押下時に評価 — ここではキャッシュしない)。 */
45
+ getPointing: () => boolean
46
+ /** The editor's reference writer (insert at press, replace on every tick). / エディタの参照書込 (押下で挿入、tick ごとに置換)。 */
47
+ insertReference: (target: CellCoord | CellRange, axis: ReferenceAxis) => void
48
+ /** Sink snapshot taken before the press (null = no sink ⇒ the press is refused). / 押下前のシンク断面 (null = シンク不在 ⇒ 押下拒否)。 */
49
+ captureSink: () => SinkSnapshot | null
50
+ /** Escape restore of the pre-press snapshot. / Escape での押下前断面の復元。 */
51
+ restoreSink: (snapshot: SinkSnapshot) => void
52
+ focusSink: () => void
53
+ /** The dedicated both-axis edge scroller (the root's 6th useAutoScroller, writer = onDragFrame). / 専用の両軸端スクローラー (root の 6 台目、writer は onDragFrame)。 */
54
+ autoScroller: AutoScroller
55
+ /** Drag-rect writer relay (null hides the resident node); the relay stamps the composite mode. / ドラッグ矩形 writer リレー (null = 非表示)。モードはリレーが合成面から刻む。 */
56
+ onDragFrame: (range: CellRange | null) => void
57
+ }
58
+
59
+ /** The owner surface: the engine's composite shape plus the press entry the engine consults. / 所有者の面: エンジン合成面 + エンジンが諮る押下入口。 */
60
+ export type PointDragOwner = SelectionEngine & {
61
+ /** Consulted by the cell engine before its commit gate; true = the press was consumed (selection frozen). / セルエンジンが commit ゲート前に諮る。true = 押下を消費 (選択凍結)。 */
62
+ onPointerDown: (event: PointerEvent) => boolean
63
+ }
64
+
65
+ /**
66
+ * Wires the reference-drag owner onto the body element (move / up / capture-loss listeners
67
+ * attach once; the press entry is called by the engine).
68
+ * body 要素へ参照ドラッグ所有者を結線する (move / up / capture 喪失リスナーは 1 回結線、押下
69
+ * 入口はエンジンが呼ぶ)。
70
+ */
71
+ export const usePointDrag = (params: UsePointDragParams): PointDragOwner => {
72
+ // paramsRef パターン: リスナーは結線 1 回、最新パラメータはレンダー毎にここへ写す
73
+ const paramsRef = useRef(params)
74
+ paramsRef.current = params
75
+
76
+ const draggingRef = useRef(false)
77
+ /** Sink snapshot taken at the press (the Escape restore truth). / 押下時のシンク断面 (Escape 復元の真実)。 */
78
+ const snapshotRef = useRef<SinkSnapshot | null>(null)
79
+ const bodyRectRef = useRef<DOMRect | null>(null)
80
+ const pointerRef = useRef({ clientX: 0, clientY: 0 })
81
+ /** Press point for the scroller arming distance gate (the engine's twin). / スクローラー武装距離ゲート用の押下点 (エンジンの双子)。 */
82
+ const pressPointRef = useRef({ clientX: 0, clientY: 0 })
83
+ const dragRafRef = useRef<number | null>(null)
84
+ const capturedPointerRef = useRef<number | null>(null)
85
+ /** `buttons` reported by the press — the primary bit vanishing on a captured move is the chorded primary release (R4). / 押下時の `buttons` (主ビットの消失 = chord 中の主ボタン解放)。 */
86
+ const pressButtonsRef = useRef(0)
87
+ /** Per-drag range-building closure shared by the move rAF and the autoscroller tick (single construction point — it also performs the token replacement). / ドラッグ毎の矩形構築クロージャ (move rAF と tick の共有 — 単一構築点。トークン置換もここで行う)。 */
88
+ const buildRangeRef = useRef<((hover: CellCoord) => CellRange) | null>(null)
89
+ /** Pressed (pointed) cell — the rectangle anchor and the frozen home-territory anchor. / 押下 (指した) セル — 矩形の起点かつ凍結ホームテリトリーのアンカー。 */
90
+ const originRef = useRef<CellCoord | null>(null)
91
+ const scrollerArmedRef = useRef(false)
92
+
93
+ /** The engine's shared hit test over this owner's press-cached rect (ONE mapping — plan §4.1.18). / エンジンと共有のヒットテスト (当所有者の押下時計測 rect 上 — 単一写像、計画 §4.1.18)。 */
94
+ const hitTest = useCallback((clientX: number, clientY: number): CellCoord | null => {
95
+ const { handleRef, rowAxis, colAxis, zoomRef } = paramsRef.current
96
+ return hitTestCell(handleRef.current, bodyRectRef.current, rowAxis, colAxis, zoomRef.current, clientX, clientY)
97
+ }, [])
98
+
99
+ /** Idempotent cleanup: flags / snapshot / rAF / scroller / capture / drag rect — the last replaced token stays. / 冪等な後始末: フラグ / 断面 / rAF / スクローラー / capture / 矩形 — 最後に置換したトークンは残る。 */
100
+ const cleanupDrag = useCallback(() => {
101
+ const { autoScroller, bodyRef, onDragFrame } = paramsRef.current
102
+ draggingRef.current = false
103
+ snapshotRef.current = null
104
+ buildRangeRef.current = null
105
+ originRef.current = null
106
+ scrollerArmedRef.current = false
107
+ bodyRectRef.current = null
108
+ if (dragRafRef.current !== null) {
109
+ cancelAnimationFrame(dragRafRef.current)
110
+ dragRafRef.current = null
111
+ }
112
+ autoScroller.stop()
113
+ const body = bodyRef.current
114
+ if (body !== null && capturedPointerRef.current !== null && body.hasPointerCapture?.(capturedPointerRef.current)) {
115
+ body.releasePointerCapture(capturedPointerRef.current)
116
+ }
117
+ capturedPointerRef.current = null
118
+ // 参照矩形を hidden へ (常設ノード)
119
+ onDragFrame(null)
120
+ }, [])
121
+
122
+ /** Escape: restores the pre-press sink (token reverts) and cleans up — the session stays. / Escape: 押下前シンクを復元 (トークンが戻る) して後始末 — セッションは残る。 */
123
+ const abortDrag = useCallback(() => {
124
+ if (!draggingRef.current) {
125
+ return
126
+ }
127
+ const snapshot = snapshotRef.current
128
+ if (snapshot !== null) {
129
+ paramsRef.current.restoreSink(snapshot)
130
+ }
131
+ cleanupDrag()
132
+ }, [cleanupDrag])
133
+
134
+ const onPointerDown = useCallback(
135
+ (event: PointerEvent): boolean => {
136
+ const p = paramsRef.current
137
+ const body = p.bodyRef.current
138
+ // 述語はここでその場評価 (getCellFormula 未宣言なら必ず false = v0.7 恒等でエンジンへ素通し)
139
+ if (!p.enabled || body === null || !p.getPointing()) {
140
+ return false
141
+ }
142
+ // bodyRect は押下で 1 回計測しドラッグ中はキャッシュ (§6.4.3-3)
143
+ bodyRectRef.current = body.getBoundingClientRect()
144
+ const cell = hitTest(event.clientX, event.clientY)
145
+ if (cell === null) {
146
+ bodyRectRef.current = null
147
+ return false
148
+ }
149
+ // 断面はシンク不在なら取れない = セッション無し — 押下はエンジンへ返す
150
+ const snapshot = p.captureSink()
151
+ if (snapshot === null) {
152
+ bodyRectRef.current = null
153
+ return false
154
+ }
155
+ // 受理: 既定動作 (フォーカス移動 / テキスト選択) を止めシンクを保持したまま参照を挿入
156
+ event.preventDefault()
157
+ p.focusSink()
158
+ snapshotRef.current = snapshot
159
+ originRef.current = cell
160
+ p.insertReference(cell, "cells")
161
+ draggingRef.current = true
162
+ pointerRef.current = { clientX: event.clientX, clientY: event.clientY }
163
+ pressPointRef.current = { clientX: event.clientX, clientY: event.clientY }
164
+ capturedPointerRef.current = event.pointerId
165
+ pressButtonsRef.current = event.buttons
166
+ try {
167
+ // jsdom は合成イベントの pointerId で InvalidPointerId を投げうる — 実ブラウザでは成功。
168
+ // capture 失敗時も pointermove/up は body リスナーで受かるため機能劣化は端外 drop のみ
169
+ body.setPointerCapture(event.pointerId)
170
+ } catch {
171
+ capturedPointerRef.current = null
172
+ }
173
+ // 単一構築点: 矩形 = 押下セル ∪ hover。置換もここで行う (move rAF と tick が共有)
174
+ buildRangeRef.current = (hover) => {
175
+ const range = makeRange(cell, hover)
176
+ paramsRef.current.insertReference(range, "cells")
177
+ return range
178
+ }
179
+ // 押下時自己修復 (エンジンと同規約): 宙吊りジェスチャの旧スクローラーと stale 武装フラグを掃除
180
+ p.autoScroller.stop()
181
+ scrollerArmedRef.current = false
182
+ // 押下直後の参照矩形 (1×1) を描く
183
+ p.onDragFrame(makeRange(cell, cell))
184
+ return true
185
+ },
186
+ [hitTest],
187
+ )
188
+
189
+ const { enabled, bodyRef } = params
190
+ useEffect(() => {
191
+ const body = bodyRef.current
192
+ if (!enabled || body === null) {
193
+ return
194
+ }
195
+
196
+ /** Whether the event belongs to the pointer that started the drag (mouse-only on all phases — engine twin). / ドラッグを開始したポインタ本人か (全フェーズ mouse 限定 — エンジンの双子)。 */
197
+ const isOwnPointer = (event: PointerEvent): boolean => (capturedPointerRef.current !== null ? event.pointerId === capturedPointerRef.current : event.pointerType === "mouse")
198
+
199
+ const onPointerMove = (event: PointerEvent) => {
200
+ if (!(draggingRef.current && isOwnPointer(event))) {
201
+ return
202
+ }
203
+ pointerRef.current = { clientX: event.clientX, clientY: event.clientY }
204
+ // 距離ゲートつき武装 (エンジンと同じ SCROLLER_ARM_THRESHOLD — ジッタ 1 発では武装しない)
205
+ const buildRange = buildRangeRef.current
206
+ const origin = originRef.current
207
+ if (!scrollerArmedRef.current && buildRange !== null && origin !== null) {
208
+ const armDx = Math.abs(event.clientX - pressPointRef.current.clientX)
209
+ const armDy = Math.abs(event.clientY - pressPointRef.current.clientY)
210
+ if (Math.max(armDx, armDy) >= SCROLLER_ARM_THRESHOLD) {
211
+ scrollerArmedRef.current = true
212
+ paramsRef.current.autoScroller.start({ getPointerPosition: () => pointerRef.current, buildRange, anchorCol: origin.col, anchorRow: origin.row })
213
+ }
214
+ }
215
+ // rAF 合流で矩形 + トークン置換 (React 未介在)
216
+ if (dragRafRef.current === null) {
217
+ dragRafRef.current = requestAnimationFrame(() => {
218
+ dragRafRef.current = null
219
+ const build = buildRangeRef.current
220
+ if (!draggingRef.current || build === null) {
221
+ return
222
+ }
223
+ const hover = hitTest(pointerRef.current.clientX, pointerRef.current.clientY)
224
+ if (hover !== null) {
225
+ paramsRef.current.onDragFrame(build(hover))
226
+ }
227
+ })
228
+ }
229
+ // chord 中の主ボタン解放 (R4 — エンジンと同型): 主ビットを失った own move は解放 = 確定
230
+ if ((pressButtonsRef.current & 1) !== 0 && (event.buttons & 1) === 0) {
231
+ cleanupDrag()
232
+ }
233
+ }
234
+
235
+ const onPointerUp = (event: PointerEvent) => {
236
+ // 主ボタンのリリースのみ確定 (最後の tick が既にトークンを置換済み — 書込は無い)
237
+ if (event.button !== 0 || !(draggingRef.current && isOwnPointer(event))) {
238
+ return
239
+ }
240
+ cleanupDrag()
241
+ }
242
+
243
+ const onLostPointerCapture = (event: PointerEvent) => {
244
+ // 所有 pointerId の capture 喪失 (ウィンドウ外 drop 等) は最後のトークンのまま終了
245
+ if (!draggingRef.current || capturedPointerRef.current === null || event.pointerId !== capturedPointerRef.current) {
246
+ return
247
+ }
248
+ cleanupDrag()
249
+ }
250
+
251
+ body.addEventListener("pointermove", onPointerMove)
252
+ body.addEventListener("pointerup", onPointerUp)
253
+ body.addEventListener("lostpointercapture", onLostPointerCapture)
254
+ return () => {
255
+ body.removeEventListener("pointermove", onPointerMove)
256
+ body.removeEventListener("pointerup", onPointerUp)
257
+ body.removeEventListener("lostpointercapture", onLostPointerCapture)
258
+ cleanupDrag()
259
+ }
260
+ }, [enabled, bodyRef, hitTest, cleanupDrag])
261
+
262
+ return useMemo(
263
+ () => ({
264
+ isDragging: () => draggingRef.current,
265
+ // 参照矩形は常に "point" (合成面 dragModeAny が飛行中だけこの値を overlay へ配る)
266
+ getDragMode: (): "add" | "subtract" | "point" => "point",
267
+ abortDrag,
268
+ cancelDrag: cleanupDrag,
269
+ onPointerDown,
270
+ }),
271
+ [abortDrag, cleanupDrag, onPointerDown],
272
+ )
273
+ }