@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.
@@ -0,0 +1,308 @@
1
+ /**
2
+ * The device-pixel grid of the window that paints an element: the snap that puts a CSS-px offset on that grid toward a
3
+ * chosen edge, and the hook that reads the painting window's device-pixel ratio during render. The items wrapper of
4
+ * `VirtualScroll` and the scrollbar thumb of `ScrollBar` are both translated, so both read their ratio here and move by
5
+ * whole device pixels. Module-level exports (NOT in the package barrel).
6
+ *
7
+ * 要素を描くウィンドウの装置の画素の格子。CSS px のオフセットを選んだ端の側へその格子へ揃える処理と、描くウィンドウの
8
+ * 装置の画素比を描画の中で読むフック。`VirtualScroll` の行ラッパーと `ScrollBar` のつまみはどちらも平行移動で動くので、
9
+ * どちらもここで比を読み、装置の画素の整数だけ動く。モジュールレベルの export (パッケージのバレルには出さない)。
10
+ */
11
+ import { useCallback, useState, useSyncExternalStore } from "react"
12
+
13
+ /**
14
+ * The edge a snapped offset keeps when the exact offset falls between two grid points, for an offset that translates
15
+ * content along an axis (a positive offset moves the content toward the end):
16
+ *
17
+ * - `"none"`: the nearest grid point, `Math.round` (an exact half rounds toward +∞).
18
+ * - `"end"`: the grid point at or before the exact offset, `Math.floor`. The content never moves toward the viewport end,
19
+ * so content aligned to the end (the last row at the maximum position, a row revealed with `align: "bottom"`) never
20
+ * crosses it.
21
+ * - `"start"`: the grid point at or after the exact offset, `Math.ceil`. The content never moves toward the viewport start,
22
+ * so content aligned to the start (the first row at position 0, a row revealed with `align: "top"`) never crosses it.
23
+ *
24
+ * 正確なオフセットが 2 つの格子点の間にあるとき、揃えたオフセットがどちらの端を守るか (中身を軸に沿って動かすオフセット。
25
+ * 正のオフセットは中身を終端側へ動かす)。
26
+ *
27
+ * - `"none"`: 最も近い格子点 (`Math.round`。ちょうど半分は +∞ 側)。
28
+ * - `"end"`: 正確なオフセット以下の格子点 (`Math.floor`)。中身は表示域の終端側へ動かないので、終端に揃えた中身
29
+ * (最大位置の最後の行・`align: "bottom"` で見せた行) は終端を越えない。
30
+ * - `"start"`: 正確なオフセット以上の格子点 (`Math.ceil`)。中身は表示域の始端側へ動かないので、始端に揃えた中身
31
+ * (位置 0 の最初の行・`align: "top"` で見せた行) は始端を越えない。
32
+ */
33
+ export type DevicePixelSnapEdge = "none" | "start" | "end"
34
+
35
+ /**
36
+ * Distance in device px within which the product of an offset and its ratio counts as a whole number. In floating point,
37
+ * the product of an offset already on the grid and its ratio lands within about 1e-8 device px of the whole number (the
38
+ * translates stay within a few million device px), and floor or ceil of such a residue would move the content by a whole
39
+ * device pixel. A genuine fraction this small is far below anything a screen shows.
40
+ *
41
+ * オフセットと比の積を整数とみなす距離 (装置 px)。浮動小数点では、格子上のオフセットと比の積は整数から 1e-8 装置 px
42
+ * ほどずれる (平行移動は数百万装置 px 以内に収まる)。その残差を floor や ceil すると中身が装置の画素 1 つ分動いてしまう。
43
+ * この大きさの本物の端数は画面に現れない。
44
+ */
45
+ const ON_GRID_TOLERANCE_DEVICE_PX = 1e-6
46
+
47
+ /**
48
+ * Snaps a CSS-px offset to the device-pixel grid of a window toward `edge`: an offset whose device-px value is a whole
49
+ * number, at most one device pixel from the exact one (`"none"`: at most half a device pixel). A product within
50
+ * `ON_GRID_TOLERANCE_DEVICE_PX` of a whole number is on the grid already, whatever the edge, so the snap is idempotent
51
+ * for every edge and floating-point residue never moves the content by a whole device pixel. A non-finite `cssPx`
52
+ * propagates (`NaN` in, `NaN` out).
53
+ *
54
+ * CSS px のオフセットをウィンドウの装置の画素の格子へ `edge` の側で揃える処理。装置 px の値が整数になるオフセットで、
55
+ * 正確な値から装置の画素 1 つ未満 (`"none"` は半分以内)。整数から `ON_GRID_TOLERANCE_DEVICE_PX` 以内の積はどの端でも
56
+ * 既に格子上とみなすので、どの端でも冪等で、浮動小数点の残差が中身を装置の画素 1 つ分動かすことはない。有限でない
57
+ * `cssPx` はそのまま伝わる (`NaN` は `NaN`)。
58
+ *
59
+ * @param cssPx - Offset in CSS px; negative and fractional values included / CSS px のオフセット (負・小数を含む)
60
+ * @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 より大きい有限数)
61
+ * @param edge - The edge the snapped offset keeps (see `DevicePixelSnapEdge`) / 揃えたオフセットが守る端 (`DevicePixelSnapEdge` を参照)
62
+ * @returns The snapped offset in CSS px / 揃えたオフセット (CSS px)
63
+ * @throws {RangeError} When `ratio` is not a finite number greater than 0 / `ratio` が 0 より大きい有限数でないとき
64
+ */
65
+ export const snapToDevicePixelGrid = (cssPx: number, ratio: number, edge: DevicePixelSnapEdge): number => {
66
+ if (!(Number.isFinite(ratio) && ratio > 0)) {
67
+ throw new RangeError(`[VirtualScroll] devicePixelRatio must be a finite number > 0, received ${ratio}`)
68
+ }
69
+ const devicePx = cssPx * ratio
70
+ const nearest = Math.round(devicePx)
71
+ if (edge === "none" || Math.abs(devicePx - nearest) <= ON_GRID_TOLERANCE_DEVICE_PX) {
72
+ return nearest / ratio
73
+ }
74
+ return (edge === "end" ? Math.floor(devicePx) : Math.ceil(devicePx)) / ratio
75
+ }
76
+
77
+ /**
78
+ * Returns the window of the document an element belongs to — the window whose screen paints it.
79
+ *
80
+ * 要素が属する文書のウィンドウ (要素を描く画面を持つウィンドウ) を返す処理。大域の `window` は使わない
81
+ * (別ウィンドウ・iframe へ描いた要素の装置の画素比は、そのウィンドウのもの)。
82
+ *
83
+ * @param element - The painted element / 描かれる要素
84
+ * @param part - The element's name for the error, with the owning component (e.g. `"[VirtualScroll] the items wrapper"`) / 誤りに使う要素の名前 (持ち主の部品を含む。例: `"[VirtualScroll] the items wrapper"`)
85
+ * @returns The window / ウィンドウ
86
+ * @throws {Error} When the element's document has no window (a document outside any browsing context, which paints nothing) / 要素の文書がウィンドウを持たないとき (閲覧の文脈の外の文書で、何も描かれない)
87
+ */
88
+ const windowOf = (element: Element, part: string): Window => {
89
+ const view = element.ownerDocument.defaultView
90
+ if (view === null) {
91
+ throw new Error(`${part} belongs to a document without a window, so it has no device-pixel ratio`)
92
+ }
93
+ return view
94
+ }
95
+
96
+ /**
97
+ * Unsubscribe that removes nothing: the subscription of a window without media queries, or of an element with no
98
+ * painting window yet.
99
+ *
100
+ * 何も外さない購読解除 (メディアクエリを持たないウィンドウ、または描くウィンドウがまだ無い要素の購読)。
101
+ */
102
+ const unsubscribeNothing = (): void => {}
103
+
104
+ /**
105
+ * Server snapshot of the painting ratio for `useSyncExternalStore`. A server has no screen, so it is `null` and the
106
+ * element keeps its exact offset; hydration renders with it too, so the hydrated markup matches the server HTML, and
107
+ * React replaces it with the snapped offset right after hydration.
108
+ *
109
+ * `useSyncExternalStore` に渡す、描く比のサーバーのスナップショット。サーバーに画面は無いので `null` で、要素は
110
+ * 厳密なオフセットのまま。ハイドレーションもこの値で描くのでサーバーの HTML と一致し、ハイドレーションの直後に
111
+ * React が揃えたオフセットへ置き換える。
112
+ *
113
+ * @returns Always `null` / 常に `null`
114
+ */
115
+ const readServerDevicePixelRatio = (): null => null
116
+
117
+ /**
118
+ * Returns the window of the JavaScript realm that runs React, or `null` outside a browser realm (server rendering).
119
+ * It is the expected painting window of an element that has not attached yet: render cannot see the document it will
120
+ * be inserted into, and a host renders into its own window's document unless it deliberately renders into another
121
+ * window's document (an iframe or an opened window), which the attach detects (see `usePaintingDevicePixelRatio`).
122
+ *
123
+ * React を実行している JavaScript のレルムのウィンドウを返す処理 (ブラウザのレルムの外 = サーバー描画では `null`)。
124
+ * まだ取り付いていない要素の、描くと見込むウィンドウ。描画からは挿入先の文書が見えず、ホストは別のウィンドウの文書
125
+ * (iframe・開いたウィンドウ) へ意図して描くのでない限り自分のウィンドウの文書へ描くため。見込みの確かめは取り付けが
126
+ * 行う (`usePaintingDevicePixelRatio` を参照)。
127
+ *
128
+ * @returns The realm's window, or `null` without one / レルムのウィンドウ (無ければ `null`)
129
+ */
130
+ const readRealmWindow = (): Window | null => (typeof window === "undefined" ? null : window)
131
+
132
+ /**
133
+ * The subscribers of one window's ratio and the removal of its media-query watch (`null` while nobody subscribes).
134
+ *
135
+ * 1 つのウィンドウの比の購読者と、そのメディアクエリの監視を外す処理 (誰も購読していない間は `null`)。
136
+ */
137
+ type DevicePixelRatioWatch = {
138
+ /** Callbacks to run after each ratio change / 比が変わるたびに呼ぶコールバック */
139
+ readonly subscribers: Set<() => void>
140
+ /** Removes the armed watch, or `null` while none is armed / 張った監視を外す処理 (張っていなければ `null`) */
141
+ disarm: (() => void) | null
142
+ }
143
+
144
+ /**
145
+ * One watch per window, shared by every element that snaps in it (an items wrapper and the scrollbar thumbs of each list).
146
+ *
147
+ * ウィンドウごとに 1 つの監視。そのウィンドウで揃えるすべての要素 (各一覧の行ラッパーとスクロールバーのつまみ) が共有する。
148
+ */
149
+ const devicePixelRatioWatches = new WeakMap<Window, DevicePixelRatioWatch>()
150
+
151
+ /**
152
+ * Arms a `(resolution: <ratio>dppx)` media-query watch on a window that is re-armed at each new ratio and runs every
153
+ * subscriber after each change.
154
+ *
155
+ * ウィンドウへ `(resolution: <比>dppx)` のメディアクエリの監視を張る処理。比が変わるたびに新しい比で張り直し、変化のたびに
156
+ * すべての購読者を呼ぶ。
157
+ *
158
+ * @param view - Window whose ratio is watched (it has `matchMedia`) / 比を監視するウィンドウ (`matchMedia` を持つ)
159
+ * @param subscribers - The subscribers, read at each change / 購読者 (変化のたびに読む)
160
+ * @returns Removal of the current watch / 今の監視を外す処理
161
+ */
162
+ const armDevicePixelRatioWatch = (view: Window, subscribers: ReadonlySet<() => void>): (() => void) => {
163
+ let resolutionQuery = view.matchMedia(`(resolution: ${view.devicePixelRatio}dppx)`)
164
+ /**
165
+ * Re-arms the watch at the window's new ratio, then reports the change to every subscriber.
166
+ *
167
+ * ウィンドウの新しい比で監視を張り直してから、すべての購読者へ変化を知らせる処理。
168
+ */
169
+ const handleResolutionChange = (): void => {
170
+ resolutionQuery.removeEventListener("change", handleResolutionChange)
171
+ // ❗ `(resolution: Ndppx)` が知らせるのは比 N との一致・不一致の切り替わりだけ。N 以外の比どうしの変化
172
+ // (もう一度の拡大縮小・さらに別の画面への移動) を捉えるには、変化のたびに新しい比で張り直すしかない
173
+ resolutionQuery = view.matchMedia(`(resolution: ${view.devicePixelRatio}dppx)`)
174
+ resolutionQuery.addEventListener("change", handleResolutionChange)
175
+ // 購読者の呼び出し中に購読が増減しても、この変化を知らせる相手は変化の時点の購読者
176
+ for (const subscriber of [...subscribers]) {
177
+ subscriber()
178
+ }
179
+ }
180
+ resolutionQuery.addEventListener("change", handleResolutionChange)
181
+ /**
182
+ * Removes the current watch.
183
+ *
184
+ * 今の監視を外す処理。
185
+ */
186
+ const disarm = (): void => {
187
+ resolutionQuery.removeEventListener("change", handleResolutionChange)
188
+ }
189
+ return disarm
190
+ }
191
+
192
+ /**
193
+ * Subscribes to the device-pixel-ratio changes of a window (browser zoom, or the window moving to a screen of another
194
+ * density). All subscribers of a window share one `(resolution: <ratio>dppx)` media-query watch (`armDevicePixelRatioWatch`),
195
+ * armed with the first subscriber and removed with the last. A window without media queries (jsdom) cannot change its
196
+ * ratio, so nothing is armed there.
197
+ *
198
+ * ウィンドウの装置の画素比の変化 (ブラウザの拡大縮小・密度の違う画面へのウィンドウの移動) を購読する処理。ウィンドウの
199
+ * 購読者はすべて 1 つの `(resolution: <比>dppx)` のメディアクエリの監視 (`armDevicePixelRatioWatch`) を共有し、監視は
200
+ * 最初の購読者で張り、最後の購読者とともに外す。メディアクエリを持たないウィンドウ (jsdom) は比が変わり得ないので
201
+ * 何も張らない。
202
+ *
203
+ * @param view - Window whose ratio is watched / 比を監視するウィンドウ
204
+ * @param onChange - Called after each ratio change, once the watch is re-armed at the new ratio / 比が変わるたび、新しい比で監視を張り直した後に呼ぶ
205
+ * @returns Unsubscribe; the last one removes the watch / 購読解除 (最後の購読解除が監視を外す)
206
+ */
207
+ const subscribeToDevicePixelRatio = (view: Window, onChange: () => void): (() => void) => {
208
+ if (typeof view.matchMedia !== "function") {
209
+ return unsubscribeNothing
210
+ }
211
+ const known = devicePixelRatioWatches.get(view)
212
+ const watch: DevicePixelRatioWatch = known === undefined ? { subscribers: new Set(), disarm: null } : known
213
+ if (known === undefined) {
214
+ devicePixelRatioWatches.set(view, watch)
215
+ }
216
+ watch.subscribers.add(onChange)
217
+ if (watch.disarm === null) {
218
+ watch.disarm = armDevicePixelRatioWatch(view, watch.subscribers)
219
+ }
220
+ /**
221
+ * Removes this subscriber; the last one also removes the watch.
222
+ *
223
+ * この購読者を外す処理。最後の購読者なら監視も外す。
224
+ */
225
+ const unsubscribe = (): void => {
226
+ watch.subscribers.delete(onChange)
227
+ if (watch.subscribers.size === 0 && watch.disarm !== null) {
228
+ watch.disarm()
229
+ watch.disarm = null
230
+ }
231
+ }
232
+ return unsubscribe
233
+ }
234
+
235
+ /**
236
+ * The painting window's device-pixel ratio and the ref callback that confirms that window (see `usePaintingDevicePixelRatio`).
237
+ *
238
+ * 描くウィンドウの装置の画素比と、そのウィンドウを確かめる ref コールバック (`usePaintingDevicePixelRatio` を参照)。
239
+ */
240
+ export type PaintingDevicePixelRatio = {
241
+ /** The ratio, or `null` while no window is known (server rendering and hydration) / 比 (ウィンドウが分からない間 = サーバー描画とハイドレーションは `null`) */
242
+ readonly ratio: number | null
243
+ /** Ref callback for the translated element / 平行移動する要素の ref コールバック */
244
+ readonly attach: (element: Element | null) => void
245
+ }
246
+
247
+ /**
248
+ * Reads, during render, the device-pixel ratio of the window that paints an element, for snapping the element's translate
249
+ * (`snapToDevicePixelGrid`). Rule: the translate that reaches the screen is always snapped with the ratio of the window
250
+ * that paints the element. Render reads the ratio through `useSyncExternalStore` from the expected painting window — the
251
+ * realm's window (`readRealmWindow`) until the element has attached, since render cannot see the target document. The
252
+ * attach (`attach`, the element's ref callback) confirms it with the element's own window (`windowOf`): when they agree
253
+ * (an element rendered into its own window's document), the first commit is already snapped and nothing is scheduled in
254
+ * the commit phase; when they differ (an iframe or an opened window), the attach switches to that window; the update is
255
+ * scheduled in the commit phase, so React re-renders synchronously before the browser paints. Ratio changes re-render
256
+ * through the store subscription (`subscribeToDevicePixelRatio`), outside the commit phase. The server snapshot is
257
+ * `null`: server HTML and hydration carry the exact offset, replaced by the snapped one right after hydration.
258
+ *
259
+ * 要素の平行移動を揃えるため (`snapToDevicePixelGrid`)、要素を描くウィンドウの装置の画素比を描画の中で読むフック。規則は
260
+ * 「画面へ届く平行移動は、常に要素を描くウィンドウの比で揃っている」。描画は `useSyncExternalStore` で、描くと見込む
261
+ * ウィンドウから比を読む — 描画からは挿入先の文書が見えないので、要素が取り付くまではレルムのウィンドウ
262
+ * (`readRealmWindow`)。取り付け (`attach`。要素の ref コールバック) が要素自身のウィンドウ (`windowOf`) と突き合わせて
263
+ * 確かめ、一致すれば (自分のウィンドウの文書に描いた要素) 最初の確定から揃っていて確定の段では何も予約しない。違えば
264
+ * (iframe・開いたウィンドウ) 取り付けがそのウィンドウへ切り替え、この更新は確定の段で予約されるので、React はブラウザの
265
+ * paint の前に同期で描き直す。比の変化はストアの購読 (`subscribeToDevicePixelRatio`) で確定の段の外から描き直す。
266
+ * サーバーのスナップショットは `null` で、サーバーの HTML とハイドレーションは厳密なオフセットを持ち、ハイドレーションの
267
+ * 直後に揃えた値へ置き換わる。
268
+ *
269
+ * @param part - The element's name for the error, with the owning component (e.g. `"[VirtualScroll] the items wrapper"`) / 誤りに使う要素の名前 (持ち主の部品を含む)
270
+ * @returns The ratio and the ref callback / 比と ref コールバック
271
+ * @throws {Error} When the element attaches to a document without a window (see `windowOf`) / ウィンドウを持たない文書へ取り付いたとき (`windowOf` を参照)
272
+ */
273
+ export const usePaintingDevicePixelRatio = (part: string): PaintingDevicePixelRatio => {
274
+ const [paintingWindow, setPaintingWindow] = useState<Window | null>(readRealmWindow)
275
+ /**
276
+ * Subscribes the store to the painting window's ratio changes.
277
+ *
278
+ * 描くウィンドウの比の変化へストアを購読させる処理。
279
+ */
280
+ const subscribe = useCallback((onChange: () => void): (() => void) => (paintingWindow === null ? unsubscribeNothing : subscribeToDevicePixelRatio(paintingWindow, onChange)), [paintingWindow])
281
+ /**
282
+ * Reads the painting window's current ratio (`null` while no window is known).
283
+ *
284
+ * 描くウィンドウの今の比を読む処理 (ウィンドウが分からない間は `null`)。
285
+ */
286
+ const readDevicePixelRatio = useCallback((): number | null => (paintingWindow === null ? null : paintingWindow.devicePixelRatio), [paintingWindow])
287
+ // 大域の devicePixelRatio と同名にしない — 宣言を消したとき参照が黙って大域のウィンドウの比へ化けるため
288
+ const ratio = useSyncExternalStore(subscribe, readDevicePixelRatio, readServerDevicePixelRatio)
289
+ /**
290
+ * Ref callback that confirms the expected painting window against the window of the element's document.
291
+ *
292
+ * 描くと見込んだウィンドウを、要素の文書のウィンドウと突き合わせて確かめる ref コールバック。
293
+ */
294
+ const attach = useCallback(
295
+ (element: Element | null): void => {
296
+ if (element === null) {
297
+ return
298
+ }
299
+ const view = windowOf(element, part)
300
+ // 同じウィンドウなら描画で読んだ比が正しく、確定の段の更新 (paint 前の同期の描き直し) は要らない
301
+ if (view !== paintingWindow) {
302
+ setPaintingWindow(view)
303
+ }
304
+ },
305
+ [paintingWindow, part],
306
+ )
307
+ return { ratio, attach }
308
+ }
package/src/index.ts CHANGED
@@ -33,6 +33,8 @@ export {
33
33
  } from "./VirtualGrid.tsx"
34
34
  export {
35
35
  VirtualScroll,
36
+ type VirtualScrollAdjustment,
37
+ type VirtualScrollAdjustmentCause,
36
38
  type VirtualScrollBehaviorOptions,
37
39
  type VirtualScrollHandle,
38
40
  type VirtualScrollLiveRegionOptions,