@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,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Column header band (viewStore subscription #2). Since v0.4 it mirrors the
|
|
3
|
+
* RowHeader scheme exactly (§7-5 of the VirtualGrid design plan): renders only the rendering
|
|
4
|
+
* column range (overscan-inclusive, fed by VirtualGrid's onRangeChange via setColRange) with
|
|
5
|
+
* WINDOW-ORIGIN-RELATIVE coordinates; the track's residual translate is owned EXCLUSIVELY by
|
|
6
|
+
* the imperative repaintColHeaders path (commit gate — the layout effect publishes the new
|
|
7
|
+
* origin pre-paint). The v0.3 --aqsp-hx CSS variable and useColumnWindow are deleted: raw
|
|
8
|
+
* axis-origin lefts would desync from the grid's anchored cells at depth (the 2^25 wall).
|
|
9
|
+
* v0.5 adds the two-band frozen structure (plan §3-2): static frozen labels [0, F) at direct
|
|
10
|
+
* offsetOf lefts, plus a static clip (left = W_F) that hosts the existing scroll track with
|
|
11
|
+
* a floor-clamped enumeration (max(colStart, F)); F = 0 renders the v0.4-identical DOM.
|
|
12
|
+
*
|
|
13
|
+
* 列ヘッダー帯 (viewStore 購読 2)。v0.4 から RowHeader 方式の完全対称 (§7-5): VirtualGrid の
|
|
14
|
+
* onRangeChange (setColRange) が供給する描画列レンジのみを**窓原点相対**の小座標で描画し、
|
|
15
|
+
* track の残差 translate は命令パス (repaintColHeaders) の専有物 — layout effect が新原点を
|
|
16
|
+
* paint 前に公開する (commit ゲート)。v0.3 の --aqsp-hx CSS 変数と useColumnWindow は削除:
|
|
17
|
+
* 軸原点の生 left は深部でアンカー済みセルと desync する (2^25 壁)。v0.5 で 2 帯凍結構造を追加
|
|
18
|
+
* (計画 §3-2): 静的凍結ラベル [0, F) は offsetOf 直接値、スクロール track は静的クリップ
|
|
19
|
+
* (left = W_F) 内で床クランプ列挙 (max(colStart, F)) — F = 0 は v0.4 恒等 DOM。
|
|
20
|
+
*
|
|
21
|
+
* 逸脱 (ADR-9): ヘッダー帯の contextmenu は非対応 — ネイティブメニューへ素通しし、
|
|
22
|
+
* 列選択も onCellContextMenu も発火しない (§6.8-3。README 逸脱表に記載)。
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { useWheelBridge, type VirtualGridHandle } from "@aiquants/virtualscroll"
|
|
26
|
+
import { type ReactNode, type RefObject, useCallback, useLayoutEffect, useSyncExternalStore } from "react"
|
|
27
|
+
import { type AxisModel, visibleIndicesInRange } from "../model/axisModel.ts"
|
|
28
|
+
import type { CellRange } from "../model/cellCoord.ts"
|
|
29
|
+
import { frozenWidthOf } from "../model/frozenPartition.ts"
|
|
30
|
+
import type { ViewStore } from "../model/viewStore.ts"
|
|
31
|
+
import { columnIndexToName } from "./columnName.ts"
|
|
32
|
+
import { buildResizeStrips } from "./resizeStrips.ts"
|
|
33
|
+
|
|
34
|
+
type ColumnHeaderProps = {
|
|
35
|
+
viewStore: ViewStore
|
|
36
|
+
colAxis: AxisModel
|
|
37
|
+
/** Frozen leading columns (v0.5 — mirrors the grid's engine-validated value; render-surface W_F derives from colAxis). / 先頭凍結列数 (v0.5 — エンジン検証値の写像。render 面の W_F は colAxis から導出)。 */
|
|
38
|
+
frozenCols: number
|
|
39
|
+
/** Wheel-bridge target (§6.8-1 — the grid handle satisfies WheelBridgeTarget). / ホイール橋渡し先 (グリッドハンドルがブリッジ契約適合)。 */
|
|
40
|
+
handleRef: RefObject<VirtualGridHandle | null>
|
|
41
|
+
/** Band element registration for the header selection machine (§6.8-1). / 選択機械の帯登録先。 */
|
|
42
|
+
bandRef: RefObject<HTMLDivElement | null>
|
|
43
|
+
/** Registration target for the track element (repaintColHeaders writes here — owner: Spreadsheet §6.9-7). / track 要素の登録先。 */
|
|
44
|
+
colTrackRef: RefObject<HTMLDivElement | null>
|
|
45
|
+
/** Shared ref of the window-origin absolute offset (§7-5 ordering contract — the row windowOriginYRef twin). / 窓原点の共有 ref (行側 windowOriginYRef の双子)。 */
|
|
46
|
+
windowOriginXRef: RefObject<number>
|
|
47
|
+
/** Imperative repaint invoked pre-paint from the layout effect (§7-5 commit gate). / paint 前の命令再描画 (§7-5 commit ゲート)。 */
|
|
48
|
+
repaintColHeaders: () => void
|
|
49
|
+
/** Selected-column emphasis source (data-aqsp-in-selection — §6.8-4; frozen at press during drags = known trait). / 選択中列の強調用 (ドラッグ中は押下時のまま凍結 = 既知特性)。 */
|
|
50
|
+
selectionRanges: readonly CellRange[]
|
|
51
|
+
/** §11 opt-out: false suppresses strip/marker DOM entirely (§7.1). / §11 の opt-out (false は DOM 生成ごと抑止)。 */
|
|
52
|
+
enableResizeUI: boolean
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Renders the visible column-name cells for the rendering window. Header is a visual aid
|
|
57
|
+
* OUTSIDE role="grid" (§10) and therefore aria-hidden.
|
|
58
|
+
* 描画ウィンドウの可視列名セルを描画。role="grid" 外の視覚補助のため aria-hidden。
|
|
59
|
+
*/
|
|
60
|
+
export const ColumnHeader = ({ viewStore, colAxis, frozenCols, handleRef, bandRef, colTrackRef, windowOriginXRef, repaintColHeaders, selectionRanges, enableResizeUI }: ColumnHeaderProps): ReactNode => {
|
|
61
|
+
const bridgeRef = useWheelBridge(handleRef)
|
|
62
|
+
// 帯要素は選択機械 (bandRef) と wheel bridge の 2 消費者を持つ — マージ ref で合成
|
|
63
|
+
const mergedBandRef = useCallback(
|
|
64
|
+
(el: HTMLDivElement | null) => {
|
|
65
|
+
bandRef.current = el
|
|
66
|
+
// bridge の戻り値 (クリーンアップ) をそのまま返す — useWheelBridge の正準形 (React 19)
|
|
67
|
+
return bridgeRef(el)
|
|
68
|
+
},
|
|
69
|
+
[bandRef, bridgeRef],
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
/** String snapshot for uSES bail (colRange + colVersion). / bail 判定用スナップショット。 */
|
|
73
|
+
const getSnapshot = useCallback(() => {
|
|
74
|
+
const { colStart, colEnd, colVersion } = viewStore.get()
|
|
75
|
+
return `${colStart}:${colEnd}:${colVersion}`
|
|
76
|
+
}, [viewStore])
|
|
77
|
+
const subscribe = useCallback((listener: () => void) => viewStore.subscribeCoalesced(listener), [viewStore])
|
|
78
|
+
useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
|
|
79
|
+
const { colStart, colEnd } = viewStore.get()
|
|
80
|
+
|
|
81
|
+
// windowOriginX = offsetOf(colStart)。React が新ウィンドウのラベルを配置し終えた後にだけ
|
|
82
|
+
// 命令パスへ見せる (§7-5 順序契約 — 行側 §6.8-2 (a) の双子): commit 後の layout effect で
|
|
83
|
+
// 共有 ref を更新し、同じ effect 内で repaintColHeaders を即時呼びして新原点の残差 transform
|
|
84
|
+
// を paint 前に書く。adapter 縮小直後の stale range クランプは多層防御 (FenwickMapTree も
|
|
85
|
+
// 内部で index をクランプするため単独では等価 — 行側と同文の契約明示)
|
|
86
|
+
const windowOriginX = colAxis.count === 0 ? 0 : colAxis.offsetOf(Math.min(colStart, colAxis.count - 1))
|
|
87
|
+
useLayoutEffect(() => {
|
|
88
|
+
windowOriginXRef.current = windowOriginX
|
|
89
|
+
repaintColHeaders()
|
|
90
|
+
}, [windowOriginX, windowOriginXRef, repaintColHeaders])
|
|
91
|
+
|
|
92
|
+
/** Whether the column intersects a selection range (§6.8-4 emphasis). / 列が選択レンジに含まれるか (§6.8-4 の強調)。 */
|
|
93
|
+
const isInSelection = (col: number): boolean => selectionRanges.some((r) => col >= r.startCol && col <= r.endCol)
|
|
94
|
+
|
|
95
|
+
// 凍結帯 (v0.5 計画 §3-2): 実効 F はエンジンの動的クランプの写像、W_F は colAxis から導出
|
|
96
|
+
// (対呼び出し規律によりエンジン getFrozenSize().width と常に一致する render 面双子)
|
|
97
|
+
const effectiveFrozenCols = Math.min(Math.max(0, frozenCols), colAxis.count)
|
|
98
|
+
const frozenWidth = frozenWidthOf(colAxis, effectiveFrozenCols)
|
|
99
|
+
// 凍結ラベルは静的 subtree — 木絶対 left の直接値 (W_F ≤ viewport で LayoutUnit 安全)
|
|
100
|
+
const frozenVisibleCols = effectiveFrozenCols > 0 ? visibleIndicesInRange(colAxis, 0, effectiveFrozenCols - 1) : []
|
|
101
|
+
|
|
102
|
+
// §5.4: Fenwick ジャンプ列挙 — 幅 0 列はヘッダーセル DOM を生成しない。配置は窓原点相対
|
|
103
|
+
// (生 offset は深部グリッドで数百万 px になり LayoutUnit 壁を踏む — §7-5)。スクロール側は
|
|
104
|
+
// 凍結列を再出現させない床クランプ (エンジン §8-a と同文 — 空窓 start > end は空列挙)
|
|
105
|
+
const visibleCols = visibleIndicesInRange(colAxis, Math.max(colStart, effectiveFrozenCols), Math.min(colEnd, Math.max(0, colAxis.count - 1)))
|
|
106
|
+
const cells: ReactNode[] = []
|
|
107
|
+
for (const col of visibleCols) {
|
|
108
|
+
cells.push(
|
|
109
|
+
<div key={col} className="aqsp-col-header-cell" data-col={col} data-aqsp-in-selection={isInSelection(col) ? "" : undefined} style={{ left: colAxis.offsetOf(col) - windowOriginX, width: colAxis.sizeOf(col) }}>
|
|
110
|
+
{columnIndexToName(col)}
|
|
111
|
+
</div>,
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// §7.1: ストリップ / マーカーは track の末尾 (ヘッダーセル群の後方兄弟 — DOM 順で上層)。
|
|
116
|
+
// 位置は窓原点相対 (ラベルと同一規律)。リスナーは持たない (帯の wire-once capture が
|
|
117
|
+
// closest() でディスパッチ — useHeaderResize)
|
|
118
|
+
const rawStrips = enableResizeUI ? buildResizeStrips(colAxis, visibleCols) : null
|
|
119
|
+
// 凍結境界の重複 unhide 遮断 (R3 是正): スクロール側先頭マーカーの復元対象が凍結列
|
|
120
|
+
// (index < F) かつ凍結帯が実在 (W_F > 0 — 帯側の分割マーカーが同じランを覆う) なら落とす。
|
|
121
|
+
// 全凍結列非表示 (W_F = 0) は帯側が何も描けないため、スクロール側が唯一の復元導線として残る
|
|
122
|
+
const strips = rawStrips === null ? null : rawStrips.filter((strip) => !(strip.kind === "unhide" && strip.index < effectiveFrozenCols && frozenWidth > 0))
|
|
123
|
+
|
|
124
|
+
// 凍結帯のストリップ (リサイズ / unhide) — 静的配置 (木絶対 left 直接値)
|
|
125
|
+
const frozenStrips = enableResizeUI && effectiveFrozenCols > 0 ? buildResizeStrips(colAxis, frozenVisibleCols) : null
|
|
126
|
+
const track = (
|
|
127
|
+
<div ref={colTrackRef} className="aqsp-col-header-track">
|
|
128
|
+
{cells}
|
|
129
|
+
{strips?.map((strip) =>
|
|
130
|
+
strip.kind === "resize" ? (
|
|
131
|
+
<div key={`r${strip.index}`} className="aqsp-col-resize-strip" data-aqsp-col-resize={strip.index} style={{ left: strip.start - windowOriginX, width: strip.length }} />
|
|
132
|
+
) : (
|
|
133
|
+
<div key={`u${strip.index}`} className="aqsp-unhide-marker" data-aqsp-unhide={strip.index} style={{ left: strip.start - windowOriginX, width: strip.length }} />
|
|
134
|
+
),
|
|
135
|
+
)}
|
|
136
|
+
</div>
|
|
137
|
+
)
|
|
138
|
+
return (
|
|
139
|
+
<div ref={mergedBandRef} className="aqsp-col-header" aria-hidden="true" data-aqsp-col-header="">
|
|
140
|
+
{/* track transform は repaintColHeaders (命令パス) の専有物 — React は書かない (§7-5)。
|
|
141
|
+
pointerdown (フォーカス回収 + 選択機械) は useHeaderSelection が帯へ結線する (§6.8-1)。
|
|
142
|
+
v0.5: F > 0 はスクロール track を静的クリップ (left = W_F) で包み、凍結ラベルを
|
|
143
|
+
帯直下の静的 subtree に置く (エンジン §8-b/c のヘッダー同文。F = 0 は従来 DOM 恒等) */}
|
|
144
|
+
{effectiveFrozenCols === 0 ? (
|
|
145
|
+
track
|
|
146
|
+
) : (
|
|
147
|
+
<>
|
|
148
|
+
{frozenVisibleCols.map((col) => (
|
|
149
|
+
<div key={col} className="aqsp-col-header-cell" data-col={col} data-aqsp-frozen-header="" data-aqsp-in-selection={isInSelection(col) ? "" : undefined} style={{ left: colAxis.offsetOf(col), width: colAxis.sizeOf(col) }}>
|
|
150
|
+
{columnIndexToName(col)}
|
|
151
|
+
</div>
|
|
152
|
+
))}
|
|
153
|
+
{frozenStrips?.map((strip) =>
|
|
154
|
+
strip.kind === "resize" ? (
|
|
155
|
+
<div key={`fr${strip.index}`} className="aqsp-col-resize-strip" data-aqsp-col-resize={strip.index} style={{ left: strip.start, width: strip.length }} />
|
|
156
|
+
) : (
|
|
157
|
+
<div key={`fu${strip.index}`} className="aqsp-unhide-marker" data-aqsp-unhide={strip.index} style={{ left: strip.start, width: strip.length }} />
|
|
158
|
+
),
|
|
159
|
+
)}
|
|
160
|
+
<div className="aqsp-col-header-scroll" data-aqsp-col-header-scroll="" style={{ left: frozenWidth }}>
|
|
161
|
+
{track}
|
|
162
|
+
</div>
|
|
163
|
+
</>
|
|
164
|
+
)}
|
|
165
|
+
</div>
|
|
166
|
+
)
|
|
167
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Row header band (viewStore subscription #3). Renders only the rendering-range
|
|
3
|
+
* rows (overscan-inclusive) with window-origin-relative coordinates; the track's residual
|
|
4
|
+
* translate is owned EXCLUSIVELY by the imperative repaintHeaders path (§6.8-2).
|
|
5
|
+
*
|
|
6
|
+
* 行ヘッダー帯 (viewStore 購読 3)。描画レンジ (オーバースキャン込み) の行のみを
|
|
7
|
+
* ウィンドウ原点基準の小座標で描画。track の残差 translate は命令パス (repaintHeaders) の
|
|
8
|
+
* 専有物 — React レンダーは書かない (§6.8-2 の二重書き手禁止)。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { useWheelBridge, type VirtualGridHandle } from "@aiquants/virtualscroll"
|
|
12
|
+
import { type ReactNode, type RefObject, useCallback, useLayoutEffect, useSyncExternalStore } from "react"
|
|
13
|
+
import { type AxisModel, visibleIndicesInRange } from "../model/axisModel.ts"
|
|
14
|
+
import type { CellRange } from "../model/cellCoord.ts"
|
|
15
|
+
import { frozenHeightOf } from "../model/frozenPartition.ts"
|
|
16
|
+
import { SCROLLBAR_WIDTH, type ViewStore } from "../model/viewStore.ts"
|
|
17
|
+
import { buildResizeStrips } from "./resizeStrips.ts"
|
|
18
|
+
|
|
19
|
+
type RowHeaderProps = {
|
|
20
|
+
viewStore: ViewStore
|
|
21
|
+
rowAxis: AxisModel
|
|
22
|
+
handleRef: RefObject<VirtualGridHandle | null>
|
|
23
|
+
/** Band element registration for the header selection machine (§6.8-2). / 選択機械の帯登録先。 */
|
|
24
|
+
bandRef: RefObject<HTMLDivElement | null>
|
|
25
|
+
/** Selected-row emphasis source (§6.8-4; frozen at press during drags = known trait). / 選択中行の強調用 (ドラッグ中は押下時のまま凍結 = 既知特性)。 */
|
|
26
|
+
selectionRanges: readonly CellRange[]
|
|
27
|
+
/** Registration target for the track element (repaintHeaders writes here — owner: Spreadsheet §6.9-7). / track 要素の登録先。 */
|
|
28
|
+
rowTrackRef: RefObject<HTMLDivElement | null>
|
|
29
|
+
/** Shared ref of the window-origin absolute offset (§6.8-2 ordering contract). / ウィンドウ原点の共有 ref。 */
|
|
30
|
+
windowOriginYRef: RefObject<number>
|
|
31
|
+
/** Imperative repaint invoked pre-paint from the layout effect (§6.8-2 (a)). / paint 前の命令再描画。 */
|
|
32
|
+
repaintHeaders: () => void
|
|
33
|
+
/** §11 opt-out: false suppresses strip/marker DOM entirely (§7.1). / §11 の opt-out (false は DOM 生成ごと抑止)。 */
|
|
34
|
+
enableResizeUI: boolean
|
|
35
|
+
/** Frozen leading rows (v0.6 — drives the two-band split; the ColumnHeader two-band transpose). / 先頭凍結行数 (v0.6 — 2 帯分割の構造判定。ColumnHeader 2 帯の転置)。 */
|
|
36
|
+
frozenRows: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Renders row-number labels for the rendering window.
|
|
41
|
+
* 描画ウィンドウの行番号ラベルを描画 (1 始まり表示)。
|
|
42
|
+
*/
|
|
43
|
+
export const RowHeader = ({ viewStore, rowAxis, handleRef, bandRef, selectionRanges, rowTrackRef, windowOriginYRef, repaintHeaders, enableResizeUI, frozenRows }: RowHeaderProps): ReactNode => {
|
|
44
|
+
const bridgeRef = useWheelBridge(handleRef)
|
|
45
|
+
// 帯要素は選択機械 (bandRef) と wheel bridge の 2 消費者 — マージ ref で合成
|
|
46
|
+
const mergedBandRef = useCallback(
|
|
47
|
+
(el: HTMLDivElement | null) => {
|
|
48
|
+
bandRef.current = el
|
|
49
|
+
// bridge の戻り値 (クリーンアップ) をそのまま返す — useWheelBridge の正準形 (React 19)
|
|
50
|
+
return bridgeRef(el)
|
|
51
|
+
},
|
|
52
|
+
[bandRef, bridgeRef],
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
/** Whether the row intersects a selection range (§6.8-4). / 行が選択レンジに含まれるか。 */
|
|
56
|
+
const isInSelection = (row: number): boolean => selectionRanges.some((r) => row >= r.startRow && row <= r.endRow)
|
|
57
|
+
|
|
58
|
+
/** String snapshot for uSES bail (rowRange + rowGeomVersion). / bail 判定用スナップショット。 */
|
|
59
|
+
const getSnapshot = useCallback(() => {
|
|
60
|
+
const { rowStart, rowEnd, rowGeomVersion } = viewStore.get()
|
|
61
|
+
return `${rowStart}:${rowEnd}:${rowGeomVersion}`
|
|
62
|
+
}, [viewStore])
|
|
63
|
+
const subscribe = useCallback((listener: () => void) => viewStore.subscribeCoalesced(listener), [viewStore])
|
|
64
|
+
useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
|
|
65
|
+
const { rowStart, rowEnd } = viewStore.get()
|
|
66
|
+
|
|
67
|
+
// windowOriginY = offsetOf(rowStart)。React が新ウィンドウのラベルを配置し終えた後にだけ
|
|
68
|
+
// 命令パスへ見せる (§6.8-2 順序契約 (a)): commit 後の layout effect で共有 ref を更新し、
|
|
69
|
+
// 同じ effect 内で repaintHeaders を即時呼びして新原点の残差 transform を paint 前に書く
|
|
70
|
+
// §6.8-2 の式 (offsetOf(rowStart) 素通し) への意図的ガード: adapter 縮小直後は store の
|
|
71
|
+
// rowRange が旧 count 基準で stale になり得る (onRangeChange の再配送は rAF 遅れ)。
|
|
72
|
+
// クランプは多層防御 (FenwickMapTree も内部で index をクランプするため単独では等価 —
|
|
73
|
+
// 範囲外読みの契約をこの層でも明示する)
|
|
74
|
+
const windowOriginY = rowAxis.count === 0 ? 0 : rowAxis.offsetOf(Math.min(rowStart, rowAxis.count - 1))
|
|
75
|
+
useLayoutEffect(() => {
|
|
76
|
+
windowOriginYRef.current = windowOriginY
|
|
77
|
+
repaintHeaders()
|
|
78
|
+
}, [windowOriginY, windowOriginYRef, repaintHeaders])
|
|
79
|
+
|
|
80
|
+
// 凍結帯 (v0.6 計画 §3-2 — ColumnHeader 2 帯の転置): 実効 R / H_F を rowAxis から導出
|
|
81
|
+
const effectiveFrozenRows = Math.min(Math.max(0, frozenRows), rowAxis.count)
|
|
82
|
+
const frozenHeight = frozenHeightOf(rowAxis, effectiveFrozenRows)
|
|
83
|
+
// 凍結ラベルは静的 subtree — 木絶対 top の直接値 (H_F ≤ viewport で LayoutUnit 安全)
|
|
84
|
+
const frozenVisibleRows = effectiveFrozenRows > 0 ? visibleIndicesInRange(rowAxis, 0, effectiveFrozenRows - 1) : []
|
|
85
|
+
|
|
86
|
+
// スクロールラベルは床クランプ max(rowStart, R) — 凍結行を再出現させない (空窓 start > end は空列挙)
|
|
87
|
+
const visibleRows = visibleIndicesInRange(rowAxis, Math.max(rowStart, effectiveFrozenRows), Math.min(rowEnd, Math.max(0, rowAxis.count - 1)))
|
|
88
|
+
const labels: ReactNode[] = []
|
|
89
|
+
for (const row of visibleRows) {
|
|
90
|
+
labels.push(
|
|
91
|
+
<div key={row} className="aqsp-row-header-cell" data-row={row} data-aqsp-in-selection={isInSelection(row) ? "" : undefined} style={{ top: rowAxis.offsetOf(row) - windowOriginY, height: rowAxis.sizeOf(row) }}>
|
|
92
|
+
{row + 1}
|
|
93
|
+
</div>,
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
// §7.1: ストリップ / マーカーは track 末尾の後方兄弟。凍結境界の重複 unhide 遮断 (R3 是正の縦版)
|
|
97
|
+
const rawStrips = enableResizeUI ? buildResizeStrips(rowAxis, visibleRows) : null
|
|
98
|
+
const strips = rawStrips === null ? null : rawStrips.filter((strip) => !(strip.kind === "unhide" && strip.index < effectiveFrozenRows && frozenHeight > 0))
|
|
99
|
+
// 凍結帯のストリップ (静的配置 — 木絶対 top 直接値)
|
|
100
|
+
const frozenStrips = enableResizeUI && effectiveFrozenRows > 0 ? buildResizeStrips(rowAxis, frozenVisibleRows) : null
|
|
101
|
+
const track = (
|
|
102
|
+
<div ref={rowTrackRef} className="aqsp-row-header-track">
|
|
103
|
+
{labels}
|
|
104
|
+
{strips?.map((strip) =>
|
|
105
|
+
strip.kind === "resize" ? (
|
|
106
|
+
<div key={`r${strip.index}`} className="aqsp-row-resize-strip" data-aqsp-row-resize={strip.index} style={{ top: strip.start - windowOriginY, height: strip.length }} />
|
|
107
|
+
) : (
|
|
108
|
+
<div key={`u${strip.index}`} className="aqsp-unhide-marker" data-aqsp-unhide={strip.index} style={{ top: strip.start - windowOriginY, height: strip.length }} />
|
|
109
|
+
),
|
|
110
|
+
)}
|
|
111
|
+
</div>
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
// margin-bottom はグリッド内在の横バー帯ぶんの切り上げ — CSS は TS 定数を読めないため
|
|
115
|
+
// インライン指定 (SCROLLBAR_WIDTH の単一 SSOT — SelectionOverlay の right / bottom と同方式)
|
|
116
|
+
return (
|
|
117
|
+
<div ref={mergedBandRef} className="aqsp-row-header" aria-hidden="true" data-aqsp-row-header="" style={{ marginBottom: SCROLLBAR_WIDTH }}>
|
|
118
|
+
{/* track transform は repaintHeaders (命令パス) の専有物 — React は書かない (§6.8-2 (b))。
|
|
119
|
+
v0.6: R > 0 はスクロール track を静的クリップ (top = H_F) で包み、凍結ラベルを帯直下の
|
|
120
|
+
静的 subtree に置く (ColumnHeader の 2 帯を転置。R = 0 は従来 DOM 恒等) */}
|
|
121
|
+
{effectiveFrozenRows === 0 ? (
|
|
122
|
+
track
|
|
123
|
+
) : (
|
|
124
|
+
<>
|
|
125
|
+
{frozenVisibleRows.map((row) => (
|
|
126
|
+
<div key={row} className="aqsp-row-header-cell" data-row={row} data-aqsp-frozen-row-header="" data-aqsp-in-selection={isInSelection(row) ? "" : undefined} style={{ top: rowAxis.offsetOf(row), height: rowAxis.sizeOf(row) }}>
|
|
127
|
+
{row + 1}
|
|
128
|
+
</div>
|
|
129
|
+
))}
|
|
130
|
+
{frozenStrips?.map((strip) =>
|
|
131
|
+
strip.kind === "resize" ? (
|
|
132
|
+
<div key={`fr${strip.index}`} className="aqsp-row-resize-strip" data-aqsp-row-resize={strip.index} style={{ top: strip.start, height: strip.length }} />
|
|
133
|
+
) : (
|
|
134
|
+
<div key={`fu${strip.index}`} className="aqsp-unhide-marker" data-aqsp-unhide={strip.index} style={{ top: strip.start, height: strip.length }} />
|
|
135
|
+
),
|
|
136
|
+
)}
|
|
137
|
+
<div className="aqsp-row-header-scroll" data-aqsp-row-header-scroll="" style={{ top: frozenHeight }}>
|
|
138
|
+
{track}
|
|
139
|
+
</div>
|
|
140
|
+
</>
|
|
141
|
+
)}
|
|
142
|
+
</div>
|
|
143
|
+
)
|
|
144
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Excel-style column name conversion (bijective base-26).
|
|
3
|
+
* Excel 形式の列名変換 (全単射 26 進)。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Column index (0-based) to Excel-style name (0 → A, 26 → AA, 16383 → XFD). / 列 index → 列名。 */
|
|
7
|
+
export const columnIndexToName = (index: number): string => {
|
|
8
|
+
if (!(Number.isInteger(index) && index >= 0)) {
|
|
9
|
+
throw new RangeError(`[spreadsheet] columnIndexToName requires a non-negative integer, received ${index}`)
|
|
10
|
+
}
|
|
11
|
+
let name = ""
|
|
12
|
+
let n = index
|
|
13
|
+
// 全単射 26 進: 各桁は 1..26 (0 が無い) ため、剰余の後に 1 引いてから桁送りする
|
|
14
|
+
while (n >= 0) {
|
|
15
|
+
name = String.fromCharCode(65 + (n % 26)) + name
|
|
16
|
+
n = Math.trunc(n / 26) - 1
|
|
17
|
+
}
|
|
18
|
+
return name
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Excel-style name to column index (inverse). / 列名 → 列 index (逆変換)。 */
|
|
22
|
+
export const columnNameToIndex = (name: string): number => {
|
|
23
|
+
if (!/^[A-Z]+$/.test(name)) {
|
|
24
|
+
throw new RangeError(`[spreadsheet] columnNameToIndex requires /^[A-Z]+$/, received "${name}"`)
|
|
25
|
+
}
|
|
26
|
+
let index = 0
|
|
27
|
+
for (const ch of name) {
|
|
28
|
+
index = index * 26 + (ch.charCodeAt(0) - 64)
|
|
29
|
+
}
|
|
30
|
+
return index - 1
|
|
31
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Resize entry gates (v0.8 §4.1.14) — the three gates that every track-size
|
|
3
|
+
* batch passes before the resizeTracks seam, extracted from useHeaderResize
|
|
4
|
+
* (applyEntries / collectTargets) so the header drag, the paste-special dimensions path and
|
|
5
|
+
* the hide / unhide commands share ONE decision. Order: (1) the ADR-12 track-count gate on
|
|
6
|
+
* the RAW entry count (before materializing — duplicates counted as-is, conservative; rows
|
|
7
|
+
* get a 10x lower ceiling than columns, §13 budget row (f)); (2) one rounding pass with the
|
|
8
|
+
* axis.resize fail-fast pulled forward (non-finite / negative sizes throw RangeError here so a
|
|
9
|
+
* batch never half-applies), each size clamped to MAX_TRACK_SIZE_PX (fail-soft — hostile pasted
|
|
10
|
+
* sizes cannot wedge the non-undoable grid geometry) and duplicate indices folded to their last
|
|
11
|
+
* size; (3) the §7.2
|
|
12
|
+
* post-application zero-guard (the pressed track — or the first entry — clamps to 1 px when
|
|
13
|
+
* the whole axis would go invisible); (4) the §5.4(b) row-axis hidden-total gate, counted on
|
|
14
|
+
* the clamped entries. Pure: reads the axis model, never writes it.
|
|
15
|
+
*
|
|
16
|
+
* リサイズ entry ゲート (v0.8 §4.1.14) — resizeTracks シームに入る全トラック寸法バッチが通る
|
|
17
|
+
* 3 ゲート。useHeaderResize (applyEntries / collectTargets) から抽出し、ヘッダードラッグ・
|
|
18
|
+
* 貼り付け寸法・hide / unhide コマンドが単一判定を共有する。順序: (1) ADR-12 のトラック数
|
|
19
|
+
* ゲートは生 entry 数で実体化前に判定 (重複は重複のまま数える保守側、行は列の 1/10 上限 —
|
|
20
|
+
* §13 予算行 (f))、(2) 丸めは 1 度だけ + axis.resize の fail-fast 先取り (非有限 / 負サイズは
|
|
21
|
+
* ここで RangeError — バッチの部分適用を作らない) + 各サイズを MAX_TRACK_SIZE_PX へクランプ
|
|
22
|
+
* (fail-soft — 敵対的貼り付け寸法が非 undo の格子幾何を破壊しない) + 同一 index は後勝ちで
|
|
23
|
+
* 畳む、(3) §7.2 の
|
|
24
|
+
* 適用後 0 化ガード (全可視消滅なら押下トラック — 無ければ先頭 entry — を 1px へクランプ)、
|
|
25
|
+
* (4) §5.4(b) の行軸非表示総数ゲートはクランプ後の entries で数える。純関数 — 軸モデルは
|
|
26
|
+
* 読むだけで書かない。
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import type { AxisModel } from "../model/axisModel.ts"
|
|
30
|
+
|
|
31
|
+
/** Row-axis hidden-track ceiling for UI-driven hides (§5.4(b) — the virtualscroll 2000-item window cutoff bound). / UI 経由の行非表示総数上限 (§5.4(b))。 */
|
|
32
|
+
export const ROW_HIDDEN_LIMIT = 1_000
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Upper track-size bound (logical px) for every gated batch and for the size trust boundaries
|
|
36
|
+
* (wire validator, HTML reader, copy writer). Generously above Excel's native caps (255-char
|
|
37
|
+
* column ≈ 1,790 px, 409-pt row ≈ 545 px), yet small enough that a hostile pasted size can
|
|
38
|
+
* never wedge the grid geometry — sizes are non-undoable (ADR-16), so an unbounded width
|
|
39
|
+
* (e.g. 1e24 px) would leave every column to its right unreachable with no in-UI recovery.
|
|
40
|
+
* 全ゲート通過バッチと寸法の信頼境界 (wire 検証器・HTML reader・copy writer) が共有する
|
|
41
|
+
* トラック寸法上限 (論理 px)。Excel の原生上限 (255 文字列幅 ≈ 1,790 px、409pt 行高 ≈ 545 px)
|
|
42
|
+
* を大きく上回りつつ、敵対的貼り付け寸法による格子幾何の破壊 (寸法は非 undo — ADR-16) を
|
|
43
|
+
* 防げる小ささに設定。
|
|
44
|
+
*/
|
|
45
|
+
export const MAX_TRACK_SIZE_PX = 10_000
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Row fan-out limit divisor (§13 budget row (f) DESIGN RETURN, measured): updateItemSize
|
|
49
|
+
* carries a per-call content-size/anchor compensation that cannot be batched away — a
|
|
50
|
+
* 20k-row batch measured 339ms median (over the 300ms budget), so row fan-outs get a 10x
|
|
51
|
+
* lower ceiling than columns (default 100k/10 = 10k rows ≈ 170ms class).
|
|
52
|
+
* 行ファンアウト上限の除数 (§13 予算行 (f) の設計返却 — 実測起点)。updateItemSize の per-call
|
|
53
|
+
* 補償はバッチ化で消せず、2 万行で中央値 339ms (予算 300ms 超過)。行は列の 1/10 上限
|
|
54
|
+
* (既定 10 万 / 10 = 1 万行 ≈ 170ms 級)。
|
|
55
|
+
*/
|
|
56
|
+
export const ROW_FANOUT_DIVISOR = 10
|
|
57
|
+
|
|
58
|
+
/** One track-size entry of a resize batch (size in logical px, 0 = hidden). / リサイズバッチの 1 entry (論理 px、0 = 非表示)。 */
|
|
59
|
+
export type ResizeEntry = { index: number; size: number }
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Gate parameters: the ADR-12 operation ceiling, the row hidden-total ceiling and which axis
|
|
63
|
+
* the batch targets (rows get the fan-out divisor and the hidden-total gate).
|
|
64
|
+
* ゲートのパラメータ: ADR-12 の操作上限、行非表示総数上限、対象軸 (行はファンアウト除数と
|
|
65
|
+
* 非表示総数ゲートの対象)。
|
|
66
|
+
*/
|
|
67
|
+
export type ResizeGateLimits = { maxOperationCells: number; rowHiddenLimit: number; axisKind: "row" | "col" }
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Gate outcome: the rounded, de-duplicated, zero-guarded entries ready for resizeTracks, or a
|
|
71
|
+
* rejection carrying the onOperationRejected reason and count ("limit" = raw entry count,
|
|
72
|
+
* "bounds" = the would-be hidden-row total).
|
|
73
|
+
* ゲート結果: resizeTracks へ渡せる丸め済み・重複畳み済み・0 化ガード済み entries、または
|
|
74
|
+
* onOperationRejected 用の理由と件数 ("limit" = 生 entry 数、"bounds" = 適用後の非表示行総数)。
|
|
75
|
+
*/
|
|
76
|
+
export type ResizeGateResult = { ok: true; entries: readonly ResizeEntry[] } | { ok: false; reason: "limit" | "bounds"; count: number }
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Sums the hidden-state transitions of a batch against the axis' stored sizes (+1 per
|
|
80
|
+
* visible→hidden, -1 per hidden→visible — implicit unhide, Excel-compatible). Compared on
|
|
81
|
+
* ROUNDED sizes, matching what resizeTracks / axis.resize will store.
|
|
82
|
+
* バッチの非表示遷移差分を格納サイズと突き合わせて合計 (可視→非表示 +1、非表示→可視 -1 —
|
|
83
|
+
* 暗黙再表示)。resizeTracks / axis.resize の格納値と一致させるため丸め後サイズで比較。
|
|
84
|
+
*/
|
|
85
|
+
const deltaHidden = (axis: AxisModel, entries: readonly ResizeEntry[]): number =>
|
|
86
|
+
entries.reduce((sum, entry) => {
|
|
87
|
+
const wasHidden = axis.sizeOf(entry.index) === 0
|
|
88
|
+
const willHide = entry.size === 0
|
|
89
|
+
return sum + (willHide && !wasHidden ? 1 : 0) - (!willHide && wasHidden ? 1 : 0)
|
|
90
|
+
}, 0)
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Runs a resize batch through the three gates (count → zero-guard → row hidden-total) and
|
|
94
|
+
* returns the entries to hand to resizeTracks, or the rejection. Empty batches are a no-op
|
|
95
|
+
* (`ok` with no entries — nothing to gate, and the zero-guard must not read a first entry).
|
|
96
|
+
* Non-finite / negative sizes throw RangeError before anything is materialized (fail-fast on
|
|
97
|
+
* the raw value, so -0.3 is rejected even though it would round to 0); finite sizes above
|
|
98
|
+
* MAX_TRACK_SIZE_PX clamp to it (fail-soft — the paste path carries untrusted sizes). Indices
|
|
99
|
+
* are not range-checked — same contract as resizeTracks / axis.resize.
|
|
100
|
+
* リサイズバッチを 3 ゲート (件数 → 0 化ガード → 行非表示総数) に通し、resizeTracks へ渡す
|
|
101
|
+
* entries か拒否を返す。空バッチは no-op (entries 無しの `ok` — 判定対象が無く、0 化ガードが
|
|
102
|
+
* 先頭 entry を読んではならない)。非有限 / 負サイズは実体化前に RangeError (丸め前の生値で
|
|
103
|
+
* fail-fast — -0.3 は 0 に丸まるが拒否)、有限の上限超は MAX_TRACK_SIZE_PX へクランプ (fail-soft —
|
|
104
|
+
* 貼り付け経路は外来寸法を運ぶ)。index の範囲検査は行わない (resizeTracks / axis.resize と
|
|
105
|
+
* 同じ契約)。
|
|
106
|
+
*/
|
|
107
|
+
export const gateResizeEntries = (axis: AxisModel, entries: readonly ResizeEntry[], pressedIndex: number | null, limits: ResizeGateLimits): ResizeGateResult => {
|
|
108
|
+
// 空バッチは no-op (resizeTracks も空で早期 return — ゲートの判定対象が無い)
|
|
109
|
+
if (entries.length === 0) {
|
|
110
|
+
return { ok: true, entries: [] }
|
|
111
|
+
}
|
|
112
|
+
// ゲート 1 (ADR-12): 実体化前の保守カウント (重複 index は重複のまま数え、上限判定を保守側に倒す —
|
|
113
|
+
// collectTargets と同じ流儀)。行軸は (f) の設計返却で 1/10 上限 (updateItemSize の per-call 補償コスト)
|
|
114
|
+
const limit = limits.axisKind === "row" ? Math.floor(limits.maxOperationCells / ROW_FANOUT_DIVISOR) : limits.maxOperationCells
|
|
115
|
+
if (entries.length > limit) {
|
|
116
|
+
return { ok: false, reason: "limit", count: entries.length }
|
|
117
|
+
}
|
|
118
|
+
// 丸めは 1 度だけ (resizeTracks / axis.resize の格納と一致させる)。検証は丸め前の生値で
|
|
119
|
+
// axis.resize と同じ条件 (先取りしてバッチの部分適用を作らない)。同一 index は後勝ちで畳む
|
|
120
|
+
const sizeByIndex = new Map<number, number>()
|
|
121
|
+
for (const entry of entries) {
|
|
122
|
+
if (!(Number.isFinite(entry.size) && entry.size >= 0)) {
|
|
123
|
+
throw new RangeError(`[spreadsheet] gateResizeEntries requires a finite size >= 0, received ${entry.size} at index ${entry.index}`)
|
|
124
|
+
}
|
|
125
|
+
// 上限は fail-soft のクランプ (RangeError でない): 貼り付け寸法は外来信頼境界由来で、
|
|
126
|
+
// 寸法は非 undo の幾何 (ADR-16) — 1e24 px を通すと右側全列が到達不能のまま復旧手段が無い
|
|
127
|
+
sizeByIndex.set(entry.index, Math.min(Math.round(entry.size), MAX_TRACK_SIZE_PX))
|
|
128
|
+
}
|
|
129
|
+
let gated: ResizeEntry[] = [...sizeByIndex].map(([index, size]) => ({ index, size }))
|
|
130
|
+
// ゲート 2 (§7.2): 0 化ガードの一般形 — press 時本数でなく適用後の可視本数で判定 (Ctrl+A 後の
|
|
131
|
+
// 全列ファンアウト 0 化がガードを素通りする穴の閉塞)。押下トラックのみ最小 1px でクランプ
|
|
132
|
+
const visibleAfter = axis.count - axis.hiddenCount() - deltaHidden(axis, gated)
|
|
133
|
+
if (visibleAfter === 0) {
|
|
134
|
+
// 押下トラックが無い経路 (unhide 復元など) は先頭 entry をクランプ対象にする
|
|
135
|
+
const clampIndex = pressedIndex === null ? gated[0].index : pressedIndex
|
|
136
|
+
gated = gated.map((entry) => (entry.index === clampIndex ? { index: entry.index, size: 1 } : entry))
|
|
137
|
+
}
|
|
138
|
+
// ゲート 3 (§5.4(b)): 行非表示の軸総数ゲート — 行軸専用、暗黙再表示込み・クランプ後の適用後判定
|
|
139
|
+
if (limits.axisKind === "row") {
|
|
140
|
+
const hiddenAfter = axis.hiddenCount() + deltaHidden(axis, gated)
|
|
141
|
+
if (hiddenAfter > limits.rowHiddenLimit) {
|
|
142
|
+
return { ok: false, reason: "bounds", count: hiddenAfter }
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return { ok: true, entries: gated }
|
|
146
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Pure geometry for the §7.1 resize strips and unhide markers. Strips are
|
|
3
|
+
* hit-test/visual DOM ONLY (no listeners — the band's wire-once capture listener dispatches
|
|
4
|
+
* via closest()). Boundary rules: a normal track (width >= 12) gets a 6px strip INSIDE its
|
|
5
|
+
* right/bottom boundary; a boundary followed by a hidden run splits the same 6px into
|
|
6
|
+
* 3px resize + 3px unhide (structural exclusivity — no closest() priority problem); a
|
|
7
|
+
* degenerate track (width < 12) gets a boundary-anchored 3px strip overhanging the neighbor
|
|
8
|
+
* (the affordance never disappears — §7.1 recovery guarantee). A leading hidden run gets a
|
|
9
|
+
* 3px unhide marker on the first visible track's leading edge. Unhide markers are emitted
|
|
10
|
+
* AFTER all resize strips so DOM order stacks them on top (degenerate priority rule).
|
|
11
|
+
*
|
|
12
|
+
* §7.1 のリサイズストリップ / unhide マーカーの純幾何。ストリップは視覚とヒット判定のための
|
|
13
|
+
* DOM のみ (リスナーなし — 帯の wire-once capture リスナーが closest() でディスパッチ)。通常
|
|
14
|
+
* トラック (幅 ≥ 12) は境界の内側 6px、非表示ラン境界は同じ 6px を 3px リサイズ + 3px unhide
|
|
15
|
+
* に分割 (構造的排他)、縮退トラック (幅 < 12) は境界アンカー 3px の隣接 overhang (アフォー
|
|
16
|
+
* ダンスは消えない — 復旧保証)。先頭非表示ランは最初の可視トラックの先頭端 3px。unhide は
|
|
17
|
+
* リサイズ全件の後に出力し DOM 順で上層に積む (縮退時のマーカー優先則)。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { type AxisModel, nextVisibleIndex } from "../model/axisModel.ts"
|
|
21
|
+
|
|
22
|
+
/** Full strip width in px on a normal boundary. / 通常境界のストリップ全幅 (px)。 */
|
|
23
|
+
const STRIP_FULL = 6
|
|
24
|
+
/** Half width for split / degenerate boundaries. / 分割 / 縮退境界の半幅 (px)。 */
|
|
25
|
+
const STRIP_HALF = 3
|
|
26
|
+
|
|
27
|
+
/** One strip/marker: `start`/`length` in logical axis px. / ストリップ 1 本 (論理軸 px)。 */
|
|
28
|
+
export type ResizeStripDescriptor = {
|
|
29
|
+
kind: "resize" | "unhide"
|
|
30
|
+
/** resize: the track whose boundary this is; unhide: the boundary-nearest hidden track to restore. / resize は境界の持ち主トラック、unhide は境界に最も近い復元対象の非表示トラック。 */
|
|
31
|
+
index: number
|
|
32
|
+
start: number
|
|
33
|
+
length: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Builds the strip/marker descriptors for the visible tracks of one window. `visible` comes
|
|
38
|
+
* from visibleIndicesInRange; hidden-run detection uses the AXIS-wide next visible track (a
|
|
39
|
+
* window-final track's run may extend beyond the window).
|
|
40
|
+
* 1 ウィンドウの可視トラック群からストリップ / マーカー記述子を構築。非表示ラン判定は軸全体の
|
|
41
|
+
* 次可視 (窓末尾トラックのランは窓外へ続き得る) で行う。
|
|
42
|
+
*/
|
|
43
|
+
export const buildResizeStrips = (axis: AxisModel, visible: readonly number[]): ResizeStripDescriptor[] => {
|
|
44
|
+
const strips: ResizeStripDescriptor[] = []
|
|
45
|
+
const markers: ResizeStripDescriptor[] = []
|
|
46
|
+
if (visible.length === 0) {
|
|
47
|
+
return strips
|
|
48
|
+
}
|
|
49
|
+
// 先頭マーカー: 窓先頭トラックの直前トラックが**実際に非表示**の場合のみ (R1(B) 実証欠陥の
|
|
50
|
+
// 閉塞 — `first > 0` 単独は「スクロールで窓が進んだ」と「先頭ランが隠れている」を混同し、
|
|
51
|
+
// 全可視軸のスクロール窓に可視トラック宛の偽 unhide マーカーを毎窓生成していた)。軸判定に
|
|
52
|
+
// より index 0 を含むラン (「列 A を再表示できない」古典問題) と窓境界を跨ぐランの両方を
|
|
53
|
+
// 正しく覆う。復元対象は境界に近い側 = ランの末尾 index
|
|
54
|
+
const first = visible[0]
|
|
55
|
+
if (first > 0 && axis.sizeOf(first - 1) === 0) {
|
|
56
|
+
markers.push({ kind: "unhide", index: first - 1, start: axis.offsetOf(first), length: STRIP_HALF })
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
for (const track of visible) {
|
|
60
|
+
const size = axis.sizeOf(track)
|
|
61
|
+
const boundary = axis.offsetOf(track) + size
|
|
62
|
+
// 非表示ラン判定は軸全体で行う (窓内の隣接可視だけを見ると窓末尾のランを見落とす)
|
|
63
|
+
const next = nextVisibleIndex(axis, track, 1)
|
|
64
|
+
const hiddenAfter = track < axis.count - 1 && (next === null || next > track + 1)
|
|
65
|
+
if (hiddenAfter) {
|
|
66
|
+
// 3px/3px 分割 (両機能の恒久排他)。復元対象は境界に近い側 = ランの先頭 index
|
|
67
|
+
strips.push({ kind: "resize", index: track, start: boundary - STRIP_FULL, length: STRIP_HALF })
|
|
68
|
+
markers.push({ kind: "unhide", index: track + 1, start: boundary - STRIP_HALF, length: STRIP_HALF })
|
|
69
|
+
} else if (size >= 2 * STRIP_FULL) {
|
|
70
|
+
// 通常トラック: 境界の内側 6px (隣接セル先頭の押下は選択のまま — §10 逸脱)
|
|
71
|
+
strips.push({ kind: "resize", index: track, start: boundary - STRIP_FULL, length: STRIP_FULL })
|
|
72
|
+
} else {
|
|
73
|
+
// 縮退トラック: 境界アンカー 3px (自トラックに収まらない分は隣へ overhang — 復旧保証)
|
|
74
|
+
strips.push({ kind: "resize", index: track, start: boundary - STRIP_HALF, length: STRIP_HALF })
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// マーカーを後置 — DOM 順の後勝ち積層で復元アフォーダンスを上層へ (縮退時の優先則)
|
|
78
|
+
return [...strips, ...markers]
|
|
79
|
+
}
|