@aiquants/spreadsheet 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1103 -0
- package/LICENSE.md +155 -0
- package/README.md +817 -0
- package/dist/index.cjs +21 -0
- package/dist/index.d.cts +1960 -0
- package/dist/index.d.ts +1960 -0
- package/dist/index.js +7711 -0
- package/dist/styles/spreadsheet.css +2 -0
- package/dist/styles/spreadsheet.standalone.css +3 -0
- package/package.json +94 -0
- package/src/Spreadsheet.tsx +2313 -0
- package/src/clipboard/clipPayload.ts +1242 -0
- package/src/clipboard/fixtures/excel.fixture.ts +301 -0
- package/src/clipboard/fixtures/html.fixture.ts +90 -0
- package/src/clipboard/fixtures/sheets.fixture.ts +83 -0
- package/src/clipboard/htmlInterop.ts +1694 -0
- package/src/clipboard/numberFormatCodec.ts +870 -0
- package/src/clipboard/pasteSpecial.ts +608 -0
- package/src/clipboard/tiling.ts +370 -0
- package/src/clipboard/tsv.ts +186 -0
- package/src/clipboard/useClipboard.ts +1154 -0
- package/src/commands/selfCalc.ts +279 -0
- package/src/commands/useCommands.ts +946 -0
- package/src/editor/useCellEditor.ts +708 -0
- package/src/grid/CellView.tsx +51 -0
- package/src/grid/GridBody.tsx +489 -0
- package/src/header/ColumnHeader.tsx +167 -0
- package/src/header/RowHeader.tsx +144 -0
- package/src/header/columnName.ts +31 -0
- package/src/header/resizeGates.ts +146 -0
- package/src/header/resizeStrips.ts +79 -0
- package/src/header/useHeaderResize.ts +598 -0
- package/src/header/useHeaderSelection.ts +454 -0
- package/src/index.ts +122 -0
- package/src/keyboard/compositionGuard.ts +53 -0
- package/src/keyboard/ensureCellVisible.ts +77 -0
- package/src/keyboard/shortcuts.ts +718 -0
- package/src/keyboard/useGridKeyboard.ts +940 -0
- package/src/model/axisModel.ts +186 -0
- package/src/model/cellAnnotations.ts +92 -0
- package/src/model/cellCoord.ts +114 -0
- package/src/model/cellFormat.ts +562 -0
- package/src/model/cellPaint.ts +537 -0
- package/src/model/conditionalFormat.ts +805 -0
- package/src/model/dataAdapter.ts +478 -0
- package/src/model/dataValidation.ts +542 -0
- package/src/model/formulaRefs.ts +984 -0
- package/src/model/frozenPartition.ts +100 -0
- package/src/model/labels.ts +228 -0
- package/src/model/viewStore.ts +124 -0
- package/src/overlay/SelectionOverlay.tsx +1425 -0
- package/src/overlay/useFillHandle.ts +432 -0
- package/src/popover/CommentPopover.tsx +109 -0
- package/src/popover/LinkPopover.tsx +108 -0
- package/src/popover/ListPopover.tsx +161 -0
- package/src/popover/MessagePopover.tsx +36 -0
- package/src/popover/NotePopover.tsx +106 -0
- package/src/popover/NoteTip.tsx +26 -0
- package/src/popover/PopoverLayer.tsx +210 -0
- package/src/popover/placement.ts +138 -0
- package/src/popover/usePopovers.ts +642 -0
- package/src/selection/selectionState.ts +239 -0
- package/src/selection/useAutoScroller.ts +226 -0
- package/src/selection/usePointDrag.ts +273 -0
- package/src/selection/useSelectionEngine.ts +687 -0
- package/src/styles/components.entry.css +10 -0
- package/src/styles/spreadsheet.css +1029 -0
- package/src/styles/standalone.entry.css +9 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Pure selection reducers (Excel semantics core — §6.4.2).
|
|
3
|
+
* Identity-preservation contract: every reducer returns the INPUT state object when the result
|
|
4
|
+
* is equivalent — the §6.9-1 setSelection wrapper's Object.is bail depends on it (breaking it
|
|
5
|
+
* double-fires onSelectionChange on a no-move click). Documented exception: subtract case (c)
|
|
6
|
+
* REBUILDS the pressCell 1x1, so an equivalent result there is a fresh object — the wrapper's
|
|
7
|
+
* structural bail absorbs it.
|
|
8
|
+
*
|
|
9
|
+
* 選択の純関数 reducer (Excel 意味論の中核 — §6.4.2)。identity 保存契約: 結果が入力と等価なら
|
|
10
|
+
* 入力 state をそのまま返す (setSelection ラッパーの Object.is bail の根拠)。明文例外: subtract
|
|
11
|
+
* の (c) は pressCell 1×1 を再構築するため等価でも新オブジェクト — ラッパーの構造 bail が受ける。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { type CellCoord, type CellRange, coordEquals, makeRange, rangeContains, type SpreadsheetDirection } from "../model/cellCoord.ts"
|
|
15
|
+
|
|
16
|
+
/** Selection state (tail of ranges = the "current range"). / 選択状態 (末尾が現在レンジ)。 */
|
|
17
|
+
export type SelectionState = {
|
|
18
|
+
/** Non-contiguous range list; the tail is the "current range". / 非連続選択のレンジ列 (末尾が現在レンジ)。 */
|
|
19
|
+
ranges: readonly CellRange[]
|
|
20
|
+
/** Drag / Shift-extend origin. / ドラッグ / Shift 拡張の起点。 */
|
|
21
|
+
anchor: CellCoord
|
|
22
|
+
/** Active cell; never moved by extend operations (Excel semantics). / アクティブセル (拡張で不動)。 */
|
|
23
|
+
active: CellCoord
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Press modifiers (extend = Shift, append = Ctrl/Cmd). / 押下修飾。 */
|
|
27
|
+
export type SelectionModifiers = { extend: boolean; append: boolean }
|
|
28
|
+
|
|
29
|
+
/** Structural equality of two ranges. / 2 レンジの構造等価 (identity 短絡の述語)。 */
|
|
30
|
+
const rangeEquals = (a: CellRange, b: CellRange): boolean => a.startRow === b.startRow && a.startCol === b.startCol && a.endRow === b.endRow && a.endCol === b.endCol
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Structural equality of two selection states — the §6.9-1 wrapper's bail predicate for
|
|
34
|
+
* CONSTRUCTED states (Ctrl+A / corner / header composition build fresh objects, so the
|
|
35
|
+
* reducers' identity contract alone cannot suppress equivalent re-fires).
|
|
36
|
+
* 2 選択状態の構造等価 — 構築系 state (Ctrl+A / コーナー / ヘッダー合成) の等価再発火を
|
|
37
|
+
* ラッパー側で抑止する述語。
|
|
38
|
+
*/
|
|
39
|
+
export const selectionStateEquals = (a: SelectionState, b: SelectionState): boolean => a.ranges.length === b.ranges.length && a.ranges.every((range, i) => rangeEquals(range, b.ranges[i])) && coordEquals(a.anchor, b.anchor) && coordEquals(a.active, b.active)
|
|
40
|
+
|
|
41
|
+
/** Replaces the tail range, preserving identity when unchanged. / 現在レンジのみ置換 (等価なら入力を返す)。 */
|
|
42
|
+
const replaceCurrentRange = (state: SelectionState, next: CellRange): SelectionState => {
|
|
43
|
+
// identity 保存契約 (冒頭): 無変化なら新オブジェクトを作らない — §6.9-1 の Object.is bail の根拠
|
|
44
|
+
const current = state.ranges[state.ranges.length - 1]
|
|
45
|
+
if (rangeEquals(current, next)) {
|
|
46
|
+
return state
|
|
47
|
+
}
|
|
48
|
+
return { ...state, ranges: [...state.ranges.slice(0, -1), next] }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Press reducer (click / keyboard reset move). / 押下 reducer (クリック / キーボード reset 移動)。 */
|
|
52
|
+
export const reduceSelectionPress = (state: SelectionState, cell: CellCoord, mods: SelectionModifiers): SelectionState => {
|
|
53
|
+
if (mods.extend) {
|
|
54
|
+
// Shift: anchor と active を維持し、現在レンジだけ置換 (Shift+Ctrl 同時は extend 優先 — Excel 同等)。
|
|
55
|
+
// extend reducer へ委譲 — 取り残しクランプを press 経路にも適用し、Shift+click の過渡発火でも
|
|
56
|
+
// active レンジ外の state を consumer へ流さない (R12 correctness の過渡残滓の封じ)
|
|
57
|
+
return reduceSelectionExtend(state, cell)
|
|
58
|
+
}
|
|
59
|
+
if (mods.append) {
|
|
60
|
+
return { ranges: [...state.ranges, makeRange(cell, cell)], anchor: cell, active: cell }
|
|
61
|
+
}
|
|
62
|
+
// 既に「そのセルだけの 1×1 選択」なら入力をそのまま返す (identity 保存 — 同一セル再クリックで発火しない)
|
|
63
|
+
if (state.ranges.length === 1 && coordEquals(state.anchor, cell) && coordEquals(state.active, cell) && rangeEquals(state.ranges[0], makeRange(cell, cell))) {
|
|
64
|
+
return state
|
|
65
|
+
}
|
|
66
|
+
return { ranges: [makeRange(cell, cell)], anchor: cell, active: cell }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Clamps a coordinate into a range. / 座標をレンジ内へクランプする補助。 */
|
|
70
|
+
const clampIntoRange = (cell: CellCoord, range: CellRange): CellCoord => ({
|
|
71
|
+
row: Math.min(Math.max(cell.row, range.startRow), range.endRow),
|
|
72
|
+
col: Math.min(Math.max(cell.col, range.startCol), range.endCol),
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Extend reducer (drag confirm / Shift+Arrow); active is immobile EXCEPT the stranded-active
|
|
77
|
+
* clamp: when a shrinking extend leaves active outside EVERY range (v0.2 invariant: active
|
|
78
|
+
* lives in SOME range, not necessarily the tail — multi-range cycling / subtraction moved it),
|
|
79
|
+
* active is clamped back into the tail range (Sheets-style — a documented deviation from the
|
|
80
|
+
* §6.6.2-3 immobility rule).
|
|
81
|
+
* 拡張 reducer (active 原則不動)。例外は取り残しクランプ — active が**どのレンジにも**含まれ
|
|
82
|
+
* なくなった場合のみ (v0.2 不変条件: active は必ずいずれかのレンジ内 — 末尾とは限らない)、
|
|
83
|
+
* 末尾レンジ内へクランプする (Sheets 準拠の文書化逸脱)。
|
|
84
|
+
*/
|
|
85
|
+
export const reduceSelectionExtend = (state: SelectionState, cell: CellCoord): SelectionState => {
|
|
86
|
+
const next = replaceCurrentRange(state, makeRange(state.anchor, cell))
|
|
87
|
+
if (next.ranges.some((range) => rangeContains(range, next.active))) {
|
|
88
|
+
return next // identity 保存契約はここで維持 (無変化 + active がいずれかのレンジ内なら入力をそのまま返す)
|
|
89
|
+
}
|
|
90
|
+
return { ...next, active: clampIntoRange(next.active, next.ranges[next.ranges.length - 1]) }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Subtracts a rectangle from every intersecting range (Ctrl+click / Ctrl+drag deselect —
|
|
95
|
+
* §3.1). Each intersecting range decomposes into at most 4 disjoint bands (top / bottom
|
|
96
|
+
* full-width, left / right only on the vertically overlapping rows), expanded in place of the
|
|
97
|
+
* original. Re-anchoring restores both invariants for EVERY case: (a) active survives — the
|
|
98
|
+
* first range containing it is extracted to the array tail and anchor := active; (b) active
|
|
99
|
+
* removed — anchor and active move to the top-left of the survivor array's tail range
|
|
100
|
+
* (fragment or untouched alike — always defined); (c) nothing survives — a 1x1 at pressCell.
|
|
101
|
+
* Zero intersection returns the input state (identity). Case (c) may REBUILD a structurally
|
|
102
|
+
* equal state — the documented exception to the identity contract; the §6.9-1 wrapper's
|
|
103
|
+
* structural bail absorbs the re-fire.
|
|
104
|
+
* 矩形減算 reducer (Ctrl+クリック / ドラッグの選択解除 — §3.1)。交差レンジは最大 4 の互いに
|
|
105
|
+
* 素な帯 (上下 = 全幅、左右 = 垂直重なり行のみ) へ分解し元位置へ展開する。再 anchor は全ケース
|
|
106
|
+
* で両不変条件を回復: (a) active 生存 → active を含む最初のレンジを末尾へ抜き出し anchor :=
|
|
107
|
+
* active。(b) active 除去 → 残存配列の末尾レンジ (フラグメントか否かを問わない) の左上へ移設。
|
|
108
|
+
* (c) 残存ゼロ → pressCell の 1×1。交差ゼロは入力をそのまま返す (identity 保存)。(c) の同値
|
|
109
|
+
* 再構築は identity 契約の明文例外 — ラッパーの構造 bail が無発火化する。
|
|
110
|
+
*/
|
|
111
|
+
export const reduceSelectionSubtract = (state: SelectionState, rect: CellRange, pressCell: CellCoord): SelectionState => {
|
|
112
|
+
let intersected = false
|
|
113
|
+
const survivors: CellRange[] = []
|
|
114
|
+
for (const range of state.ranges) {
|
|
115
|
+
// 交差なしのレンジは無変更でそのまま (identity 判定の分母)
|
|
116
|
+
if (rect.startRow > range.endRow || rect.endRow < range.startRow || rect.startCol > range.endCol || rect.endCol < range.startCol) {
|
|
117
|
+
survivors.push(range)
|
|
118
|
+
continue
|
|
119
|
+
}
|
|
120
|
+
intersected = true
|
|
121
|
+
// 帯分割 (重複なし — 生成順は上 → 下 → 左 → 右。負スパン帯は生成しない §3.1-1)
|
|
122
|
+
if (rect.startRow > range.startRow) {
|
|
123
|
+
survivors.push(makeRange({ row: range.startRow, col: range.startCol }, { row: rect.startRow - 1, col: range.endCol }))
|
|
124
|
+
}
|
|
125
|
+
if (rect.endRow < range.endRow) {
|
|
126
|
+
survivors.push(makeRange({ row: rect.endRow + 1, col: range.startCol }, { row: range.endRow, col: range.endCol }))
|
|
127
|
+
}
|
|
128
|
+
// 左右帯の行スパン = rect と元レンジの垂直重なり行のみ (上下帯との重複を作らない)
|
|
129
|
+
const midStart = Math.max(range.startRow, rect.startRow)
|
|
130
|
+
const midEnd = Math.min(range.endRow, rect.endRow)
|
|
131
|
+
if (rect.startCol > range.startCol) {
|
|
132
|
+
survivors.push(makeRange({ row: midStart, col: range.startCol }, { row: midEnd, col: rect.startCol - 1 }))
|
|
133
|
+
}
|
|
134
|
+
if (rect.endCol < range.endCol) {
|
|
135
|
+
survivors.push(makeRange({ row: midStart, col: rect.endCol + 1 }, { row: midEnd, col: range.endCol }))
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (!intersected) {
|
|
139
|
+
return state // identity 保存 (全レンジと交差ゼロ)
|
|
140
|
+
}
|
|
141
|
+
if (survivors.length === 0) {
|
|
142
|
+
// (c) 残存ゼロ → 押下セルの 1×1 (空選択の不可能性を維持)
|
|
143
|
+
return { ranges: [makeRange(pressCell, pressCell)], anchor: pressCell, active: pressCell }
|
|
144
|
+
}
|
|
145
|
+
const activeIndex = survivors.findIndex((range) => rangeContains(range, state.active))
|
|
146
|
+
if (activeIndex >= 0) {
|
|
147
|
+
// (a) active 生存 → 所属レンジ (先頭一致) を末尾へ抜き出し anchor := active
|
|
148
|
+
// (「anchor = 末尾レンジ内」「active = いずれかのレンジ内」を同時回復。巡回順の
|
|
149
|
+
// 再順序化は §10 逸脱表の意図仕様)
|
|
150
|
+
const [containing] = survivors.splice(activeIndex, 1)
|
|
151
|
+
survivors.push(containing)
|
|
152
|
+
return { ranges: survivors, anchor: state.active, active: state.active }
|
|
153
|
+
}
|
|
154
|
+
// (b) active 除去 → 残存配列の末尾レンジ左上へ anchor / active を移設 (末尾は移動不要)
|
|
155
|
+
const topLeft: CellCoord = { row: survivors[survivors.length - 1].startRow, col: survivors[survivors.length - 1].startCol }
|
|
156
|
+
return { ranges: survivors, anchor: topLeft, active: topLeft }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* One in-range cycle step at a (rangeIndex, cell) position: row-major for left/right,
|
|
161
|
+
* column-major for up/down; leaving the range's terminal cell transfers to the ADJACENT range
|
|
162
|
+
* (right/down = next range's top-left, left/up = previous range's bottom-right, circular).
|
|
163
|
+
* Single-range input reproduces the v0.1 same-range wrap exactly (adjacent = itself).
|
|
164
|
+
* (rangeIndex, cell) 位置の巡回 1 歩。レンジ終端セルからの離脱は隣接レンジの入口へ (right /
|
|
165
|
+
* down = 次レンジ左上、left / up = 前レンジ右下。環状)。単一レンジでは v0.1 の同レンジ
|
|
166
|
+
* 折り返しと完全一致 (隣接 = 自分自身)。
|
|
167
|
+
*/
|
|
168
|
+
const cycleStep = (ranges: readonly CellRange[], rangeIndex: number, cell: CellCoord, direction: SpreadsheetDirection): { rangeIndex: number; cell: CellCoord } => {
|
|
169
|
+
const range = ranges[rangeIndex]
|
|
170
|
+
if (direction === "right") {
|
|
171
|
+
if (cell.col < range.endCol) {
|
|
172
|
+
return { rangeIndex, cell: { row: cell.row, col: cell.col + 1 } }
|
|
173
|
+
}
|
|
174
|
+
if (cell.row < range.endRow) {
|
|
175
|
+
return { rangeIndex, cell: { row: cell.row + 1, col: range.startCol } } // 行末 → 次行の先頭
|
|
176
|
+
}
|
|
177
|
+
} else if (direction === "left") {
|
|
178
|
+
if (cell.col > range.startCol) {
|
|
179
|
+
return { rangeIndex, cell: { row: cell.row, col: cell.col - 1 } }
|
|
180
|
+
}
|
|
181
|
+
if (cell.row > range.startRow) {
|
|
182
|
+
return { rangeIndex, cell: { row: cell.row - 1, col: range.endCol } }
|
|
183
|
+
}
|
|
184
|
+
} else if (direction === "down") {
|
|
185
|
+
if (cell.row < range.endRow) {
|
|
186
|
+
return { rangeIndex, cell: { row: cell.row + 1, col: cell.col } }
|
|
187
|
+
}
|
|
188
|
+
if (cell.col < range.endCol) {
|
|
189
|
+
return { rangeIndex, cell: { row: range.startRow, col: cell.col + 1 } } // 列末 → 次列の上端
|
|
190
|
+
}
|
|
191
|
+
} else {
|
|
192
|
+
if (cell.row > range.startRow) {
|
|
193
|
+
return { rangeIndex, cell: { row: cell.row - 1, col: cell.col } }
|
|
194
|
+
}
|
|
195
|
+
if (cell.col > range.startCol) {
|
|
196
|
+
return { rangeIndex, cell: { row: range.endRow, col: cell.col - 1 } }
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
// レンジ終端 → 隣接レンジの入口 (環状)
|
|
200
|
+
if (direction === "right" || direction === "down") {
|
|
201
|
+
const nextIndex = (rangeIndex + 1) % ranges.length
|
|
202
|
+
const entry = ranges[nextIndex]
|
|
203
|
+
return { rangeIndex: nextIndex, cell: { row: entry.startRow, col: entry.startCol } }
|
|
204
|
+
}
|
|
205
|
+
const prevIndex = (rangeIndex - 1 + ranges.length) % ranges.length
|
|
206
|
+
const entry = ranges[prevIndex]
|
|
207
|
+
return { rangeIndex: prevIndex, cell: { row: entry.endRow, col: entry.endCol } }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Cycles the active cell across ALL ranges (the ONE sanctioned exception to the
|
|
212
|
+
* active-immobility rule — Tab/Enter data-entry loop). active's home range resolves
|
|
213
|
+
* statelessly to the FIRST range containing it; range edges transfer to the adjacent range's
|
|
214
|
+
* entrance. Overlap-skip rule: a landing whose coordinate equals the current active keeps
|
|
215
|
+
* advancing until the coordinate changes; a full circle back to the start position returns
|
|
216
|
+
* the input state (identity — the caller falls through to a reset move). Active outside every
|
|
217
|
+
* range is unreachable under the invariant and throws (No-Fallback: no silent tail rescue).
|
|
218
|
+
* Tab / Enter による全レンジ横断の active 巡回 (active 不動則の唯一の正規例外)。所属レンジは
|
|
219
|
+
* 先頭一致で stateless に解決し、レンジ終端は隣接レンジの入口へ乗り継ぐ。重なりスキップ:
|
|
220
|
+
* 着地座標が現在の active と同一なら座標が変わるまで前進し、開始位置へ全周したら入力 state を
|
|
221
|
+
* 返す (identity — 呼び出し側が reset へフォールスルー)。active がどのレンジにも属さない状態は
|
|
222
|
+
* 不変条件により unreachable — 発生時は throw (No-Fallback: 末尾レンジへの無音救済を発明しない)。
|
|
223
|
+
*/
|
|
224
|
+
export const reduceActiveCycle = (state: SelectionState, direction: SpreadsheetDirection): SelectionState => {
|
|
225
|
+
const startIndex = state.ranges.findIndex((range) => rangeContains(range, state.active))
|
|
226
|
+
if (startIndex === -1) {
|
|
227
|
+
throw new RangeError("[spreadsheet] reduceActiveCycle requires active inside some range (invariant violation)")
|
|
228
|
+
}
|
|
229
|
+
let position = { rangeIndex: startIndex, cell: state.active }
|
|
230
|
+
// 座標が変わるまで前進 (重なりレンジの同一座標入口をスキップ — §3.4-3)。ループ内では座標は
|
|
231
|
+
// 常に active と同一のため、開始位置 (rangeIndex) への回帰 = 全周 = identity
|
|
232
|
+
do {
|
|
233
|
+
position = cycleStep(state.ranges, position.rangeIndex, position.cell, direction)
|
|
234
|
+
if (!coordEquals(position.cell, state.active)) {
|
|
235
|
+
return { ...state, active: position.cell }
|
|
236
|
+
}
|
|
237
|
+
} while (position.rangeIndex !== startIndex)
|
|
238
|
+
return state // 全周して active 自身のみ (単一 1×1 / 重複同一レンジ群) — identity 保存
|
|
239
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Edge-zone rAF auto-scroller for drag selection (§6.4.4). Driven by the
|
|
3
|
+
* selection engine via start/stop; hit tests use the 2-axis scrollBy synchronous return
|
|
4
|
+
* (freshness hierarchy §7-4: scrollBy return > getScrollPosition() — the store carries no
|
|
5
|
+
* positions since v0.4).
|
|
6
|
+
*
|
|
7
|
+
* ドラッグ選択の端ゾーン rAF オートスクロール (§6.4.4)。エンジンが start/stop で駆動し、
|
|
8
|
+
* ヒットテストは 2 軸 scrollBy の同期返り値を使う (鮮度序列 §7-4: scrollBy 返り値 >
|
|
9
|
+
* getScrollPosition — v0.4 からストアは位置を運ばない)。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { VirtualGridHandle } from "@aiquants/virtualscroll"
|
|
13
|
+
import { type RefObject, useCallback, useEffect, useMemo, useRef } from "react"
|
|
14
|
+
import type { AxisModel } from "../model/axisModel.ts"
|
|
15
|
+
import type { CellCoord, CellRange } from "../model/cellCoord.ts"
|
|
16
|
+
import { clampCoord } from "../model/cellCoord.ts"
|
|
17
|
+
import { viewportXToCol, viewportYToRow } from "../model/frozenPartition.ts"
|
|
18
|
+
|
|
19
|
+
/** Edge zone size in px. / 端ゾーン幅 (px)。 */
|
|
20
|
+
const EDGE_ZONE = 24
|
|
21
|
+
/** Max scroll speed in px per frame. / フレームあたり最大速度 (px)。 */
|
|
22
|
+
const MAX_SPEED = 32
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Arming distance for edge auto-scroll (the fill machine's FILL_ARM_THRESHOLD twin — R5 fix):
|
|
26
|
+
* real clicks carry 1-3px jitter and coordinate-identical capture echoes, so a single move
|
|
27
|
+
* event must not arm the scroller (an edge-zone click would drain at 32px/frame). Only >= this
|
|
28
|
+
* cumulative movement from the press point arms.
|
|
29
|
+
* 端オートスクロールの武装距離 (fill 機械 FILL_ARM_THRESHOLD の双子 — R5 是正)。実クリックは
|
|
30
|
+
* 1〜3px のジッタと座標同値のキャプチャエコーを含むため、move 1 発では武装させない (端ゾーンの
|
|
31
|
+
* クリックが 32px/frame のドレインに化ける)。押下点から累計この距離以上の実移動のみ武装。
|
|
32
|
+
*/
|
|
33
|
+
export const SCROLLER_ARM_THRESHOLD = 4
|
|
34
|
+
|
|
35
|
+
/** Start context supplied by the engine per drag. / エンジンがドラッグ毎に渡す開始コンテキスト。 */
|
|
36
|
+
export type AutoScrollContext = {
|
|
37
|
+
/** Live pointer reader (a stable closure over the ref the engine updates per pointermove). / エンジンが pointermove ごとに更新する ref を読む安定クロージャ。 */
|
|
38
|
+
getPointerPosition: () => { clientX: number; clientY: number }
|
|
39
|
+
/**
|
|
40
|
+
* Hover cell to drag-rect range (the single construction point). Cell drags build
|
|
41
|
+
* anchor-to-hover; header drags promote to the §6.8-1 axis-full span — writing a raw
|
|
42
|
+
* anchor-to-hover here would overwrite the full-span rect with a non-span rect every tick
|
|
43
|
+
* during header drags.
|
|
44
|
+
* hover セル → ドラッグ矩形レンジの構築 (単一の構築点)。セルドラッグは anchor→hover、
|
|
45
|
+
* ヘッダードラッグは片軸全域スパンへ昇格 (素の anchor→hover 直書きは毎 tick の非スパン上書き)。
|
|
46
|
+
*/
|
|
47
|
+
buildRange: (hover: CellCoord) => CellRange
|
|
48
|
+
/**
|
|
49
|
+
* Drag-anchor column for the frozen home-territory rule (v0.5 R5 fix): when the anchor is
|
|
50
|
+
* a frozen column AND the pointer sits inside the frozen band, the band-based left zone
|
|
51
|
+
* does NOT drive — operating within the frozen pane must not scroll the band (Excel-like).
|
|
52
|
+
* Omit for vertical-only drags.
|
|
53
|
+
* 凍結ホームテリトリー規則 (v0.5 R5 是正) 用のドラッグアンカー列。アンカーが凍結列で
|
|
54
|
+
* ポインタも凍結帯内なら帯基準左ゾーンは駆動しない — 凍結ペイン内の操作は可動帯を
|
|
55
|
+
* スクロールさせない (Excel 同型)。縦専用ドラッグでは省略。
|
|
56
|
+
*/
|
|
57
|
+
anchorCol?: number
|
|
58
|
+
/**
|
|
59
|
+
* Drag-anchor row for the vertical frozen home-territory rule (row twin of anchorCol — v0.6):
|
|
60
|
+
* anchor frozen AND pointer inside the frozen band suppresses the band-based top zone.
|
|
61
|
+
* 縦の凍結ホームテリトリー規則用のドラッグアンカー行 (anchorCol の行双子 — v0.6)。アンカーが
|
|
62
|
+
* 凍結行でポインタも凍結帯内なら帯基準上ゾーンは駆動しない。
|
|
63
|
+
*/
|
|
64
|
+
anchorRow?: number
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
type UseAutoScrollerParams = {
|
|
68
|
+
/** 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 回だけ除算する。 */
|
|
69
|
+
zoomRef: RefObject<number>
|
|
70
|
+
handleRef: RefObject<VirtualGridHandle | null>
|
|
71
|
+
rowAxis: AxisModel
|
|
72
|
+
colAxis: AxisModel
|
|
73
|
+
bodyRef: RefObject<HTMLElement | null>
|
|
74
|
+
onDragFrame: (range: CellRange | null) => void
|
|
75
|
+
/** Axis restriction (header drags drive a single axis — §6.8). / 軸制限 (ヘッダードラッグは片軸のみ駆動)。 */
|
|
76
|
+
axis?: "both" | "x" | "y"
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Return surface consumed by the engine. / エンジンが消費する戻り面。 */
|
|
80
|
+
export type AutoScroller = {
|
|
81
|
+
start: (ctx: AutoScrollContext) => void
|
|
82
|
+
stop: () => void
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Builds the drag auto-scroller (rAF loop active only while dragging).
|
|
87
|
+
* ドラッグ中のみ稼働する rAF ループのオートスクローラーを構築。
|
|
88
|
+
*
|
|
89
|
+
* 既知特性: 速度は rAF 結合のため 120Hz 表示では 60Hz の 2 倍速になる (Excel も同型の
|
|
90
|
+
* フレーム結合 — 時間正規化はドラッグ体感を粘らせるため v0.1 では採らない)。
|
|
91
|
+
*/
|
|
92
|
+
export const useAutoScroller = ({ handleRef, rowAxis, colAxis, bodyRef, onDragFrame, axis = "both", zoomRef }: UseAutoScrollerParams): AutoScroller => {
|
|
93
|
+
const rafIdRef = useRef<number | null>(null)
|
|
94
|
+
const contextRef = useRef<AutoScrollContext | null>(null)
|
|
95
|
+
/** Last tick's hover (suppresses identical rewrites on stationary no-scroll ticks). / 直近 tick の hover (静止 + 無スクロール tick の同値再描画を抑止)。 */
|
|
96
|
+
const lastHoverRef = useRef<CellCoord | null>(null)
|
|
97
|
+
// bodyRect はドラッグ開始時にエンジンが計測したものと同条件で 1 回だけ読む (§6.4.3-3 の
|
|
98
|
+
// レイアウト読み規律 — body の画面位置はドラッグ中不変)
|
|
99
|
+
const bodyRectRef = useRef<DOMRect | null>(null)
|
|
100
|
+
|
|
101
|
+
/** Idempotent stop (§6.4.4-3). / 冪等な停止関数。 */
|
|
102
|
+
const stop = useCallback(() => {
|
|
103
|
+
if (rafIdRef.current !== null) {
|
|
104
|
+
cancelAnimationFrame(rafIdRef.current)
|
|
105
|
+
rafIdRef.current = null
|
|
106
|
+
}
|
|
107
|
+
contextRef.current = null
|
|
108
|
+
bodyRectRef.current = null
|
|
109
|
+
lastHoverRef.current = null
|
|
110
|
+
}, [])
|
|
111
|
+
|
|
112
|
+
/** One edge-scroll + re-hit-test frame. / 端スクロール + 再ヒットテストの 1 フレーム。 */
|
|
113
|
+
const tick = useCallback(() => {
|
|
114
|
+
const context = contextRef.current
|
|
115
|
+
const handle = handleRef.current
|
|
116
|
+
const rect = bodyRectRef.current
|
|
117
|
+
if (context === null || handle === null || rect === null) {
|
|
118
|
+
rafIdRef.current = null
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
const { clientX, clientY } = context.getPointerPosition()
|
|
122
|
+
// ズーム境界 (v0.3): ポインタ / rect は視覚 px — ペイン局所の論理 px へ 1 回だけ変換し、
|
|
123
|
+
// 端ゾーン幅・速度・ヒットテストを全て論理系で統一する (EDGE_ZONE / MAX_SPEED は論理 px)
|
|
124
|
+
const z = zoomRef.current
|
|
125
|
+
const px = (clientX - rect.left) / z
|
|
126
|
+
const py = (clientY - rect.top) / z
|
|
127
|
+
// ペイン寸法はセルビューポート (getViewportSize — バー帯除外の論理 px)。body rect は
|
|
128
|
+
// v0.4 からバー帯 2 本を内包するため、rect 寸法だと下端ゾーンが横バー帯へ 12px 食い込む。
|
|
129
|
+
// コンテンツ原点は body 左上と一致 (バーは右 / 下) なので px / py はそのまま比較できる
|
|
130
|
+
const viewport = handle.getViewportSize()
|
|
131
|
+
const paneW = viewport.width
|
|
132
|
+
const paneH = viewport.height
|
|
133
|
+
// 未計測 (幅高 0 以下) はドラッグ成立前の過渡 — 端判定せず次フレームへ (代替値は使わない)
|
|
134
|
+
if (paneW <= 0 || paneH <= 0) {
|
|
135
|
+
rafIdRef.current = requestAnimationFrame(tick)
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
// 食い込み深さの線形関数 (depth / 24 * 32、最大 32)。ceil は意図的な微逸脱 (§6.4.4-1 は素の
|
|
139
|
+
// 線形): 端に 1px でも触れたら必ず 1px/frame 以上動く下限を作り、浮動小数の 0 埋めで
|
|
140
|
+
// 「触れているのに進まない」帯を消す
|
|
141
|
+
const speedFor = (depth: number): number => Math.min(MAX_SPEED, Math.ceil((depth / EDGE_ZONE) * MAX_SPEED))
|
|
142
|
+
let dy = 0
|
|
143
|
+
let dx = 0
|
|
144
|
+
if (axis !== "x") {
|
|
145
|
+
// 上端ゾーンは帯基準 (v0.6 計画 §3 — 横の左端ゾーンと対称): スクロール帯の上端 = H_F。
|
|
146
|
+
// 下端優先 + 凍結ホームテリトリー (アンカー凍結行 × ポインタ帯内は駆動しない、H_F = 0 除外)
|
|
147
|
+
const frozenV = handle.getFrozenSize()
|
|
148
|
+
if (py > paneH - EDGE_ZONE) {
|
|
149
|
+
dy = speedFor(py - (paneH - EDGE_ZONE))
|
|
150
|
+
} else if (py < frozenV.height + EDGE_ZONE) {
|
|
151
|
+
const anchorFrozenRow = context.anchorRow !== undefined && context.anchorRow < frozenV.rows
|
|
152
|
+
if (!(anchorFrozenRow && frozenV.height > 0 && py < frozenV.height)) {
|
|
153
|
+
dy = -speedFor(frozenV.height + EDGE_ZONE - py)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (axis !== "y") {
|
|
158
|
+
// 左端ゾーンは帯基準 (v0.5 計画 §3-6): スクロール帯の左端 = W_F。スクロール帯アンカーの
|
|
159
|
+
// ドラッグは凍結帯上の保持でも左スクロールが継続する (Excel 同型 — 帯を跨いだ引き寄せ)。
|
|
160
|
+
// 判定は右端優先 (R2 是正): 帯幅 < 2×EDGE_ZONE で両ゾーンが重なると左優先は右端保持の
|
|
161
|
+
// ドラッグを逆走させる — 重なりは右端が勝つ。凍結ホームテリトリー (R5 是正): アンカーが
|
|
162
|
+
// 凍結列でポインタも凍結帯内なら駆動しない — 凍結ペイン内の選択操作が可動帯を勝手に
|
|
163
|
+
// 左スクロールさせない (帯外 [W_F, W_F+EDGE_ZONE) の戻りドラッグは従来どおり駆動)
|
|
164
|
+
const frozen = handle.getFrozenSize()
|
|
165
|
+
if (px > paneW - EDGE_ZONE) {
|
|
166
|
+
dx = speedFor(px - (paneW - EDGE_ZONE))
|
|
167
|
+
} else if (px < frozen.width + EDGE_ZONE) {
|
|
168
|
+
// W_F = 0 (凍結列が全て非表示) は可視の凍結帯が存在しない — 負 px を帯内と
|
|
169
|
+
// 誤認して抑止しない (R5 是正)
|
|
170
|
+
const anchorFrozen = context.anchorCol !== undefined && context.anchorCol < frozen.cols
|
|
171
|
+
if (!(anchorFrozen && frozen.width > 0 && px < frozen.width)) {
|
|
172
|
+
dx = -speedFor(frozen.width + EDGE_ZONE - px)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
// 両軸とも scrollBy のクランプ後同期返り値を採用 (§7-4 の鮮度序列 — 行側の
|
|
177
|
+
// `const vy = handle.scrollBy(dy)` パターンの 2 軸同文)。0 デルタ軸は動かさず最新値のみ読む
|
|
178
|
+
const applied = dx !== 0 || dy !== 0 ? handle.scrollBy({ x: dx !== 0 ? dx : undefined, y: dy !== 0 ? dy : undefined }) : handle.getScrollPosition()
|
|
179
|
+
const vy = applied.y
|
|
180
|
+
const hx = applied.x
|
|
181
|
+
// スクロール後にその場でヒットテスト再実行 → onDragFrame (ポインタ静止でも選択が伸びる)。
|
|
182
|
+
// レンジ構築は context.buildRange の 1 点 (ヘッダードラッグの片軸全域昇格を尊重 — §6.8-1)。
|
|
183
|
+
// 静止 + 無スクロール + hover 同値の tick は書き込みをスキップ (同値スタイルの毎フレーム
|
|
184
|
+
// 再書き込みを抑止 — スクロール追随の再配置は repaintOverlay 側が担う)
|
|
185
|
+
if (vy >= 0 && hx >= 0 && rowAxis.totalSize() > 0 && colAxis.totalSize() > 0) {
|
|
186
|
+
// 行 / 列とも凍結パーティション逆写像 (v0.6 / v0.5 計画 §2)
|
|
187
|
+
const frozenHover = handle.getFrozenSize()
|
|
188
|
+
const hover = clampCoord({ row: viewportYToRow(rowAxis, py, vy, frozenHover.height), col: viewportXToCol(colAxis, px, hx, frozenHover.width) }, rowAxis.count, colAxis.count)
|
|
189
|
+
const last = lastHoverRef.current
|
|
190
|
+
if (dy !== 0 || dx !== 0 || last === null || last.row !== hover.row || last.col !== hover.col) {
|
|
191
|
+
lastHoverRef.current = hover
|
|
192
|
+
onDragFrame(context.buildRange(hover))
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
rafIdRef.current = requestAnimationFrame(tick)
|
|
196
|
+
}, [handleRef, rowAxis, colAxis, axis, onDragFrame])
|
|
197
|
+
|
|
198
|
+
const start = useCallback(
|
|
199
|
+
(ctx: AutoScrollContext) => {
|
|
200
|
+
stop()
|
|
201
|
+
contextRef.current = ctx
|
|
202
|
+
bodyRectRef.current = bodyRef.current?.getBoundingClientRect() ?? null
|
|
203
|
+
rafIdRef.current = requestAnimationFrame(tick)
|
|
204
|
+
},
|
|
205
|
+
[stop, tick, bodyRef],
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
// 停止条件: visibilitychange (hidden) とアンマウント (§6.4.4-3)
|
|
209
|
+
useEffect(() => {
|
|
210
|
+
const onVisibilityChange = () => {
|
|
211
|
+
if (document.visibilityState === "hidden") {
|
|
212
|
+
stop()
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
document.addEventListener("visibilitychange", onVisibilityChange)
|
|
216
|
+
return () => {
|
|
217
|
+
document.removeEventListener("visibilitychange", onVisibilityChange)
|
|
218
|
+
stop()
|
|
219
|
+
}
|
|
220
|
+
}, [stop])
|
|
221
|
+
|
|
222
|
+
// 戻り値は memo 必須: 毎レンダー新オブジェクトだと消費側 effect (エンジン / ヘッダー機械) の
|
|
223
|
+
// deps が毎回変わり、押下の setState 再レンダーで cleanupDrag が走ってドラッグが即死する
|
|
224
|
+
// (E2E で実測した欠陥クラス)
|
|
225
|
+
return useMemo(() => ({ start, stop }), [start, stop])
|
|
226
|
+
}
|