@aiquants/virtualscroll 3.8.2 → 3.9.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 +69 -0
- package/README.md +112 -24
- package/dist/ScrollBar.d.cts +8 -2
- package/dist/ScrollBar.d.ts +8 -2
- package/dist/ScrollBar.d.ts.map +1 -1
- package/dist/VirtualScroll.d.cts +122 -19
- package/dist/VirtualScroll.d.ts +122 -19
- package/dist/VirtualScroll.d.ts.map +1 -1
- package/dist/devicePixelGrid.d.cts +78 -0
- package/dist/devicePixelGrid.d.ts +79 -0
- package/dist/devicePixelGrid.d.ts.map +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2143 -2092
- package/package.json +1 -1
- package/src/ScrollBar.tsx +24 -8
- package/src/VirtualScroll.tsx +332 -200
- package/src/devicePixelGrid.ts +308 -0
- package/src/index.ts +2 -0
package/src/VirtualScroll.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import React, { forwardRef, type ReactNode, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState
|
|
1
|
+
import React, { forwardRef, type ReactNode, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from "react"
|
|
2
|
+
import { type DevicePixelSnapEdge, snapToDevicePixelGrid, usePaintingDevicePixelRatio } from "./devicePixelGrid.ts"
|
|
2
3
|
import { resolveVirtualScrollLabels, type VirtualScrollLabelOverrides, type VirtualScrollLocale } from "./labels.ts"
|
|
3
4
|
import { Logger } from "./logger.ts"
|
|
4
5
|
import { ScrollPane, type ScrollPaneContentInsets, type ScrollPaneHandle, type ScrollPaneProps } from "./ScrollPane.tsx"
|
|
@@ -25,6 +26,44 @@ export type VirtualScrollRange = {
|
|
|
25
26
|
totalHeight: number
|
|
26
27
|
}
|
|
27
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Why VirtualScroll moved its scroll position on its own (see `VirtualScrollProps["onScrollAdjust"]`).
|
|
31
|
+
*
|
|
32
|
+
* - `"item-resize"`: `updateItemSize` changed the height of a row above the first visible row, and the position moved by
|
|
33
|
+
* the same delta, so the visible rows stay where they were.
|
|
34
|
+
* - `"reconciliation"`: a render found that `getItemHeight` returns a new height for a rendered row above the first
|
|
35
|
+
* visible row; the height reconciliation that follows the render (a microtask) moved the position by the same delta.
|
|
36
|
+
* - `"drift"`: after a size change (content, viewport, item count or insets) the pending alignment of the last
|
|
37
|
+
* `scrollToIndex` (or of `initialScrollAnchor`) was pinned again, in an effect after the commit.
|
|
38
|
+
* - `"re-issue"`: a compensation that the pane clamped against the previous content size was issued again once the new
|
|
39
|
+
* content size had committed, in an effect after the commit.
|
|
40
|
+
*
|
|
41
|
+
* VirtualScroll が自分でスクロール位置を動かした理由 (`VirtualScrollProps["onScrollAdjust"]` を参照)。
|
|
42
|
+
*
|
|
43
|
+
* - `"item-resize"`: `updateItemSize` が先頭の可視行より上の行の高さを変え、位置を同じ差だけ動かした (見えている行はその場に
|
|
44
|
+
* 留まる)。
|
|
45
|
+
* - `"reconciliation"`: 描画が、描いた行のうち先頭の可視行より上の行について `getItemHeight` の新しい高さを見つけ、描画の後の
|
|
46
|
+
* 高さの照合 (マイクロタスク) が位置を同じ差だけ動かした。
|
|
47
|
+
* - `"drift"`: 寸法の変化 (中身・ビューポート・件数・インセット) の後、最後の `scrollToIndex` (または `initialScrollAnchor`)
|
|
48
|
+
* の保留中の揃えを、確定の後の effect で留め直した。
|
|
49
|
+
* - `"re-issue"`: ペインが前の中身の寸法でクランプした補正を、新しい中身の寸法が確定した後の effect でもう一度発行した。
|
|
50
|
+
*/
|
|
51
|
+
export type VirtualScrollAdjustmentCause = "item-resize" | "reconciliation" | "drift" | "re-issue"
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* One position change VirtualScroll made on its own (the argument of `VirtualScrollProps["onScrollAdjust"]`).
|
|
55
|
+
*
|
|
56
|
+
* VirtualScroll が自分で行った 1 回の位置の変化 (`VirtualScrollProps["onScrollAdjust"]` の引数)。
|
|
57
|
+
*/
|
|
58
|
+
export type VirtualScrollAdjustment = {
|
|
59
|
+
/** The LOGICAL scroll position after the change — what `getScrollPosition()` returns at that moment / 変化の後の論理スクロール位置 (その時点の `getScrollPosition()` の値) */
|
|
60
|
+
readonly position: number
|
|
61
|
+
/** The applied change in LOGICAL px: `position` minus the position before the change; never 0 / 適用した変化 (論理 px)。`position` から変化の前の位置を引いた値で、0 にはならない */
|
|
62
|
+
readonly delta: number
|
|
63
|
+
/** Why the position moved / 位置が動いた理由 */
|
|
64
|
+
readonly cause: VirtualScrollAdjustmentCause
|
|
65
|
+
}
|
|
66
|
+
|
|
28
67
|
/**
|
|
29
68
|
* Imperative handle of VirtualScroll. Every position it accepts or returns is in the
|
|
30
69
|
* LOGICAL coordinate space (content px, insets excluded) — the same space as onScroll /
|
|
@@ -98,7 +137,15 @@ export type VirtualScrollHandle = {
|
|
|
98
137
|
getContentSize: () => number
|
|
99
138
|
/** Viewport size / ビューポートの高さ。未接続時は -1 */
|
|
100
139
|
getViewportSize: () => number
|
|
101
|
-
/**
|
|
140
|
+
/**
|
|
141
|
+
* Scrolls to a specific item index, landing exactly on the aligned position (clamped to the content). A `"top"`
|
|
142
|
+
* (default) or `"bottom"` alignment is remembered at that position, so the device-pixel snap of the items wrapper
|
|
143
|
+
* keeps the aligned edge there (see the README's device-pixel snapping section).
|
|
144
|
+
*
|
|
145
|
+
* 指定したアイテムインデックスへスクロールする処理。揃えた位置 (中身の範囲へクランプ) へ厳密に着地する。`"top"` (既定) と
|
|
146
|
+
* `"bottom"` の揃えはその位置で覚えるので、そこでは行ラッパーの装置の画素への揃えが揃えた端を守る (README の装置の画素への
|
|
147
|
+
* 揃えの節を参照)。
|
|
148
|
+
*/
|
|
102
149
|
scrollToIndex: (index: number, options?: { align?: "top" | "bottom" | "center"; offset?: number }) => void
|
|
103
150
|
/** Gets the total height managed by the Fenwick Tree / Fenwick Tree で管理されている総高さを取得 */
|
|
104
151
|
getFenwickTreeTotalHeight: () => number
|
|
@@ -119,10 +166,15 @@ export type VirtualScrollHandle = {
|
|
|
119
166
|
* `getItemHeight(index)` must return the same `size`; `getItemHeight` is the source of truth,
|
|
120
167
|
* so if it keeps returning the old value, rows inside the current rendering window (including
|
|
121
168
|
* overscan) are reverted to the `getItemHeight` value by height reconciliation on the next render.
|
|
169
|
+
* When the item lies above the first visible row, the scroll position moves by the size change
|
|
170
|
+
* before this returns (layout-shift compensation), and `onScrollAdjust` reports it with the cause
|
|
171
|
+
* `"item-resize"`.
|
|
122
172
|
*
|
|
123
173
|
* 特定のアイテムのサイズを手動で更新。契約: 呼び出し後は `getItemHeight(index)` も同じ値を
|
|
124
174
|
* 返すこと。`getItemHeight` が正であるため、旧値を返し続けると描画ウィンドウ (オーバースキャン
|
|
125
|
-
* 含む) 内の行は次レンダーの高さ照合で `getItemHeight`
|
|
175
|
+
* 含む) 内の行は次レンダーの高さ照合で `getItemHeight` の値へ巻き戻る。アイテムが先頭の可視行より
|
|
176
|
+
* 上にあるときは、戻る前にスクロール位置をサイズの変化だけ動かし (レイアウトシフトの補正)、
|
|
177
|
+
* `onScrollAdjust` が理由 `"item-resize"` で知らせる。
|
|
126
178
|
*/
|
|
127
179
|
updateItemSize: (index: number, size: number) => void
|
|
128
180
|
}
|
|
@@ -330,6 +382,33 @@ export type VirtualScrollProps<T> = {
|
|
|
330
382
|
testId?: string
|
|
331
383
|
onScroll?: (scrollPosition: number, totalHeight: number) => void
|
|
332
384
|
onRangeChange?: (range: VirtualScrollRange) => void
|
|
385
|
+
/**
|
|
386
|
+
* Called synchronously, without throttling, each time VirtualScroll moves its scroll position on its own: a
|
|
387
|
+
* layout-shift compensation (`"item-resize"`, `"reconciliation"`), the re-pinning of a pending alignment
|
|
388
|
+
* (`"drift"`) or the second stage of a clamped compensation (`"re-issue"`) — see `VirtualScrollAdjustmentCause`.
|
|
389
|
+
* It runs after the change is complete, so `getScrollPosition()` and `getScrollAnchor()` read inside it already
|
|
390
|
+
* see the adjusted position and row heights. It never runs for scrolls that the user or the host start (wheel,
|
|
391
|
+
* drag, scrollbar, inertia, keyboard row navigation, `scrollTo` / `scrollBy` / `scrollToIndex` / `applyWheel`),
|
|
392
|
+
* nor for a change that leaves `getScrollPosition()` where it was.
|
|
393
|
+
*
|
|
394
|
+
* `onScroll` and `onRangeChange` report every position, but throttled and one frame later. A host that keeps its
|
|
395
|
+
* own scroll anchor by item identity (re-finding the first visible item by key after a list change) records that
|
|
396
|
+
* anchor from the range report and from its own scrolls; it must also record it from here, or a list change
|
|
397
|
+
* committed before the next range report restores a position VirtualScroll has already moved.
|
|
398
|
+
*
|
|
399
|
+
* VirtualScroll が自分でスクロール位置を動かすたびに、間引かず同期で呼ぶ関数。レイアウトシフトの補正
|
|
400
|
+
* (`"item-resize"`・`"reconciliation"`)、保留中の揃えの留め直し (`"drift"`)、クランプされた補正の二段目
|
|
401
|
+
* (`"re-issue"`) が対象 (`VirtualScrollAdjustmentCause` を参照)。変化を終えてから呼ぶので、中で読む
|
|
402
|
+
* `getScrollPosition()` と `getScrollAnchor()` は動かした後の位置と行の高さを返す。利用者やホストが始めた
|
|
403
|
+
* スクロール (ホイール・ドラッグ・スクロールバー・慣性・行のキーボード移動・`scrollTo` / `scrollBy` /
|
|
404
|
+
* `scrollToIndex` / `applyWheel`) と、`getScrollPosition()` を変えない変化では呼ばない。
|
|
405
|
+
*
|
|
406
|
+
* `onScroll` と `onRangeChange` はどの位置も知らせるが、間引いたうえで 1 フレーム遅れる。項目の同一性で自前の
|
|
407
|
+
* スクロールの錨を持つホスト (一覧の変化の後に先頭の可視項目をキーで探し直す) は、範囲の知らせと自分のスクロールで
|
|
408
|
+
* 錨を記録するが、ここでも記録すること。さもないと、次の範囲の知らせより前に確定した一覧の変化が、VirtualScroll が
|
|
409
|
+
* 既に動かした位置を巻き戻す。
|
|
410
|
+
*/
|
|
411
|
+
onScrollAdjust?: (adjustment: VirtualScrollAdjustment) => void
|
|
333
412
|
/**
|
|
334
413
|
* Opt-in `aria-live` region announcing the visible range to assistive technology (default:
|
|
335
414
|
* none rendered). Virtualization removes off-screen rows from the DOM, so a screen-reader
|
|
@@ -557,127 +636,92 @@ export const MAX_RENDERED_ITEMS = 2000
|
|
|
557
636
|
export const ANCHOR_REBASE_DISTANCE = 1_048_576 // 2^20 px — VirtualGrid の横アンカーが共有 import する (パッケージバレルへは非公開)
|
|
558
637
|
|
|
559
638
|
/**
|
|
560
|
-
*
|
|
561
|
-
*
|
|
562
|
-
*
|
|
563
|
-
*
|
|
564
|
-
*
|
|
565
|
-
* Module-level export (NOT in the package barrel).
|
|
566
|
-
*
|
|
567
|
-
* CSS px のオフセットをウィンドウの装置の画素の格子へ揃える処理。装置 px で整数になる最も近いオフセット
|
|
568
|
-
* `Math.round(cssPx × ratio) / ratio` (ちょうど半分は `Math.round` どおり +∞ 側)。
|
|
569
|
-
* 行ラッパーの平行移動はここを通す — 合成層を装置の画素の端数だけ動かすとブラウザは層全体を再標本化し
|
|
570
|
-
* (2px の輪郭・隙間・輪が隣の装置の行へ滲み、文字もぼける)、層の中の端数のレイアウト位置は描画が既に画素へ
|
|
571
|
-
* 揃えるため。冪等 (揃えた値はそのまま)。有限でない `cssPx` はそのまま伝わる (`NaN` は `NaN`)。
|
|
572
|
-
* モジュールレベル export (バレル非公開)。
|
|
639
|
+
* Distance in px within which a pane position counts as resting at an edge: position 0, the maximum position, or the
|
|
640
|
+
* position where a remembered alignment holds. It absorbs the rounding of the position arithmetic (the same sums of row
|
|
641
|
+
* heights and insets taken in a different order, which differ by far less), and it stays below a device pixel at any
|
|
642
|
+
* ratio and below the browser's layout unit (1/64 px): content resting this close to an edge crosses that edge by at most
|
|
643
|
+
* this distance.
|
|
573
644
|
*
|
|
574
|
-
*
|
|
575
|
-
*
|
|
576
|
-
*
|
|
577
|
-
* @throws {RangeError} When `ratio` is not a finite number greater than 0 / `ratio` が 0 より大きい有限数でないとき
|
|
645
|
+
* ペイン位置を端にあるとみなす距離 (px)。端は位置 0・最大位置・覚えた揃えが成り立つ位置。位置の計算の丸め (同じ行の高さと
|
|
646
|
+
* インセットの和を別の順で取った差で、これよりはるかに小さい) を吸収し、どの画素比でも装置の画素 1 つ未満、ブラウザの
|
|
647
|
+
* レイアウトの単位 (1/64 px) 未満に収まる。端にこれだけ近く止まった中身がその端を越える量は、この距離を超えない。
|
|
578
648
|
*/
|
|
579
|
-
|
|
580
|
-
if (!(Number.isFinite(ratio) && ratio > 0)) {
|
|
581
|
-
throw new RangeError(`[VirtualScroll] devicePixelRatio must be a finite number > 0, received ${ratio}`)
|
|
582
|
-
}
|
|
583
|
-
return Math.round(cssPx * ratio) / ratio
|
|
584
|
-
}
|
|
649
|
+
const EDGE_POSITION_TOLERANCE = 2 ** -10
|
|
585
650
|
|
|
586
651
|
/**
|
|
587
|
-
*
|
|
588
|
-
*
|
|
589
|
-
* 要素が属する文書のウィンドウ (要素を描く画面を持つウィンドウ) を返す処理。大域の `window` は使わない
|
|
590
|
-
* (別ウィンドウ・iframe へ描いた一覧の装置の画素比は、そのウィンドウのもの)。
|
|
652
|
+
* The viewport edge VirtualScroll aligned a row to, and the pane position at which that alignment holds. Module-level
|
|
653
|
+
* export (NOT in the package barrel), the parameter type of `resolveItemsWrapperSnapEdge`.
|
|
591
654
|
*
|
|
592
|
-
*
|
|
593
|
-
*
|
|
594
|
-
* @throws {Error} When the element's document has no window (a document outside any browsing context, which paints nothing) / 要素の文書がウィンドウを持たないとき (閲覧の文脈の外の文書で、何も描かれない)
|
|
655
|
+
* VirtualScroll が行を揃えた表示域の端と、その揃えが成り立つペイン位置。モジュールレベル export (バレル非公開)。
|
|
656
|
+
* `resolveItemsWrapperSnapEdge` の引数の型。
|
|
595
657
|
*/
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
return view
|
|
658
|
+
export type AlignedEdge = {
|
|
659
|
+
/** `"start"` for a top alignment, `"end"` for a bottom alignment / 上端揃えは `"start"`、下端揃えは `"end"` */
|
|
660
|
+
readonly edge: "start" | "end"
|
|
661
|
+
/** Pane position (PANE coordinates) where the aligned row sits at that edge / 揃えた行がその端にあるペイン位置 (ペイン座標) */
|
|
662
|
+
readonly panePosition: number
|
|
602
663
|
}
|
|
603
664
|
|
|
604
665
|
/**
|
|
605
|
-
*
|
|
606
|
-
*
|
|
666
|
+
* Returns the viewport edge an alignment of `scrollToIndex` keeps: `undefined` is the default top alignment, and a
|
|
667
|
+
* centred row keeps no edge.
|
|
607
668
|
*
|
|
608
|
-
*
|
|
669
|
+
* `scrollToIndex` の揃えが守る表示域の端を返す処理。`undefined` は既定の上端揃えで、中央に揃えた行はどの端も守らない。
|
|
670
|
+
*
|
|
671
|
+
* @param align - The alignment / 揃え方
|
|
672
|
+
* @returns The edge, or `null` for a centred row / 端 (中央揃えなら `null`)
|
|
609
673
|
*/
|
|
610
|
-
const
|
|
674
|
+
const edgeOfAlignment = (align: "top" | "bottom" | "center" | undefined): AlignedEdge["edge"] | null => {
|
|
675
|
+
if (align === "center") {
|
|
676
|
+
return null
|
|
677
|
+
}
|
|
678
|
+
return align === "bottom" ? "end" : "start"
|
|
679
|
+
}
|
|
611
680
|
|
|
612
681
|
/**
|
|
613
|
-
*
|
|
614
|
-
*
|
|
615
|
-
* server HTML, and React replaces it with the snapped translate right after hydration.
|
|
682
|
+
* Chooses the edge the items-wrapper translate keeps when it is snapped to the device-pixel grid
|
|
683
|
+
* (`snapToDevicePixelGrid`), so that aligned content never loses part of its edge gutter to the snap:
|
|
616
684
|
*
|
|
617
|
-
* `
|
|
618
|
-
*
|
|
619
|
-
*
|
|
685
|
+
* - `"start"` while the pane rests at position 0: the first row and the top inset keep their place.
|
|
686
|
+
* - `"end"` while the pane rests at its maximum position: the last row and the bottom inset keep theirs.
|
|
687
|
+
* - The edge of the remembered alignment while the pane is at the position where it holds: a row revealed by
|
|
688
|
+
* `scrollToIndex` with `align: "top"` (or the default) or `align: "bottom"`, kept through layout-shift compensation and
|
|
689
|
+
* drift correction.
|
|
690
|
+
* - `"none"` (nearest) everywhere else.
|
|
620
691
|
*
|
|
621
|
-
*
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
/**
|
|
626
|
-
* Returns the window of the JavaScript realm that runs React, or `null` outside a browser realm (server rendering).
|
|
627
|
-
* It is the expected painting window of a wrapper that has not attached yet: render cannot see the document it
|
|
628
|
-
* will be inserted into, and a host renders into its own window's document unless it deliberately renders into
|
|
629
|
-
* another window's document (an iframe or an opened window), which the attach detects (see `ItemsWrapper`).
|
|
692
|
+
* Position 0 wins over the maximum position (a list that does not scroll stays top-aligned), and both win over a
|
|
693
|
+
* remembered alignment, since a clamp means that alignment was not reached. A position within `EDGE_POSITION_TOLERANCE`
|
|
694
|
+
* of one of these counts as that position. Module-level export (NOT in the package barrel).
|
|
630
695
|
*
|
|
631
|
-
*
|
|
632
|
-
*
|
|
633
|
-
* 別のウィンドウの文書 (iframe・開いたウィンドウ) へ意図して描くのでない限り自分のウィンドウの文書へ描くため。
|
|
634
|
-
* 見込みの確かめは取り付けが行う (`ItemsWrapper` を参照)。
|
|
696
|
+
* 行ラッパーの平行移動を装置の画素の格子へ揃えるとき (`snapToDevicePixelGrid`) に守る端を選ぶ処理。揃えた中身の端の余白を
|
|
697
|
+
* 丸めが削らないようにする。
|
|
635
698
|
*
|
|
636
|
-
*
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
* Subscribes to the device-pixel-ratio changes of a window (browser zoom, or the window moving to a screen of
|
|
642
|
-
* another density) through a `(resolution: <ratio>dppx)` media-query watch that is re-armed at each new ratio.
|
|
643
|
-
* A window without media queries (jsdom) cannot change its ratio, so nothing is armed there.
|
|
699
|
+
* - ペインが位置 0 に止まっている間は `"start"`。最初の行と上のインセットがその場に留まる。
|
|
700
|
+
* - ペインが最大位置に止まっている間は `"end"`。最後の行と下のインセットがその場に留まる。
|
|
701
|
+
* - 覚えた揃えが成り立つ位置にペインがある間は、その揃えの端。`scrollToIndex` が `align: "top"` (既定を含む) か
|
|
702
|
+
* `align: "bottom"` で見せた行で、レイアウトシフトの補正とドリフト補正を通して保つ。
|
|
703
|
+
* - それ以外は `"none"` (最も近い格子点)。
|
|
644
704
|
*
|
|
645
|
-
*
|
|
646
|
-
*
|
|
647
|
-
*
|
|
705
|
+
* 位置 0 は最大位置に勝ち (スクロールしない一覧は上端揃えのまま)、どちらも覚えた揃えに勝つ (クランプは揃えが届かなかった
|
|
706
|
+
* ことを意味する)。これらの位置から `EDGE_POSITION_TOLERANCE` 以内の位置はその位置とみなす。モジュールレベル export
|
|
707
|
+
* (バレル非公開)。
|
|
648
708
|
*
|
|
649
|
-
* @param
|
|
650
|
-
* @param
|
|
651
|
-
* @
|
|
709
|
+
* @param panePosition - The pane position the wrapper is rendered at (PANE coordinates) / ラッパーを描くペイン位置 (ペイン座標)
|
|
710
|
+
* @param maxPanePosition - The pane's maximum position: content plus insets minus the viewport / ペインの最大位置 (中身とインセットの和からビューポートを引いた値)
|
|
711
|
+
* @param alignedEdge - The remembered alignment, or `null` / 覚えた揃え (無ければ `null`)
|
|
712
|
+
* @returns The edge the snapped translate keeps / 揃えた平行移動が守る端
|
|
652
713
|
*/
|
|
653
|
-
const
|
|
654
|
-
if (
|
|
655
|
-
return
|
|
714
|
+
export const resolveItemsWrapperSnapEdge = (panePosition: number, maxPanePosition: number, alignedEdge: AlignedEdge | null): DevicePixelSnapEdge => {
|
|
715
|
+
if (panePosition <= EDGE_POSITION_TOLERANCE) {
|
|
716
|
+
return "start"
|
|
656
717
|
}
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
* Re-arms the watch at the window's new ratio, then reports the change.
|
|
660
|
-
*
|
|
661
|
-
* ウィンドウの新しい比で監視を張り直してから変化を知らせる処理。
|
|
662
|
-
*/
|
|
663
|
-
const handleResolutionChange = (): void => {
|
|
664
|
-
resolutionQuery.removeEventListener("change", handleResolutionChange)
|
|
665
|
-
// ❗ `(resolution: Ndppx)` が知らせるのは比 N との一致・不一致の切り替わりだけ。N 以外の比どうしの変化
|
|
666
|
-
// (もう一度の拡大縮小・さらに別の画面への移動) を捉えるには、変化のたびに新しい比で張り直すしかない
|
|
667
|
-
resolutionQuery = view.matchMedia(`(resolution: ${view.devicePixelRatio}dppx)`)
|
|
668
|
-
resolutionQuery.addEventListener("change", handleResolutionChange)
|
|
669
|
-
onChange()
|
|
718
|
+
if (panePosition >= maxPanePosition - EDGE_POSITION_TOLERANCE) {
|
|
719
|
+
return "end"
|
|
670
720
|
}
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
* Removes the current watch.
|
|
674
|
-
*
|
|
675
|
-
* 今の監視を外す処理。
|
|
676
|
-
*/
|
|
677
|
-
const unsubscribe = (): void => {
|
|
678
|
-
resolutionQuery.removeEventListener("change", handleResolutionChange)
|
|
721
|
+
if (alignedEdge !== null && Math.abs(panePosition - alignedEdge.panePosition) <= EDGE_POSITION_TOLERANCE) {
|
|
722
|
+
return alignedEdge.edge
|
|
679
723
|
}
|
|
680
|
-
return
|
|
724
|
+
return "none"
|
|
681
725
|
}
|
|
682
726
|
|
|
683
727
|
/**
|
|
@@ -688,82 +732,43 @@ const subscribeToDevicePixelRatio = (view: Window, onChange: () => void): (() =>
|
|
|
688
732
|
type ItemsWrapperProps = {
|
|
689
733
|
/** Exact translate in CSS px: top inset + render anchor - pane position / 厳密な平行移動 (CSS px。上のインセット + 描画アンカー - ペイン位置) */
|
|
690
734
|
readonly translateY: number
|
|
735
|
+
/** The edge the snapped translate keeps (`resolveItemsWrapperSnapEdge`) / 揃えた平行移動が守る端 (`resolveItemsWrapperSnapEdge`) */
|
|
736
|
+
readonly snapEdge: DevicePixelSnapEdge
|
|
691
737
|
/** The rendered rows and the bottom inset / 描いた行と下のインセット */
|
|
692
738
|
readonly children: ReactNode
|
|
693
739
|
}
|
|
694
740
|
|
|
695
741
|
/**
|
|
696
|
-
* The items wrapper: a compositor layer (`will-change: transform`) moved by `translateY`, snapped to the
|
|
697
|
-
*
|
|
698
|
-
*
|
|
699
|
-
*
|
|
700
|
-
*
|
|
701
|
-
*
|
|
702
|
-
*
|
|
703
|
-
* an iframe or an opened window), the attach switches to that window; the update is scheduled in the commit
|
|
704
|
-
* phase, so React re-renders the wrapper synchronously before the browser paints. Ratio changes re-render
|
|
705
|
-
* through the store subscription (`subscribeToDevicePixelRatio`), outside the commit phase. The server snapshot
|
|
706
|
-
* is `null`: server HTML and hydration carry the exact translate, replaced by the snapped one right after
|
|
707
|
-
* hydration.
|
|
742
|
+
* The items wrapper: a compositor layer (`will-change: transform`) moved by `translateY`, snapped to the device-pixel
|
|
743
|
+
* grid of the window that paints it toward `snapEdge` (`snapToDevicePixelGrid`; the ratio is read during render by
|
|
744
|
+
* `usePaintingDevicePixelRatio`, so a list rendered into its own window's document is snapped from its first commit).
|
|
745
|
+
* Only the layer's translate is snapped. The rows inside it paint at their exact layout positions, where rounded
|
|
746
|
+
* borders, rings and outlines are anti-aliased, so a row starts on a whole device pixel only when its offset from the
|
|
747
|
+
* render anchor is a whole number of device px — for example when every row height is a multiple of a lattice L with
|
|
748
|
+
* L × ratio a whole number (L = 4 px at ratios in quarter steps).
|
|
708
749
|
*
|
|
709
|
-
* 行ラッパー。`translateY` で動かす合成層 (`will-change: transform`)
|
|
710
|
-
*
|
|
711
|
-
*
|
|
712
|
-
*
|
|
713
|
-
*
|
|
714
|
-
* 揃っていて確定の段では何も予約しない。違えば (iframe・開いたウィンドウに描いた一覧) 取り付けがそのウィンドウへ
|
|
715
|
-
* 切り替え、この更新は確定の段で予約されるので、React はブラウザの paint の前にラッパーを同期で描き直す。
|
|
716
|
-
* 比の変化はストアの購読 (`subscribeToDevicePixelRatio`) で確定の段の外から描き直す。サーバーのスナップショットは
|
|
717
|
-
* `null` で、サーバーの HTML とハイドレーションは厳密な平行移動を持ち、ハイドレーションの直後に揃えた値へ置き換わる。
|
|
750
|
+
* 行ラッパー。`translateY` で動かす合成層 (`will-change: transform`) で、平行移動は描くウィンドウの装置の画素の格子へ
|
|
751
|
+
* `snapEdge` の側で揃える (`snapToDevicePixelGrid`。比は `usePaintingDevicePixelRatio` が描画の中で読むので、自分の
|
|
752
|
+
* ウィンドウの文書に描いた一覧は最初の確定から揃う)。揃えるのは層の平行移動だけ。層の中の行は厳密なレイアウトの位置に
|
|
753
|
+
* 描かれ、角の丸い枠線・輪・輪郭はそこで滲む。行が装置の画素の整数から始まるのは、描画のアンカーからの行の位置が装置 px の
|
|
754
|
+
* 整数のときだけ (例: どの行の高さも、L × 比が整数になる格子 L の倍数のとき。比が 4 分の 1 刻みなら L = 4px)。
|
|
718
755
|
*
|
|
719
|
-
* @param props - The exact translate and the wrapped rows /
|
|
756
|
+
* @param props - The exact translate, the edge it keeps and the wrapped rows / 厳密な平行移動・守る端・包む行
|
|
720
757
|
* @returns The wrapper element / ラッパーの要素
|
|
721
|
-
* @throws {Error} When the wrapper attaches to a document without a window (see `
|
|
758
|
+
* @throws {Error} When the wrapper attaches to a document without a window (see `usePaintingDevicePixelRatio`) / ウィンドウを持たない文書へ取り付いたとき (`usePaintingDevicePixelRatio` を参照)
|
|
722
759
|
* @throws {RangeError} When the painting window reports a ratio that is not a finite number > 0 (see `snapToDevicePixelGrid`) / 描くウィンドウの比が 0 より大きい有限数でないとき (`snapToDevicePixelGrid` を参照)
|
|
723
760
|
*/
|
|
724
|
-
const ItemsWrapper = ({ translateY, children }: ItemsWrapperProps) => {
|
|
725
|
-
const
|
|
726
|
-
/**
|
|
727
|
-
* Subscribes the store to the painting window's ratio changes.
|
|
728
|
-
*
|
|
729
|
-
* 描くウィンドウの比の変化へストアを購読させる処理。
|
|
730
|
-
*/
|
|
731
|
-
const subscribe = useCallback((onChange: () => void): (() => void) => (paintingWindow === null ? unsubscribeNothing : subscribeToDevicePixelRatio(paintingWindow, onChange)), [paintingWindow])
|
|
732
|
-
/**
|
|
733
|
-
* Reads the painting window's current ratio (`null` while no window is known).
|
|
734
|
-
*
|
|
735
|
-
* 描くウィンドウの今の比を読む処理 (ウィンドウが分からない間は `null`)。
|
|
736
|
-
*/
|
|
737
|
-
const readDevicePixelRatio = useCallback((): number | null => (paintingWindow === null ? null : paintingWindow.devicePixelRatio), [paintingWindow])
|
|
738
|
-
// 大域の devicePixelRatio と同名にしない — 宣言を消したとき参照が黙って大域のウィンドウの比へ化けるため
|
|
739
|
-
const paintingRatio = useSyncExternalStore(subscribe, readDevicePixelRatio, readServerDevicePixelRatio)
|
|
740
|
-
/**
|
|
741
|
-
* Ref callback that confirms the expected painting window against the window of the wrapper's document.
|
|
742
|
-
*
|
|
743
|
-
* 描くと見込んだウィンドウを、ラッパーの文書のウィンドウと突き合わせて確かめる ref コールバック。
|
|
744
|
-
*/
|
|
745
|
-
const confirmPaintingWindow = useCallback(
|
|
746
|
-
(wrapper: HTMLDivElement | null): void => {
|
|
747
|
-
if (wrapper === null) {
|
|
748
|
-
return
|
|
749
|
-
}
|
|
750
|
-
const view = windowOf(wrapper)
|
|
751
|
-
// 同じウィンドウなら描画で読んだ比が正しく、確定の段の更新 (paint 前の同期の描き直し) は要らない
|
|
752
|
-
if (view !== paintingWindow) {
|
|
753
|
-
setPaintingWindow(view)
|
|
754
|
-
}
|
|
755
|
-
},
|
|
756
|
-
[paintingWindow],
|
|
757
|
-
)
|
|
761
|
+
const ItemsWrapper = ({ translateY, snapEdge, children }: ItemsWrapperProps) => {
|
|
762
|
+
const { ratio, attach } = usePaintingDevicePixelRatio("[VirtualScroll] the items wrapper")
|
|
758
763
|
// ❗ ラッパーは合成層なので、平行移動が装置の画素の端数を持つとブラウザは層ごと再標本化する (実測: -2440.5px で
|
|
759
|
-
// 2px の輪郭・2px の隙間・2px の輪が 3+1+3 行に滲み、下辺の隙間が消える)
|
|
760
|
-
//
|
|
761
|
-
const wrapperTranslateY =
|
|
764
|
+
// 2px の輪郭・2px の隙間・2px の輪が 3+1+3 行に滲み、下辺の隙間が消える)。揃えるのは層の平行移動だけ —
|
|
765
|
+
// 層の中の行の端数の位置は揃わないので、行の高さを装置の画素の格子に乗せるのはホストの役目
|
|
766
|
+
const wrapperTranslateY = ratio === null ? translateY : snapToDevicePixelGrid(translateY, ratio, snapEdge)
|
|
762
767
|
// ❗ 層の様式は will-change: transform だけ。perspective や 3D の変換を足すと Chromium は描画の切り取り (cull rect) を
|
|
763
768
|
// 外して描くが、行を非 2D の変換の下で合成するので画素比によっては層ごと再標本化する (実測: 比 1.25 の明色で選択の輪と
|
|
764
769
|
// フォーカスの輪の間の帯が混ざる)。窓をずらす 1 段がはみ出しを切る中身の行を描き直す費用は、その代わりに受け入れる
|
|
765
770
|
return (
|
|
766
|
-
<div ref={
|
|
771
|
+
<div ref={attach} className="aqvs-items-wrapper" style={{ top: 0, transform: `translateY(${wrapperTranslateY}px)`, willChange: "transform" }}>
|
|
767
772
|
{children}
|
|
768
773
|
</div>
|
|
769
774
|
)
|
|
@@ -1275,6 +1280,7 @@ const VirtualScrollInner = <T,>(
|
|
|
1275
1280
|
testId,
|
|
1276
1281
|
onScroll,
|
|
1277
1282
|
onRangeChange,
|
|
1283
|
+
onScrollAdjust,
|
|
1278
1284
|
children,
|
|
1279
1285
|
background,
|
|
1280
1286
|
initialScrollIndex,
|
|
@@ -1422,11 +1428,62 @@ const VirtualScrollInner = <T,>(
|
|
|
1422
1428
|
|
|
1423
1429
|
const [scrollPosition, setScrollPosition] = useState(initialValues.position)
|
|
1424
1430
|
const [contentSize, setContentSize] = useState<number>(initialValues.total)
|
|
1431
|
+
// 初期位置の index とアンカーは上端揃えの scrollToIndex と同じ位置なので、その位置の上端揃えとして覚えておく
|
|
1432
|
+
const [alignedEdge, setAlignedEdge] = useState<AlignedEdge | null>(() => ((initialScrollAnchor && itemCount > 0) || typeof initialScrollIndex === "number" ? { edge: "start", panePosition: initialValues.position } : null))
|
|
1433
|
+
// 状態の同期の写し。手動スクロールのたびに状態へ null を書くと、同じ値でも React が描画を予約し得るため、写しで要否を決める
|
|
1434
|
+
const alignedEdgeRef = useRef(alignedEdge)
|
|
1435
|
+
|
|
1436
|
+
/**
|
|
1437
|
+
* Remembers the alignment that holds at a pane position, or forgets it with `null`: writes the state the items wrapper
|
|
1438
|
+
* snaps with and its synchronous copy.
|
|
1439
|
+
*
|
|
1440
|
+
* あるペイン位置で成り立つ揃えを覚える処理 (`null` で忘れる)。行ラッパーが揃えに使う状態と、その同期の写しへ書く。
|
|
1441
|
+
*
|
|
1442
|
+
* @param next - The alignment, or `null` / 揃え (無ければ `null`)
|
|
1443
|
+
*/
|
|
1444
|
+
const rememberAlignedEdge = useCallback((next: AlignedEdge | null): void => {
|
|
1445
|
+
alignedEdgeRef.current = next
|
|
1446
|
+
setAlignedEdge(next)
|
|
1447
|
+
}, [])
|
|
1448
|
+
|
|
1449
|
+
/**
|
|
1450
|
+
* Moves the remembered alignment along with a position change that keeps the aligned row in place (layout-shift
|
|
1451
|
+
* compensation): when the alignment held at `fromPanePosition`, it now holds at `toPanePosition`.
|
|
1452
|
+
*
|
|
1453
|
+
* 揃えた行をその場に留める位置の変化 (レイアウトシフトの補正) に合わせて、覚えた揃えを動かす処理。揃えが
|
|
1454
|
+
* `fromPanePosition` で成り立っていたなら、`toPanePosition` で成り立つ。
|
|
1455
|
+
*
|
|
1456
|
+
* @param fromPanePosition - Pane position before the change / 変化の前のペイン位置
|
|
1457
|
+
* @param toPanePosition - Pane position after the change / 変化の後のペイン位置
|
|
1458
|
+
*/
|
|
1459
|
+
const carryAlignedEdge = useCallback(
|
|
1460
|
+
(fromPanePosition: number, toPanePosition: number): void => {
|
|
1461
|
+
const current = alignedEdgeRef.current
|
|
1462
|
+
if (current === null || Math.abs(current.panePosition - fromPanePosition) > EDGE_POSITION_TOLERANCE) {
|
|
1463
|
+
return
|
|
1464
|
+
}
|
|
1465
|
+
rememberAlignedEdge({ edge: current.edge, panePosition: toPanePosition })
|
|
1466
|
+
},
|
|
1467
|
+
[rememberAlignedEdge],
|
|
1468
|
+
)
|
|
1469
|
+
|
|
1470
|
+
/**
|
|
1471
|
+
* Forgets the remembered alignment (a scroll the user started moves the list away from it).
|
|
1472
|
+
*
|
|
1473
|
+
* 覚えた揃えを忘れる処理 (利用者が始めたスクロールは一覧を揃えから離す)。
|
|
1474
|
+
*/
|
|
1475
|
+
const forgetAlignedEdge = useCallback((): void => {
|
|
1476
|
+
if (alignedEdgeRef.current !== null) {
|
|
1477
|
+
rememberAlignedEdge(null)
|
|
1478
|
+
}
|
|
1479
|
+
}, [rememberAlignedEdge])
|
|
1425
1480
|
|
|
1426
1481
|
const latestScrollPositionRef = useRef(initialValues.position)
|
|
1427
1482
|
const previousTopInsetRef = useRef(resolvedInsets.top)
|
|
1428
1483
|
const onScrollRef = useRef<OnScrollCallback | undefined>(onScroll ?? undefined)
|
|
1429
1484
|
const onRangeChangeRef = useRef<OnRangeChangeCallback | undefined>(onRangeChange ?? undefined)
|
|
1485
|
+
// 位置を自分で動かしたことの知らせは、補正の最中 (updateItemSize・マイクロタスク・effect) から同期で呼ぶので ref から読む
|
|
1486
|
+
const onScrollAdjustRef = useRef<((adjustment: VirtualScrollAdjustment) => void) | undefined>(onScrollAdjust)
|
|
1430
1487
|
// 横委譲コールバックを ref 経由で読む。行キーハンドラ (handleItemKeyDown) は全行の React.memo が
|
|
1431
1488
|
// 依存する安定参照でなければならず、コールバックの差し替えで identity を変えられないため。
|
|
1432
1489
|
const onWheelHorizontalRef = useRef<((deltaX: number) => void) | undefined>(onWheelHorizontal)
|
|
@@ -1477,11 +1534,12 @@ const VirtualScrollInner = <T,>(
|
|
|
1477
1534
|
// **1 フレーム古いコールバック**を読む窓を消すため (通常の effect はペイントを跨いで遅延しうる)
|
|
1478
1535
|
useLayoutEffect(() => {
|
|
1479
1536
|
// 目的: 外部から渡されたコールバックの参照を最新状態に保つ。
|
|
1480
|
-
// 依存関係: onRangeChange, onScroll, onWheelHorizontal
|
|
1537
|
+
// 依存関係: onRangeChange, onScroll, onScrollAdjust, onWheelHorizontal
|
|
1481
1538
|
onScrollRef.current = onScroll ?? undefined
|
|
1482
1539
|
onRangeChangeRef.current = onRangeChange ?? undefined
|
|
1540
|
+
onScrollAdjustRef.current = onScrollAdjust
|
|
1483
1541
|
onWheelHorizontalRef.current = onWheelHorizontal
|
|
1484
|
-
}, [onRangeChange, onScroll, onWheelHorizontal])
|
|
1542
|
+
}, [onRangeChange, onScroll, onScrollAdjust, onWheelHorizontal])
|
|
1485
1543
|
|
|
1486
1544
|
const tryFocusElement = useCallback(
|
|
1487
1545
|
(element: HTMLElement | null) => {
|
|
@@ -1723,6 +1781,39 @@ const VirtualScrollInner = <T,>(
|
|
|
1723
1781
|
return appliedPosition
|
|
1724
1782
|
}, [])
|
|
1725
1783
|
|
|
1784
|
+
/**
|
|
1785
|
+
* Runs one position change VirtualScroll makes on its own (`move` updates the pane, the latest position and, where it
|
|
1786
|
+
* applies, the tree), then reports it through `onScrollAdjust` with the change of `getScrollPosition()` it applied.
|
|
1787
|
+
* Nothing is reported without a connected pane or when the position did not change.
|
|
1788
|
+
*
|
|
1789
|
+
* VirtualScroll が自分で行う位置の変化を 1 回実行し (`move` がペイン・最新の位置・必要なら木を更新する)、それが
|
|
1790
|
+
* `getScrollPosition()` に与えた変化を `onScrollAdjust` で知らせる処理。ペインが繋がっていないときと、位置が変わら
|
|
1791
|
+
* なかったときは知らせない。
|
|
1792
|
+
*
|
|
1793
|
+
* @param cause - Why the position moves / 位置が動く理由
|
|
1794
|
+
* @param move - The change itself / 変化そのもの
|
|
1795
|
+
*/
|
|
1796
|
+
const applySelfAdjustment = useCallback((cause: VirtualScrollAdjustmentCause, move: () => void): void => {
|
|
1797
|
+
const pane = scrollPaneRef.current
|
|
1798
|
+
if (pane === null) {
|
|
1799
|
+
move()
|
|
1800
|
+
return
|
|
1801
|
+
}
|
|
1802
|
+
const panePositionBefore = pane.getScrollPosition()
|
|
1803
|
+
move()
|
|
1804
|
+
const notify = onScrollAdjustRef.current
|
|
1805
|
+
if (notify === undefined) {
|
|
1806
|
+
return
|
|
1807
|
+
}
|
|
1808
|
+
// 知らせる位置と差は getScrollPosition() と同じ論理座標 (ペインの位置から上のインセットを除く)。ホストは知らせの中で
|
|
1809
|
+
// ハンドルを読み直すので、ハンドルが返す値と食い違う数を渡さない
|
|
1810
|
+
const position = toLogicalPositionWithInset(pane.getScrollPosition(), resolvedInsetsTopRef.current)
|
|
1811
|
+
const delta = position - toLogicalPositionWithInset(panePositionBefore, resolvedInsetsTopRef.current)
|
|
1812
|
+
if (delta !== 0) {
|
|
1813
|
+
notify({ position, delta, cause })
|
|
1814
|
+
}
|
|
1815
|
+
}, [])
|
|
1816
|
+
|
|
1726
1817
|
const didApplyInitialOffsetRef = useRef(false)
|
|
1727
1818
|
|
|
1728
1819
|
/**
|
|
@@ -1773,14 +1864,16 @@ const VirtualScrollInner = <T,>(
|
|
|
1773
1864
|
pendingCompensationRef.current = false
|
|
1774
1865
|
const pane = scrollPaneRef.current
|
|
1775
1866
|
if (pane && Math.abs(pane.getScrollPosition() - latestScrollPositionRef.current) > 0.5) {
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1867
|
+
applySelfAdjustment("re-issue", () => {
|
|
1868
|
+
const appliedPosition = issueCompensationScroll(latestScrollPositionRef.current)
|
|
1869
|
+
// 再発行後もなお目標に届かない場合 (目標が負値等) はクランプ済み実位置へ収束させ、
|
|
1870
|
+
// 再発行の連鎖を断つ
|
|
1871
|
+
pendingCompensationRef.current = false
|
|
1872
|
+
updateScrollPositionImmediate(appliedPosition, { immediate: true })
|
|
1873
|
+
})
|
|
1781
1874
|
}
|
|
1782
1875
|
}
|
|
1783
|
-
}, [fenwickTree, contentSize, itemCount, issueCompensationScroll, updateScrollPositionImmediate])
|
|
1876
|
+
}, [fenwickTree, contentSize, itemCount, applySelfAdjustment, issueCompensationScroll, updateScrollPositionImmediate])
|
|
1784
1877
|
|
|
1785
1878
|
useEffect(() => {
|
|
1786
1879
|
// 目的: サイズ変更やスクロール等でターゲットインデックスの位置がずれた場合、スクロール位置を補正 (ドリフト補正) する。
|
|
@@ -1809,23 +1902,29 @@ const VirtualScrollInner = <T,>(
|
|
|
1809
1902
|
const maxScrollPosition = Math.max(0, contentSize + resolvedInsets.top + resolvedInsets.bottom - viewportSize)
|
|
1810
1903
|
const targetPanePosition = Math.min(toPanePositionWithInset(targetLogicalPosition, resolvedInsets.top), maxScrollPosition)
|
|
1811
1904
|
|
|
1812
|
-
|
|
1905
|
+
// ❗ 1px 未満のずれも留め直す。揃えた行 (最後の行の下端揃えなど) の端の余白を、行の高さの端数の変化が削ったまま残さない
|
|
1906
|
+
if (Math.abs(targetPanePosition - latestScrollPositionRef.current) > EDGE_POSITION_TOLERANCE) {
|
|
1813
1907
|
Logger.debug("[VirtualScroll] Drift correction", {
|
|
1814
1908
|
from: latestScrollPositionRef.current,
|
|
1815
1909
|
to: targetPanePosition,
|
|
1816
1910
|
targetIndex: safeIndex,
|
|
1817
1911
|
})
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
isCompensatingRef.current
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1912
|
+
applySelfAdjustment("drift", () => {
|
|
1913
|
+
// 複数の effect がペインのスクロールの処理より先に走っても、補正の数を二重に数えない
|
|
1914
|
+
if (isCompensatingRef.current === 0) {
|
|
1915
|
+
isCompensatingRef.current += 1
|
|
1916
|
+
}
|
|
1917
|
+
scrollPaneRef.current?.scrollTo(targetPanePosition)
|
|
1918
|
+
updateScrollPositionImmediate(targetPanePosition, { immediate: true })
|
|
1919
|
+
// 留め直した位置で、保留中の揃えの端が成り立つ (知らせの前に覚える — 知らせの中のハンドルの呼び出しが新しい揃えを覚えたら、それを上書きしない)
|
|
1920
|
+
const edge = edgeOfAlignment(align)
|
|
1921
|
+
rememberAlignedEdge(edge === null ? null : { edge, panePosition: targetPanePosition })
|
|
1922
|
+
})
|
|
1824
1923
|
}
|
|
1825
1924
|
}
|
|
1826
1925
|
}
|
|
1827
1926
|
isResizingRef.current = false
|
|
1828
|
-
}, [contentSize, fenwickTree, itemCount, resolvedInsets.top, updateScrollPositionImmediate, viewportSize, resolvedInsets.bottom])
|
|
1927
|
+
}, [contentSize, fenwickTree, itemCount, resolvedInsets.top, applySelfAdjustment, rememberAlignedEdge, updateScrollPositionImmediate, viewportSize, resolvedInsets.bottom])
|
|
1829
1928
|
|
|
1830
1929
|
useEffect(() => {
|
|
1831
1930
|
// 目的: 上部インセットが動的に変更された場合、論理スクロール位置を維持したまま、ペインのスクロール位置を再計算してずらす。
|
|
@@ -1853,11 +1952,18 @@ const VirtualScrollInner = <T,>(
|
|
|
1853
1952
|
* Updates the size of a specific item. Contract: after this call, `getItemHeight(index)` must
|
|
1854
1953
|
* return the same `size`; `getItemHeight` is the source of truth, so rows inside the current
|
|
1855
1954
|
* rendering window (including overscan) are otherwise reverted to the `getItemHeight` value by
|
|
1856
|
-
* height reconciliation on the next render.
|
|
1955
|
+
* height reconciliation on the next render. A change above the first visible row moves the scroll
|
|
1956
|
+
* position by the same delta (layout-shift compensation), carries the remembered alignment along and
|
|
1957
|
+
* is reported through `onScrollAdjust` (`"item-resize"`) before this returns.
|
|
1857
1958
|
*
|
|
1858
1959
|
* 指定されたアイテムのサイズを更新。契約: 呼び出し後は `getItemHeight(index)` も同じ値を返すこと。
|
|
1859
1960
|
* `getItemHeight` が正であるため、そうでない場合は描画ウィンドウ (オーバースキャン含む) 内の行が
|
|
1860
|
-
* 次レンダーの高さ照合で `getItemHeight`
|
|
1961
|
+
* 次レンダーの高さ照合で `getItemHeight` の値へ巻き戻る。先頭の可視行より上の変化はスクロール位置を
|
|
1962
|
+
* 同じ差だけ動かし (レイアウトシフトの補正)、覚えた揃えも一緒に動かして、戻る前に `onScrollAdjust`
|
|
1963
|
+
* (`"item-resize"`) で知らせる。
|
|
1964
|
+
*
|
|
1965
|
+
* @param index - Item index / アイテムのインデックス
|
|
1966
|
+
* @param size - New size in px / 新しいサイズ (px)
|
|
1861
1967
|
*/
|
|
1862
1968
|
const updateItemSize = useCallback(
|
|
1863
1969
|
(index: number, size: number) => {
|
|
@@ -1902,21 +2008,30 @@ const VirtualScrollInner = <T,>(
|
|
|
1902
2008
|
const currentPanePosition = latestScrollPositionRef.current
|
|
1903
2009
|
const newPosition = currentPanePosition + delta
|
|
1904
2010
|
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
2011
|
+
applySelfAdjustment("item-resize", () => {
|
|
2012
|
+
// 同一同期バッチ内では ScrollPane の sizeRef が旧 contentSize のままのため、scrollTo は
|
|
2013
|
+
// 旧最大値でクランプされ得る。issueCompensationScroll がカウンタのリークを防ぎつつ乖離時の
|
|
2014
|
+
// 再発行を予約し、論理位置 (latestScrollPositionRef) には補正目標を保持して二段目の収束先とする。
|
|
2015
|
+
issueCompensationScroll(newPosition, delta)
|
|
2016
|
+
updateScrollPositionImmediate(newPosition, { immediate: true })
|
|
2017
|
+
carryAlignedEdge(currentPanePosition, newPosition)
|
|
2018
|
+
})
|
|
1910
2019
|
Logger.debug("[VirtualScroll] Adjusted scroll for layout shift (manual update)", { from: currentPanePosition, to: newPosition, causedByIndex: safeIndex, delta, activeVisibleStartIndex })
|
|
1911
2020
|
}
|
|
1912
2021
|
},
|
|
1913
|
-
[fenwickTree, itemCount, issueCompensationScroll, updateScrollPositionImmediate, contentInsets],
|
|
2022
|
+
[fenwickTree, itemCount, applySelfAdjustment, carryAlignedEdge, issueCompensationScroll, updateScrollPositionImmediate, contentInsets],
|
|
1914
2023
|
)
|
|
1915
2024
|
|
|
1916
2025
|
/**
|
|
1917
|
-
* Scrolls to the requested logical index
|
|
2026
|
+
* Scrolls to the requested logical index: lands the pane exactly on the aligned position (clamped to the content),
|
|
2027
|
+
* pins it as the pending alignment for drift correction, and remembers the alignment's edge at that position for
|
|
2028
|
+
* the device-pixel snap of the items wrapper (`resolveItemsWrapperSnapEdge`).
|
|
2029
|
+
*
|
|
2030
|
+
* 指定インデックスへのスクロールを実行する処理。ペインを揃えた位置 (中身の範囲へクランプ) へ厳密に着地させ、ドリフト補正の
|
|
2031
|
+
* 保留中の揃えとして留め、行ラッパーの装置の画素への揃えのためにその位置で揃えの端を覚える (`resolveItemsWrapperSnapEdge`)。
|
|
1918
2032
|
*
|
|
1919
|
-
*
|
|
2033
|
+
* @param index - Item index / アイテムのインデックス
|
|
2034
|
+
* @param options - Alignment (`"top"` by default) and offset / 揃え方 (既定は `"top"`) と offset
|
|
1920
2035
|
*/
|
|
1921
2036
|
const scrollToIndex = useCallback(
|
|
1922
2037
|
(index: number, options?: { align?: "top" | "bottom" | "center"; offset?: number }) => {
|
|
@@ -1972,9 +2087,13 @@ const VirtualScrollInner = <T,>(
|
|
|
1972
2087
|
align: options?.align,
|
|
1973
2088
|
offset: options?.offset,
|
|
1974
2089
|
}
|
|
2090
|
+
const edge = edgeOfAlignment(options?.align)
|
|
2091
|
+
rememberAlignedEdge(edge === null ? null : { edge, panePosition: clampedPaneOffset })
|
|
1975
2092
|
|
|
1976
2093
|
const currentPanePosition = scrollPaneRef.current?.getScrollPosition() ?? -1
|
|
1977
|
-
|
|
2094
|
+
// ❗ 揃えは厳密に着地させる。半ピクセル未満のずれを残すと、揃えた行の端の余白をそのずれが削り、行ラッパーを
|
|
2095
|
+
// 揃えた端の側へ丸めても取り戻せない (覚えた揃えもペインの位置と一致せず効かない)
|
|
2096
|
+
const shouldScroll = Math.abs(currentPanePosition - clampedPaneOffset) > EDGE_POSITION_TOLERANCE
|
|
1978
2097
|
|
|
1979
2098
|
// Reset flags for strict accounting to avoid leaks
|
|
1980
2099
|
// リークを防ぐため、補正関連のフラグをリセットします
|
|
@@ -1997,7 +2116,7 @@ const VirtualScrollInner = <T,>(
|
|
|
1997
2116
|
|
|
1998
2117
|
Logger.debug("[VirtualScroll] Setting scroll position to:", clampedPaneOffset, { original: paneOffset, max: maxScrollPosition })
|
|
1999
2118
|
},
|
|
2000
|
-
[fenwickTree, overscanCount, itemCount, resolvedInsets.top, resolvedInsets.bottom, viewportSize, updateScrollPositionImmediate],
|
|
2119
|
+
[fenwickTree, overscanCount, itemCount, resolvedInsets.top, resolvedInsets.bottom, viewportSize, rememberAlignedEdge, updateScrollPositionImmediate],
|
|
2001
2120
|
)
|
|
2002
2121
|
|
|
2003
2122
|
// アンカー付きマウントが itemCount 0 で始まった場合の遅延適用 (一度きり)。
|
|
@@ -2157,6 +2276,7 @@ const VirtualScrollInner = <T,>(
|
|
|
2157
2276
|
const isCommittedResize = isResizingRef.current && prevItemCountRef.current === itemCount
|
|
2158
2277
|
if (!(isProgrammatic || isCompensating || isCommittedResize)) {
|
|
2159
2278
|
pendingVisibleStartIndexRef.current = null
|
|
2279
|
+
forgetAlignedEdge()
|
|
2160
2280
|
}
|
|
2161
2281
|
|
|
2162
2282
|
updateScrollPositionImmediate(newPosition)
|
|
@@ -2184,7 +2304,7 @@ const VirtualScrollInner = <T,>(
|
|
|
2184
2304
|
}
|
|
2185
2305
|
}
|
|
2186
2306
|
},
|
|
2187
|
-
[updateScrollPositionImmediate, enableScrollToTopBottomButtons, itemCount],
|
|
2307
|
+
[updateScrollPositionImmediate, forgetAlignedEdge, enableScrollToTopBottomButtons, itemCount],
|
|
2188
2308
|
)
|
|
2189
2309
|
|
|
2190
2310
|
// レンダリング範囲を計算
|
|
@@ -2640,10 +2760,13 @@ const VirtualScrollInner = <T,>(
|
|
|
2640
2760
|
|
|
2641
2761
|
if (shiftAmount !== 0) {
|
|
2642
2762
|
const newPosition = panePosition + shiftAmount
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2763
|
+
applySelfAdjustment("reconciliation", () => {
|
|
2764
|
+
// updateItemSize と同型: scrollTo は旧 contentSize でクランプされ得るため、
|
|
2765
|
+
// issueCompensationScroll でリークを防ぎつつ、乖離時は contentSize 同期 effect で再発行する。
|
|
2766
|
+
issueCompensationScroll(newPosition, shiftAmount)
|
|
2767
|
+
updateScrollPositionImmediate(newPosition, { immediate: true })
|
|
2768
|
+
carryAlignedEdge(panePosition, newPosition)
|
|
2769
|
+
})
|
|
2647
2770
|
Logger.debug("[VirtualScroll] Adjusted scroll for layout shift (auto update)", { from: panePosition, to: newPosition, shiftAmount })
|
|
2648
2771
|
} else if (panePosition !== latestScrollPositionRef.current) {
|
|
2649
2772
|
updateScrollPositionImmediate(panePosition)
|
|
@@ -2653,6 +2776,8 @@ const VirtualScrollInner = <T,>(
|
|
|
2653
2776
|
|
|
2654
2777
|
return { visibleItems: nodes, renderAnchor: currentAnchor }
|
|
2655
2778
|
}, [
|
|
2779
|
+
applySelfAdjustment,
|
|
2780
|
+
carryAlignedEdge,
|
|
2656
2781
|
children,
|
|
2657
2782
|
clipItemHeight,
|
|
2658
2783
|
contentSize,
|
|
@@ -2678,11 +2803,14 @@ const VirtualScrollInner = <T,>(
|
|
|
2678
2803
|
/**
|
|
2679
2804
|
* Renders the items wrapper for the pane's current scroll position (ScrollPane's `children` render prop).
|
|
2680
2805
|
* The wrapper carries the scroll offset, the top inset and the render anchor as one translate, which
|
|
2681
|
-
* `ItemsWrapper` snaps to the device-pixel grid of the window that paints it
|
|
2806
|
+
* `ItemsWrapper` snaps to the device-pixel grid of the window that paints it, toward the edge chosen by
|
|
2807
|
+
* `resolveItemsWrapperSnapEdge` (position 0, the maximum position, or the remembered alignment); rows keep their
|
|
2808
|
+
* exact tops.
|
|
2682
2809
|
*
|
|
2683
2810
|
* ペインの現在のスクロール位置に対する行ラッパーを描く処理 (ScrollPane の `children` の描画関数)。スクロール位置・
|
|
2684
2811
|
* 上のインセット・描画アンカーを 1 つの平行移動としてラッパーが運び、`ItemsWrapper` がそれを描くウィンドウの
|
|
2685
|
-
*
|
|
2812
|
+
* 装置の画素の格子へ、`resolveItemsWrapperSnapEdge` が選ぶ端 (位置 0・最大位置・覚えた揃え) の側で揃える。
|
|
2813
|
+
* 行の上端は厳密値のまま。
|
|
2686
2814
|
*
|
|
2687
2815
|
* @param currentScrollPosition - The pane's scroll position in PANE coordinates / ペイン座標のスクロール位置
|
|
2688
2816
|
* @returns The items wrapper, or the empty state when there are no items / 行ラッパー (項目が無ければ空状態)
|
|
@@ -2719,6 +2847,9 @@ const VirtualScrollInner = <T,>(
|
|
|
2719
2847
|
// 毎フレームのリフローを避けコンポジタのみで完結させる。
|
|
2720
2848
|
// アンカーは visibleItems と同じ memo の返り値を使い、行 top との整合を保証する。
|
|
2721
2849
|
const containerTop = resolvedInsets.top + renderAnchor - rawEffectiveScrollPosition
|
|
2850
|
+
const contentTotal = fenwickTree.getTotal()
|
|
2851
|
+
// ペインがクランプに使うのと同じ和 (中身 + インセット - ビューポート) で、ペインが最大位置に止まっているかを判定する
|
|
2852
|
+
const snapEdge = resolveItemsWrapperSnapEdge(rawEffectiveScrollPosition, contentTotal + resolvedInsets.top + resolvedInsets.bottom - viewportSize, alignedEdge)
|
|
2722
2853
|
|
|
2723
2854
|
const bottomInset =
|
|
2724
2855
|
resolvedInsets.bottom > 0 ? (
|
|
@@ -2727,7 +2858,7 @@ const VirtualScrollInner = <T,>(
|
|
|
2727
2858
|
className="aqvs-bottom-inset"
|
|
2728
2859
|
style={{
|
|
2729
2860
|
// ラッパー座標での bottom インセット位置 = コンテンツ総高さ (最終行の下端) - アンカー。
|
|
2730
|
-
top:
|
|
2861
|
+
top: contentTotal - renderAnchor,
|
|
2731
2862
|
height: resolvedInsets.bottom,
|
|
2732
2863
|
}}
|
|
2733
2864
|
/>
|
|
@@ -2735,19 +2866,20 @@ const VirtualScrollInner = <T,>(
|
|
|
2735
2866
|
|
|
2736
2867
|
Logger.debug("[VirtualScroll] Rendering items", () => ({
|
|
2737
2868
|
containerTop,
|
|
2869
|
+
snapEdge,
|
|
2738
2870
|
logicalScrollPosition,
|
|
2739
2871
|
resolvedInsets,
|
|
2740
2872
|
effectiveScrollPosition,
|
|
2741
2873
|
}))
|
|
2742
2874
|
|
|
2743
2875
|
return (
|
|
2744
|
-
<ItemsWrapper translateY={containerTop}>
|
|
2876
|
+
<ItemsWrapper translateY={containerTop} snapEdge={snapEdge}>
|
|
2745
2877
|
{visibleItems}
|
|
2746
2878
|
{bottomInset}
|
|
2747
2879
|
</ItemsWrapper>
|
|
2748
2880
|
)
|
|
2749
2881
|
},
|
|
2750
|
-
[callbackThrottleMs, itemCount, fenwickTree, logicalScrollPosition, renderAnchor, renderingEndIndex, renderingStartIndex, resolvedInsets, scrollPosition, viewportSize, visibleItems],
|
|
2882
|
+
[alignedEdge, callbackThrottleMs, itemCount, fenwickTree, logicalScrollPosition, renderAnchor, renderingEndIndex, renderingStartIndex, resolvedInsets, scrollPosition, viewportSize, visibleItems],
|
|
2751
2883
|
)
|
|
2752
2884
|
|
|
2753
2885
|
const currentRange = useMemo<VirtualScrollRange>(
|