@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.
@@ -1,4 +1,5 @@
1
- import React, { forwardRef, type ReactNode, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"
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
- /** Scrolls to a specific item index / 指定したアイテムインデックスへスクロール */
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
- * Snaps a CSS-px offset to the device-pixel grid of a window: the nearest offset whose device-px value is a
561
- * whole number, `Math.round(cssPx × ratio) / ratio` (an exact half rounds toward +∞, as `Math.round` does). The items-wrapper translate goes through it: a composited layer moved by a fraction of a
562
- * device pixel is resampled as a whole (2-px outlines, gaps and rings smear across neighbouring device rows and
563
- * text blurs), while fractional layout positions inside the layer are painted on whole pixels already.
564
- * Idempotent: a snapped value snaps to itself. A non-finite `cssPx` propagates (`NaN` in, `NaN` out).
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
- * @param cssPx - Offset in CSS px; negative and fractional values included / CSS px のオフセット (負・小数を含む)
575
- * @param ratio - Device px per CSS px of the window that paints the offset (its `devicePixelRatio`); a finite number > 0 / オフセットを描くウィンドウの CSS px あたりの装置 px (そのウィンドウの `devicePixelRatio`。0 より大きい有限数)
576
- * @returns The snapped offset in CSS px / 揃えたオフセット (CSS px)
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
- export const snapToDevicePixelGrid = (cssPx: number, ratio: number): number => {
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
- * Returns the window of the document the element belongs to — the window whose screen paints it.
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
- * @param element - Element of the list / 一覧の要素
593
- * @returns The window / ウィンドウ
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
- const windowOf = (element: Element): Window => {
597
- const view = element.ownerDocument.defaultView
598
- if (view === null) {
599
- throw new Error("[VirtualScroll] the items wrapper belongs to a document without a window, so it has no device-pixel ratio")
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
- * Unsubscribe that removes nothing: the subscription of a window without media queries, or of a wrapper with no
606
- * painting window yet.
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 unsubscribeNothing = (): void => {}
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
- * Server snapshot of the painting ratio for `useSyncExternalStore`. A server has no screen, so it is `null`
614
- * and the wrapper keeps the exact translate; hydration renders with it too, so the hydrated markup matches the
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
- * `useSyncExternalStore` に渡す、描く比のサーバーのスナップショット。サーバーに画面は無いので `null` で、
618
- * ラッパーは厳密な平行移動のまま。ハイドレーションもこの値で描くのでサーバーの HTML と一致し、
619
- * ハイドレーションの直後に React が揃えた平行移動へ置き換える。
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
- * @returns Always `null` / 常に `null`
622
- */
623
- const readServerDevicePixelRatio = (): null => null
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
- * React を実行している JavaScript のレルムのウィンドウを返す処理 (ブラウザのレルムの外 = サーバー描画では
632
- * `null`)。まだ取り付いていないラッパーの、描くと見込むウィンドウ。描画からは挿入先の文書が見えず、ホストは
633
- * 別のウィンドウの文書 (iframe・開いたウィンドウ) へ意図して描くのでない限り自分のウィンドウの文書へ描くため。
634
- * 見込みの確かめは取り付けが行う (`ItemsWrapper` を参照)。
696
+ * 行ラッパーの平行移動を装置の画素の格子へ揃えるとき (`snapToDevicePixelGrid`) に守る端を選ぶ処理。揃えた中身の端の余白を
697
+ * 丸めが削らないようにする。
635
698
  *
636
- * @returns The realm's window, or `null` without one / レルムのウィンドウ (無ければ `null`)
637
- */
638
- const readRealmWindow = (): Window | null => (typeof window === "undefined" ? null : window)
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
- * たびに新しい比で張り直す `(resolution: <比>dppx)` のメディアクエリの監視で購読する処理。メディアクエリを
647
- * 持たないウィンドウ (jsdom) は比が変わり得ないので何も張らない。
705
+ * 位置 0 は最大位置に勝ち (スクロールしない一覧は上端揃えのまま)、どちらも覚えた揃えに勝つ (クランプは揃えが届かなかった
706
+ * ことを意味する)。これらの位置から `EDGE_POSITION_TOLERANCE` 以内の位置はその位置とみなす。モジュールレベル export
707
+ * (バレル非公開)。
648
708
  *
649
- * @param view - Window whose ratio is watched / 比を監視するウィンドウ
650
- * @param onChange - Called after each ratio change, once the watch is re-armed at the new ratio / 比が変わるたび、新しい比で監視を張り直した後に呼ぶ
651
- * @returns Unsubscribe that removes the current watch / 今の監視を外す購読解除
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 subscribeToDevicePixelRatio = (view: Window, onChange: () => void): (() => void) => {
654
- if (typeof view.matchMedia !== "function") {
655
- return unsubscribeNothing
714
+ export const resolveItemsWrapperSnapEdge = (panePosition: number, maxPanePosition: number, alignedEdge: AlignedEdge | null): DevicePixelSnapEdge => {
715
+ if (panePosition <= EDGE_POSITION_TOLERANCE) {
716
+ return "start"
656
717
  }
657
- let resolutionQuery = view.matchMedia(`(resolution: ${view.devicePixelRatio}dppx)`)
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
- resolutionQuery.addEventListener("change", handleResolutionChange)
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 unsubscribe
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
- * device-pixel grid of the window that paints it (`snapToDevicePixelGrid`). Rule: the translate that reaches
698
- * the screen is always snapped with the ratio of the window that paints the wrapper. Render reads the ratio
699
- * through `useSyncExternalStore` from the expected painting window — the realm's window (`readRealmWindow`)
700
- * until the wrapper has attached, since render cannot see the target document. The attach confirms it with the
701
- * wrapper's own window (`windowOf`): when they agree (a list rendered into its own window's document), the first
702
- * commit is already snapped and nothing is scheduled in the commit phase; when they differ (a list rendered into
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
- * 格子へ揃える (`snapToDevicePixelGrid`)。規則は「画面へ届く平行移動は、常にラッパーを描くウィンドウの比で
711
- * 揃っている」。描画は `useSyncExternalStore` で、描くと見込むウィンドウから比を読む — 描画からは挿入先の文書が
712
- * 見えないので、ラッパーが取り付くまではレルムのウィンドウ (`readRealmWindow`)。取り付けがラッパー自身の
713
- * ウィンドウ (`windowOf`) と突き合わせて確かめ、一致すれば (自分のウィンドウの文書に描いた一覧) 最初の確定から
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 `windowOf`) / ウィンドウを持たない文書へ取り付いたとき (`windowOf` を参照)
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 [paintingWindow, setPaintingWindow] = useState<Window | null>(readRealmWindow)
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 = paintingRatio === null ? translateY : snapToDevicePixelGrid(translateY, paintingRatio)
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={confirmPaintingWindow} className="aqvs-items-wrapper" style={{ top: 0, transform: `translateY(${wrapperTranslateY}px)`, willChange: "transform" }}>
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
- const appliedPosition = issueCompensationScroll(latestScrollPositionRef.current)
1777
- // 再発行後もなお目標に届かない場合 (目標が負値等) はクランプ済み実位置へ収束させ、
1778
- // 再発行の連鎖を断つ
1779
- pendingCompensationRef.current = false
1780
- updateScrollPositionImmediate(appliedPosition, { immediate: true })
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
- if (Math.abs(targetPanePosition - latestScrollPositionRef.current) > 1.0) {
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
- // Guard against double increment if multiple effects fire before scroll handles it
1819
- if (isCompensatingRef.current === 0) {
1820
- isCompensatingRef.current += 1
1821
- }
1822
- scrollPaneRef.current?.scrollTo(targetPanePosition)
1823
- updateScrollPositionImmediate(targetPanePosition, { immediate: true })
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
- // 同一同期バッチ内では ScrollPane の sizeRef が旧 contentSize のままのため、scrollTo は
1906
- // 旧最大値でクランプされ得る。issueCompensationScroll がカウンタのリークを防ぎつつ乖離時の
1907
- // 再発行を予約し、論理位置 (latestScrollPositionRef) には補正目標を保持して二段目の収束先とする。
1908
- issueCompensationScroll(newPosition, delta)
1909
- updateScrollPositionImmediate(newPosition, { immediate: true })
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
- const shouldScroll = Math.abs(currentPanePosition - clampedPaneOffset) > 0.5
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
- // updateItemSize と同型: scrollTo は旧 contentSize でクランプされ得るため、
2644
- // issueCompensationScroll でリークを防ぎつつ、乖離時は contentSize 同期 effect で再発行する。
2645
- issueCompensationScroll(newPosition, shiftAmount)
2646
- updateScrollPositionImmediate(newPosition, { immediate: true })
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; rows keep their exact tops.
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: fenwickTree.getTotal() - renderAnchor,
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>(