@design-edito/tools 0.5.5 → 0.5.7
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/agnostic/css/index.d.ts +1 -1
- package/agnostic/css/index.js +1 -1
- package/agnostic/errors/index.d.ts +1 -1
- package/agnostic/errors/index.js +1 -1
- package/agnostic/html/hyper-json/smart-tags/coalesced/index.d.ts +8 -8
- package/agnostic/html/hyper-json/smart-tags/coalesced/index.js +8 -8
- package/agnostic/html/hyper-json/smart-tags/isolated/index.d.ts +2 -2
- package/agnostic/html/hyper-json/smart-tags/isolated/index.js +2 -2
- package/agnostic/index.d.ts +1 -0
- package/agnostic/index.js +1 -0
- package/agnostic/misc/index.d.ts +1 -1
- package/agnostic/misc/index.js +1 -1
- package/agnostic/objects/index.d.ts +2 -2
- package/agnostic/objects/index.js +2 -2
- package/agnostic/random/index.d.ts +1 -1
- package/agnostic/random/index.js +1 -1
- package/agnostic/strings/index.d.ts +1 -1
- package/agnostic/strings/index.js +1 -1
- package/agnostic/subtitles/index.d.ts +2 -0
- package/agnostic/subtitles/index.js +2 -0
- package/agnostic/subtitles/parse-srt/index.d.ts +17 -0
- package/agnostic/subtitles/parse-srt/index.js +67 -0
- package/agnostic/subtitles/parse-srt/index.test.d.ts +1 -0
- package/agnostic/subtitles/parse-srt/index.test.js +94 -0
- package/agnostic/subtitles/parse-srt/types.d.ts +22 -0
- package/agnostic/subtitles/parse-srt/types.js +1 -0
- package/agnostic/subtitles/srt-timecode-to-ms/index.d.ts +14 -0
- package/agnostic/subtitles/srt-timecode-to-ms/index.js +36 -0
- package/agnostic/subtitles/srt-timecode-to-ms/index.test.d.ts +1 -0
- package/agnostic/subtitles/srt-timecode-to-ms/index.test.js +44 -0
- package/agnostic/time/dates/index.d.ts +1 -1
- package/agnostic/time/dates/index.js +1 -1
- package/components/ListLoader/index.controlled.d.ts +8 -3
- package/components/ListLoader/index.controlled.js +2 -2
- package/components/ListLoader/index.d.ts +1 -1
- package/components/ListLoader/index.js +2 -2
- package/components/Paginator/index.d.ts +5 -0
- package/components/Paginator/index.js +2 -1
- package/components/Scrllgngn/index.d.ts +68 -8
- package/components/Scrllgngn/index.js +231 -27
- package/components/Scrllgngn/styles.module.css +30 -5
- package/components/Scrllgngn/utils.d.ts +222 -4
- package/components/Scrllgngn/utils.js +335 -8
- package/components/ScrollListener/index.d.ts +7 -3
- package/components/ScrollListener/index.js +2 -2
- package/components/Sequencer/index.d.ts +10 -1
- package/components/Sequencer/index.js +7 -3
- package/components/Subtitles/index.d.ts +53 -62
- package/components/Subtitles/index.js +90 -158
- package/components/Subtitles/utils.d.ts +60 -0
- package/components/Subtitles/utils.js +76 -0
- package/components/Video/index.controlled.d.ts +5 -0
- package/components/Video/index.controlled.js +22 -3
- package/components/Video/index.d.ts +47 -9
- package/components/Video/index.js +71 -20
- package/components/Video/utils.d.ts +14 -0
- package/components/Video/utils.js +18 -0
- package/components/utils/types.d.ts +32 -0
- package/index.d.ts +1 -1
- package/index.js +1 -1
- package/node/@google-cloud/storage/file/index.d.ts +1 -1
- package/node/@google-cloud/storage/file/index.js +1 -1
- package/node/files/index.d.ts +1 -1
- package/node/files/index.js +1 -1
- package/node/images/transform/operations/index.d.ts +1 -1
- package/node/images/transform/operations/index.js +1 -1
- package/package.json +26 -1
|
@@ -66,4 +66,4 @@ export type Props<T> = Omit<ControlledProps<T>, 'itemsPages' | 'loadingPages' |
|
|
|
66
66
|
* - No page outside `firstPagePos`–`lastPagePos` is ever fetched, whichever mode
|
|
67
67
|
* is in use.
|
|
68
68
|
*/
|
|
69
|
-
export declare const ListLoader: <T>({ className, pages, defaultPage, fillGaps, dropPagesFurtherThan, autoLoadPrevWhenVisible, autoLoadNextWhenVisible, firstPagePos, lastPagePos, fetch, filter, display, getIdentifier, onLoadPageClicked, staleAfterMs, onPageFetched, onPageFetchFailed, fetchRetriesNb, fetchRetriesDelayMs }: Props<T>) => ReactNode;
|
|
69
|
+
export declare const ListLoader: <T>({ className, pages, defaultPage, fillGaps, dropPagesFurtherThan, autoLoadPrevWhenVisible, autoLoadNextWhenVisible, threshold, root, rootMargin, firstPagePos, lastPagePos, fetch, filter, display, getIdentifier, onLoadPageClicked, staleAfterMs, onPageFetched, onPageFetchFailed, fetchRetriesNb, fetchRetriesDelayMs }: Props<T>) => ReactNode;
|
|
@@ -25,7 +25,7 @@ import { rangeBetween } from './utils.js';
|
|
|
25
25
|
* - No page outside `firstPagePos`–`lastPagePos` is ever fetched, whichever mode
|
|
26
26
|
* is in use.
|
|
27
27
|
*/
|
|
28
|
-
export const ListLoader = ({ className, pages, defaultPage, fillGaps = true, dropPagesFurtherThan, autoLoadPrevWhenVisible, autoLoadNextWhenVisible, firstPagePos, lastPagePos, fetch, filter, display, getIdentifier, onLoadPageClicked, staleAfterMs, onPageFetched, onPageFetchFailed, fetchRetriesNb = Infinity, fetchRetriesDelayMs = 1000 }) => {
|
|
28
|
+
export const ListLoader = ({ className, pages, defaultPage, fillGaps = true, dropPagesFurtherThan, autoLoadPrevWhenVisible, autoLoadNextWhenVisible, threshold, root, rootMargin, firstPagePos, lastPagePos, fetch, filter, display, getIdentifier, onLoadPageClicked, staleAfterMs, onPageFetched, onPageFetchFailed, fetchRetriesNb = Infinity, fetchRetriesDelayMs = 1000 }) => {
|
|
29
29
|
const [itemsPages, setItemsPages] = useState(new Map());
|
|
30
30
|
const [loadingPages, setLoadingPages] = useState(new Set());
|
|
31
31
|
const [internalPages, setInternalPages] = useState([
|
|
@@ -142,5 +142,5 @@ export const ListLoader = ({ className, pages, defaultPage, fillGaps = true, dro
|
|
|
142
142
|
const controlledItemsPages = new Map(Array
|
|
143
143
|
.from(itemsPages)
|
|
144
144
|
.map(([page, { items }]) => [page, items]));
|
|
145
|
-
return _jsx(ControlledListLoader, { className: className, pages: currentPages, firstPagePos: firstPagePos, lastPagePos: lastPagePos, itemsPages: controlledItemsPages, filter: filter, display: display, getIdentifier: getIdentifier, loadingPages: Array.from(loadingPages), onLoadPageClicked: handleLoadClick, autoLoadPrevWhenVisible: autoLoadPrevWhenVisible, autoLoadNextWhenVisible: autoLoadNextWhenVisible });
|
|
145
|
+
return _jsx(ControlledListLoader, { className: className, pages: currentPages, firstPagePos: firstPagePos, lastPagePos: lastPagePos, itemsPages: controlledItemsPages, filter: filter, display: display, getIdentifier: getIdentifier, loadingPages: Array.from(loadingPages), onLoadPageClicked: handleLoadClick, autoLoadPrevWhenVisible: autoLoadPrevWhenVisible, autoLoadNextWhenVisible: autoLoadNextWhenVisible, threshold: threshold, root: root, rootMargin: rootMargin });
|
|
146
146
|
};
|
|
@@ -32,6 +32,10 @@ type DirectionState = 'forwards' | 'backwards' | null;
|
|
|
32
32
|
* trigger it again.
|
|
33
33
|
* @property onPagesChanged - Called after any page's {@link PageState} changed,
|
|
34
34
|
* with a flat array of every page's state, ordered by position.
|
|
35
|
+
* @property onPageElementsChanged - Called with the page slot elements, in order,
|
|
36
|
+
* whenever the observed set changes. Unlike the other handlers it *does* fire on
|
|
37
|
+
* mount: the elements don't exist before it, so their appearance is the change.
|
|
38
|
+
* Lets a parent measure the pages without reaching into this component's DOM.
|
|
35
39
|
* @property className - Optional additional class name(s) applied to the root element.
|
|
36
40
|
* @property children - Each direct child is treated as an individual page slot.
|
|
37
41
|
*/
|
|
@@ -39,6 +43,7 @@ export type Props = PropsWithChildren<WithClassName<{
|
|
|
39
43
|
thresholdOffsetPercent?: number;
|
|
40
44
|
onDirectionChanged?: (direction: DirectionState) => void;
|
|
41
45
|
onPagesChanged?: (pages: PageState[]) => void;
|
|
46
|
+
onPageElementsChanged?: (pageElements: HTMLElement[]) => void;
|
|
42
47
|
}>>;
|
|
43
48
|
/**
|
|
44
49
|
* A scroll-driven pagination component that tracks which child page is currently
|
|
@@ -27,7 +27,7 @@ import cssModule from './styles.module.css';
|
|
|
27
27
|
* - `currCount` on each {@link PageState} increments each time a page transitions
|
|
28
28
|
* into the `'curr'` position, making it useful as a re-entry counter.
|
|
29
29
|
*/
|
|
30
|
-
export const Paginator = ({ thresholdOffsetPercent, onDirectionChanged, onPagesChanged, className, children }) => {
|
|
30
|
+
export const Paginator = ({ thresholdOffsetPercent, onDirectionChanged, onPagesChanged, onPageElementsChanged, className, children }) => {
|
|
31
31
|
// State, refs, effects
|
|
32
32
|
const [pagesState, setPagesState] = useState(new Map());
|
|
33
33
|
const [directionState, setDirectionState] = useState(null);
|
|
@@ -67,6 +67,7 @@ export const Paginator = ({ thresholdOffsetPercent, onDirectionChanged, onPagesC
|
|
|
67
67
|
if (pagesRef.current === null)
|
|
68
68
|
return;
|
|
69
69
|
const pages = Array.from(pagesRef.current.children);
|
|
70
|
+
onPageElementsChanged?.(pages.filter(page => page instanceof HTMLElement));
|
|
70
71
|
const observerRootMargin = `-${thresholdOffsetPercent ?? 0}%`
|
|
71
72
|
+ ' 0px'
|
|
72
73
|
+ ` -${100 - (thresholdOffsetPercent ?? 0)}%`
|
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
import { type PropsWithChildren, type FunctionComponent } from 'react';
|
|
2
2
|
import type { WithClassName } from '../utils/types.js';
|
|
3
|
+
import { type TrackedBlockContext, type ViewportOffset } from './utils.js';
|
|
3
4
|
/**
|
|
4
5
|
* Common properties shared by all block types.
|
|
5
6
|
*
|
|
6
7
|
* @property id - Optional stable identifier for the block. Used to consolidate
|
|
7
8
|
* blocks with the same id across multiple pages into a single sticky block
|
|
8
9
|
* displayed across those pages.
|
|
9
|
-
* @property
|
|
10
|
+
* @property onScrolled - Called with this block's {@link TrackedBlockContext} on
|
|
11
|
+
* every frame the scroll moved it, while it is displayed. Declaring it is what
|
|
12
|
+
* turns tracking on for the block — nothing is measured for a block without it.
|
|
10
13
|
* @property children - Content rendered inside the block.
|
|
11
14
|
*/
|
|
12
15
|
export type PropsCommonBlock = PropsWithChildren<{
|
|
13
16
|
id?: string;
|
|
14
|
-
|
|
17
|
+
onScrolled?: (context: TrackedBlockContext) => void;
|
|
15
18
|
}>;
|
|
16
19
|
/**
|
|
17
20
|
* A block that scrolls with the page content.
|
|
@@ -59,9 +62,17 @@ export type PropsPage = {
|
|
|
59
62
|
*
|
|
60
63
|
* @property pages - Ordered list of pages that compose the scrollytelling
|
|
61
64
|
* sequence. Each page may contain any mix of {@link PropsBlock} variants.
|
|
62
|
-
* @property thresholdOffsetPercent -
|
|
63
|
-
*
|
|
64
|
-
* which page
|
|
65
|
+
* @property thresholdOffsetPercent - Where the threshold line sits, as a
|
|
66
|
+
* percentage of the **visible zone** — not of the viewport, so a threshold stays
|
|
67
|
+
* put under a sticky nav. It decides which page counts as current, and anchors
|
|
68
|
+
* every progression handed to a tracked block.
|
|
69
|
+
* @property viewportOffsetTop - How much of the top of the screen is covered by
|
|
70
|
+
* something else — a sticky nav, say — and should be kept out of the visible zone.
|
|
71
|
+
* A number means pixels; a string is any CSS length, `var()` and `clamp()`
|
|
72
|
+
* included, so the value can follow a breakpoint without JavaScript.
|
|
73
|
+
* @property viewportOffsetRight - Same, on the right edge.
|
|
74
|
+
* @property viewportOffsetBottom - Same, on the bottom edge.
|
|
75
|
+
* @property viewportOffsetLeft - Same, on the left edge.
|
|
65
76
|
* @property stickyBlocksLazyLoadDistance - Number of pages around the current
|
|
66
77
|
* page within which sticky blocks are mounted. Blocks outside this window are
|
|
67
78
|
* unmounted to save resources. Defaults to `2`.
|
|
@@ -82,6 +93,10 @@ export type PropsPage = {
|
|
|
82
93
|
export type Props = WithClassName<{
|
|
83
94
|
pages?: PropsPage[];
|
|
84
95
|
thresholdOffsetPercent?: number;
|
|
96
|
+
viewportOffsetTop?: ViewportOffset;
|
|
97
|
+
viewportOffsetRight?: ViewportOffset;
|
|
98
|
+
viewportOffsetBottom?: ViewportOffset;
|
|
99
|
+
viewportOffsetLeft?: ViewportOffset;
|
|
85
100
|
stickyBlocksLazyLoadDistance?: number;
|
|
86
101
|
forceStickBlocks?: 'before' | 'after' | 'both' | 'none';
|
|
87
102
|
onPageChanged?: (currentPagePos: number, pageData?: PropsPage) => void;
|
|
@@ -122,6 +137,16 @@ export type Props = WithClassName<{
|
|
|
122
137
|
* `-width` and `-height` for the component's own stylesheet. They are internal:
|
|
123
138
|
* do not read or override them.
|
|
124
139
|
*
|
|
140
|
+
* ### Visible zone
|
|
141
|
+
* Set any of the `viewportOffset*` props and the component stops treating the whole
|
|
142
|
+
* screen as available: the fixed layers are inset by those edges, the top / content
|
|
143
|
+
* / bottom detection observes the reduced box, and `thresholdOffsetPercent` reads as
|
|
144
|
+
* a percentage of it. With none of them set, everything behaves exactly as before.
|
|
145
|
+
*
|
|
146
|
+
* The four edges are echoed as `--lm-scrllgngn-viewport-offset-top`, `-right`,
|
|
147
|
+
* `-bottom` and `-left`, carrying the length as authored — pixels for a number, the
|
|
148
|
+
* string untouched otherwise.
|
|
149
|
+
*
|
|
125
150
|
* ### Sticky block elements
|
|
126
151
|
* Each lazy-loaded sticky block receives:
|
|
127
152
|
* - `--active` modifier when the block's page range includes the current page.
|
|
@@ -129,10 +154,45 @@ export type Props = WithClassName<{
|
|
|
129
154
|
* - An inline `z-index` derived from the block's position in the sorted stack
|
|
130
155
|
* (overridden by {@link PropsStickyBlock.zIndex} if provided).
|
|
131
156
|
*
|
|
157
|
+
* ### Scroll block elements
|
|
158
|
+
* Each scroll block sits in its own wrapper, whatever it does — a block that isn't
|
|
159
|
+
* tracked still gets one, so the DOM keeps the same shape either way.
|
|
160
|
+
*
|
|
161
|
+
* ### Tracked block elements
|
|
162
|
+
* A block declaring {@link PropsCommonBlock.onScrolled} also carries its
|
|
163
|
+
* {@link TrackedBlockContext} on its wrapper, for scrollytelling driven in CSS
|
|
164
|
+
* alone. Written straight to the element on the frames they change, so they never
|
|
165
|
+
* re-render the sequence.
|
|
166
|
+
*
|
|
167
|
+
* Progressions, as unitless `0`–`1` ratios:
|
|
168
|
+
* - `--lm-scrllgngn-block-current-page-progression-ratio`
|
|
169
|
+
* - `--lm-scrllgngn-block-display-zone-progression-ratio`
|
|
170
|
+
* - `--lm-scrllgngn-block-contiguous-display-zone-progression-ratio`
|
|
171
|
+
*
|
|
172
|
+
* Position, which only moves with the page:
|
|
173
|
+
* - `data-current-page`
|
|
174
|
+
* - `data-display-zone` — comma-separated page positions.
|
|
175
|
+
* - `data-index-of-current-page-in-display-zone`
|
|
176
|
+
* - `data-contiguous-display-zone`
|
|
177
|
+
* - `data-index-of-current-page-in-contiguous-display-zone`
|
|
178
|
+
*
|
|
132
179
|
* @param props - Component properties.
|
|
133
180
|
* @see {@link Props}
|
|
134
|
-
* @returns A div wrapping the full scrollytelling structure:
|
|
135
|
-
* back-blocks layer, front-blocks layer, paginated scrolling
|
|
136
|
-
* bottom-bound sentinel.
|
|
181
|
+
* @returns A div wrapping the full scrollytelling structure: visible zone probe,
|
|
182
|
+
* top-bound sentinel, back-blocks layer, front-blocks layer, paginated scrolling
|
|
183
|
+
* content, and bottom-bound sentinel.
|
|
184
|
+
*
|
|
185
|
+
* @remarks
|
|
186
|
+
* A `viewportOffset*` given as a CSS length is resolved by CSS, not parsed: a hidden
|
|
187
|
+
* probe is inset by the four of them and observed, so `var()`, `clamp()` and
|
|
188
|
+
* breakpoint-dependent values all work, and any restyling that changes them is
|
|
189
|
+
* picked up as a resize. A change that moves the zone without altering its size —
|
|
190
|
+
* a top offset traded for an equal bottom one — is caught instead by a re-read every
|
|
191
|
+
* 100ms, which runs only while the component is on screen and only reaches state
|
|
192
|
+
* when the zone really moved.
|
|
193
|
+
*
|
|
194
|
+
* Tracking costs nothing until a block asks for it: with no `onScrolled` anywhere,
|
|
195
|
+
* the component never joins the shared scroll listener. Once it does, each frame
|
|
196
|
+
* measures only the pages the displayed tracked blocks span, not the whole sequence.
|
|
137
197
|
*/
|
|
138
198
|
export declare const Scrllgngn: FunctionComponent<Props>;
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
3
3
|
import { clss } from '../../agnostic/css/clss/index.js';
|
|
4
|
+
import { randomHash } from '../../agnostic/random/uuid/index.js';
|
|
4
5
|
import { IntersectionObserverComponent } from '../IntersectionObserver/index.js';
|
|
5
6
|
import { Paginator } from '../Paginator/index.js';
|
|
6
7
|
import { ResizeObserverComponent } from '../ResizeObserver/index.js';
|
|
8
|
+
import { subscribe, unsubscribe } from '../ScrollListener/utils.js';
|
|
7
9
|
import { mergeClassNames, useChangeDispatch } from '../utils/index.js';
|
|
8
10
|
import { scrllgngn as publicClassName } from '../public-classnames.js';
|
|
9
|
-
import { consolidateStickyBlocks, lazyLoadedBlocks, toScreenCssProps } from './utils.js';
|
|
11
|
+
import { blockDisplayZones, consolidateStickyBlocks, contextsAreEqual, lazyLoadedBlocks, measurePages, sameDiscretePart, scrollBlocksOf, scrollKey, stickyKey, toPaginatorThresholdPercent, toScreenCssProps, toThresholdY, toTrackedBlockContext, toTrackedBlockCssProps, toTrackedBlockDataAttributes, toViewportOffsetCssProps, toVisibleZoneRect, toVisibleZoneRootMargin, visibleZonePollInterval, visibleZonesAreEqual } from './utils.js';
|
|
10
12
|
import cssModule from './styles.module.css';
|
|
11
13
|
/**
|
|
12
14
|
* Scrollytelling engine component. Orchestrates layered sticky blocks (`back`
|
|
@@ -43,6 +45,16 @@ import cssModule from './styles.module.css';
|
|
|
43
45
|
* `-width` and `-height` for the component's own stylesheet. They are internal:
|
|
44
46
|
* do not read or override them.
|
|
45
47
|
*
|
|
48
|
+
* ### Visible zone
|
|
49
|
+
* Set any of the `viewportOffset*` props and the component stops treating the whole
|
|
50
|
+
* screen as available: the fixed layers are inset by those edges, the top / content
|
|
51
|
+
* / bottom detection observes the reduced box, and `thresholdOffsetPercent` reads as
|
|
52
|
+
* a percentage of it. With none of them set, everything behaves exactly as before.
|
|
53
|
+
*
|
|
54
|
+
* The four edges are echoed as `--lm-scrllgngn-viewport-offset-top`, `-right`,
|
|
55
|
+
* `-bottom` and `-left`, carrying the length as authored — pixels for a number, the
|
|
56
|
+
* string untouched otherwise.
|
|
57
|
+
*
|
|
46
58
|
* ### Sticky block elements
|
|
47
59
|
* Each lazy-loaded sticky block receives:
|
|
48
60
|
* - `--active` modifier when the block's page range includes the current page.
|
|
@@ -50,13 +62,48 @@ import cssModule from './styles.module.css';
|
|
|
50
62
|
* - An inline `z-index` derived from the block's position in the sorted stack
|
|
51
63
|
* (overridden by {@link PropsStickyBlock.zIndex} if provided).
|
|
52
64
|
*
|
|
65
|
+
* ### Scroll block elements
|
|
66
|
+
* Each scroll block sits in its own wrapper, whatever it does — a block that isn't
|
|
67
|
+
* tracked still gets one, so the DOM keeps the same shape either way.
|
|
68
|
+
*
|
|
69
|
+
* ### Tracked block elements
|
|
70
|
+
* A block declaring {@link PropsCommonBlock.onScrolled} also carries its
|
|
71
|
+
* {@link TrackedBlockContext} on its wrapper, for scrollytelling driven in CSS
|
|
72
|
+
* alone. Written straight to the element on the frames they change, so they never
|
|
73
|
+
* re-render the sequence.
|
|
74
|
+
*
|
|
75
|
+
* Progressions, as unitless `0`–`1` ratios:
|
|
76
|
+
* - `--lm-scrllgngn-block-current-page-progression-ratio`
|
|
77
|
+
* - `--lm-scrllgngn-block-display-zone-progression-ratio`
|
|
78
|
+
* - `--lm-scrllgngn-block-contiguous-display-zone-progression-ratio`
|
|
79
|
+
*
|
|
80
|
+
* Position, which only moves with the page:
|
|
81
|
+
* - `data-current-page`
|
|
82
|
+
* - `data-display-zone` — comma-separated page positions.
|
|
83
|
+
* - `data-index-of-current-page-in-display-zone`
|
|
84
|
+
* - `data-contiguous-display-zone`
|
|
85
|
+
* - `data-index-of-current-page-in-contiguous-display-zone`
|
|
86
|
+
*
|
|
53
87
|
* @param props - Component properties.
|
|
54
88
|
* @see {@link Props}
|
|
55
|
-
* @returns A div wrapping the full scrollytelling structure:
|
|
56
|
-
* back-blocks layer, front-blocks layer, paginated scrolling
|
|
57
|
-
* bottom-bound sentinel.
|
|
89
|
+
* @returns A div wrapping the full scrollytelling structure: visible zone probe,
|
|
90
|
+
* top-bound sentinel, back-blocks layer, front-blocks layer, paginated scrolling
|
|
91
|
+
* content, and bottom-bound sentinel.
|
|
92
|
+
*
|
|
93
|
+
* @remarks
|
|
94
|
+
* A `viewportOffset*` given as a CSS length is resolved by CSS, not parsed: a hidden
|
|
95
|
+
* probe is inset by the four of them and observed, so `var()`, `clamp()` and
|
|
96
|
+
* breakpoint-dependent values all work, and any restyling that changes them is
|
|
97
|
+
* picked up as a resize. A change that moves the zone without altering its size —
|
|
98
|
+
* a top offset traded for an equal bottom one — is caught instead by a re-read every
|
|
99
|
+
* 100ms, which runs only while the component is on screen and only reaches state
|
|
100
|
+
* when the zone really moved.
|
|
101
|
+
*
|
|
102
|
+
* Tracking costs nothing until a block asks for it: with no `onScrolled` anywhere,
|
|
103
|
+
* the component never joins the shared scroll listener. Once it does, each frame
|
|
104
|
+
* measures only the pages the displayed tracked blocks span, not the whole sequence.
|
|
58
105
|
*/
|
|
59
|
-
export const Scrllgngn = ({ pages, thresholdOffsetPercent, stickyBlocksLazyLoadDistance = 2, forceStickBlocks, onPageChanged, onContentVisibilityChanged, className }) => {
|
|
106
|
+
export const Scrllgngn = ({ pages, thresholdOffsetPercent, viewportOffsetTop, viewportOffsetRight, viewportOffsetBottom, viewportOffsetLeft, stickyBlocksLazyLoadDistance = 2, forceStickBlocks, onPageChanged, onContentVisibilityChanged, className }) => {
|
|
60
107
|
// State
|
|
61
108
|
const [topVisible, setTopVis] = useState(false);
|
|
62
109
|
const [contentVisible, setCntVis] = useState(false);
|
|
@@ -64,12 +111,148 @@ export const Scrllgngn = ({ pages, thresholdOffsetPercent, stickyBlocksLazyLoadD
|
|
|
64
111
|
const [currentPagePos, setCurrentPagePos] = useState(0);
|
|
65
112
|
const [stickyBlocks, setStickyBlocks] = useState(new Map());
|
|
66
113
|
const [partialBoundingRect, setPartialBoundingRect] = useState();
|
|
114
|
+
const [visibleZone, setVisibleZone] = useState();
|
|
115
|
+
// Scroll tracking. Everything the per-frame pass reads lives in a ref: it is
|
|
116
|
+
// registered once with the shared scroll listener, and writes straight to the DOM
|
|
117
|
+
// rather than through state, which would re-render the whole sequence per frame.
|
|
118
|
+
const rootRef = useRef(null);
|
|
119
|
+
const subscriptionIdRef = useRef(randomHash(8));
|
|
120
|
+
const pageElementsRef = useRef([]);
|
|
121
|
+
const trackedBlocksRef = useRef(new Map());
|
|
122
|
+
const trackedWrappersRef = useRef(new Map());
|
|
123
|
+
const lastContextsRef = useRef(new Map());
|
|
124
|
+
const currentPagePosRef = useRef(currentPagePos);
|
|
125
|
+
const visibleZoneRef = useRef(visibleZone);
|
|
126
|
+
const probeRef = useRef(null);
|
|
67
127
|
// Sticky blocks calculations
|
|
68
128
|
useEffect(() => {
|
|
69
129
|
setStickyBlocks(consolidateStickyBlocks(pages));
|
|
70
130
|
}, [pages]);
|
|
71
131
|
const lazyLoadedBackBlocks = lazyLoadedBlocks(stickyBlocks, 'back', currentPagePos, stickyBlocksLazyLoadDistance);
|
|
72
132
|
const lazyLoadedFrontBlocks = lazyLoadedBlocks(stickyBlocks, 'front', currentPagePos, stickyBlocksLazyLoadDistance);
|
|
133
|
+
// Tracked blocks, keyed the same way their wrappers are
|
|
134
|
+
const displayZones = blockDisplayZones(pages);
|
|
135
|
+
const trackedBlocks = new Map();
|
|
136
|
+
for (const [blockId, block] of stickyBlocks) {
|
|
137
|
+
if (block.onScrolled === undefined)
|
|
138
|
+
continue;
|
|
139
|
+
trackedBlocks.set(stickyKey(blockId), {
|
|
140
|
+
displayZone: block.id === undefined
|
|
141
|
+
? block.displayOnPages
|
|
142
|
+
: displayZones.get(block.id) ?? block.displayOnPages,
|
|
143
|
+
onScrolled: block.onScrolled
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
pages?.forEach((page, pagePos) => {
|
|
147
|
+
scrollBlocksOf(page).forEach((block, blockPos) => {
|
|
148
|
+
if (block.onScrolled === undefined)
|
|
149
|
+
return;
|
|
150
|
+
trackedBlocks.set(scrollKey(pagePos, blockPos), {
|
|
151
|
+
displayZone: block.id === undefined
|
|
152
|
+
? [pagePos]
|
|
153
|
+
: displayZones.get(block.id) ?? [pagePos],
|
|
154
|
+
onScrolled: block.onScrolled
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
const hasTrackedBlocks = trackedBlocks.size > 0;
|
|
159
|
+
// Fx. no dep. - Keep what the per-frame pass reads in sync with the last render
|
|
160
|
+
useEffect(() => {
|
|
161
|
+
trackedBlocksRef.current = trackedBlocks;
|
|
162
|
+
currentPagePosRef.current = currentPagePos;
|
|
163
|
+
visibleZoneRef.current = visibleZone;
|
|
164
|
+
});
|
|
165
|
+
// Re-reads the probe, and only disturbs the render if the zone actually moved.
|
|
166
|
+
const readVisibleZone = useCallback(() => {
|
|
167
|
+
const probe = probeRef.current;
|
|
168
|
+
if (probe === null)
|
|
169
|
+
return;
|
|
170
|
+
const zone = toVisibleZoneRect(probe.getBoundingClientRect());
|
|
171
|
+
if (visibleZonesAreEqual(zone, visibleZoneRef.current))
|
|
172
|
+
return;
|
|
173
|
+
setVisibleZone(zone);
|
|
174
|
+
}, []);
|
|
175
|
+
// The per-frame pass: measure only the pages the displayed tracked blocks span,
|
|
176
|
+
// then hand each block its context — once, and only when something moved.
|
|
177
|
+
const handleScrolled = useCallback(() => {
|
|
178
|
+
const trackedBlocks = trackedBlocksRef.current;
|
|
179
|
+
const lastContexts = lastContextsRef.current;
|
|
180
|
+
const currentPage = currentPagePosRef.current;
|
|
181
|
+
const pending = [];
|
|
182
|
+
for (const [key, block] of trackedBlocks) {
|
|
183
|
+
if (block.displayZone.includes(currentPage))
|
|
184
|
+
pending.push([key, block, currentPage]);
|
|
185
|
+
}
|
|
186
|
+
// A block that just left the current page gets one last pass, on the page it was
|
|
187
|
+
// on: its progressions are clamped, so it lands on the edge it crossed instead of
|
|
188
|
+
// freezing part-way.
|
|
189
|
+
for (const [key, lastContext] of lastContexts) {
|
|
190
|
+
const block = trackedBlocks.get(key);
|
|
191
|
+
if (block === undefined) {
|
|
192
|
+
lastContexts.delete(key);
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (block.displayZone.includes(currentPage))
|
|
196
|
+
continue;
|
|
197
|
+
pending.push([key, block, lastContext.currentPage]);
|
|
198
|
+
}
|
|
199
|
+
if (pending.length === 0)
|
|
200
|
+
return;
|
|
201
|
+
const pagesToMeasure = new Set();
|
|
202
|
+
for (const [, block, page] of pending) {
|
|
203
|
+
pagesToMeasure.add(page);
|
|
204
|
+
for (const pagePos of block.displayZone)
|
|
205
|
+
pagesToMeasure.add(pagePos);
|
|
206
|
+
}
|
|
207
|
+
const thresholdY = toThresholdY(visibleZoneRef.current, thresholdOffsetPercent);
|
|
208
|
+
const metrics = measurePages(pageElementsRef.current, pagesToMeasure, thresholdY);
|
|
209
|
+
for (const [key, block, page] of pending) {
|
|
210
|
+
const context = toTrackedBlockContext(block.displayZone, page, metrics);
|
|
211
|
+
const lastContext = lastContexts.get(key);
|
|
212
|
+
if (contextsAreEqual(context, lastContext))
|
|
213
|
+
continue;
|
|
214
|
+
const wrapper = trackedWrappersRef.current.get(key);
|
|
215
|
+
if (wrapper !== undefined) {
|
|
216
|
+
for (const [name, value] of Object.entries(toTrackedBlockCssProps(context))) {
|
|
217
|
+
wrapper.style.setProperty(name, value);
|
|
218
|
+
}
|
|
219
|
+
// Discrete values only move with the page, so they don't need a write per frame
|
|
220
|
+
if (!sameDiscretePart(context, lastContext)) {
|
|
221
|
+
for (const [name, value] of Object.entries(toTrackedBlockDataAttributes(context))) {
|
|
222
|
+
wrapper.setAttribute(name, value);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
block.onScrolled(context);
|
|
227
|
+
if (page === currentPage)
|
|
228
|
+
lastContexts.set(key, context);
|
|
229
|
+
else
|
|
230
|
+
lastContexts.delete(key);
|
|
231
|
+
}
|
|
232
|
+
}, [thresholdOffsetPercent]);
|
|
233
|
+
// Fx. dep. hasTrackedBlocks, handleScrolled - Join the shared scroll listener, and
|
|
234
|
+
// only then: no tracked block, no measurement pass at all.
|
|
235
|
+
useEffect(() => {
|
|
236
|
+
if (!hasTrackedBlocks)
|
|
237
|
+
return;
|
|
238
|
+
const subscriptionId = subscriptionIdRef.current;
|
|
239
|
+
subscribe(subscriptionId, {
|
|
240
|
+
rootRef,
|
|
241
|
+
onScrollStateChange: handleScrolled
|
|
242
|
+
});
|
|
243
|
+
return () => unsubscribe(subscriptionId);
|
|
244
|
+
}, [hasTrackedBlocks, handleScrolled]);
|
|
245
|
+
// Fx. dep. isOnScreen, readVisibleZone - Catch what the probe's ResizeObserver
|
|
246
|
+
// cannot: a restyle that moves the visible zone without resizing it, say a top
|
|
247
|
+
// offset traded for an equal bottom one. Only runs while the component is on
|
|
248
|
+
// screen, and only touches state when the zone actually moved.
|
|
249
|
+
const isOnScreen = topVisible || contentVisible || bottomVisible;
|
|
250
|
+
useEffect(() => {
|
|
251
|
+
if (!isOnScreen)
|
|
252
|
+
return;
|
|
253
|
+
const interval = window.setInterval(readVisibleZone, visibleZonePollInterval);
|
|
254
|
+
return () => window.clearInterval(interval);
|
|
255
|
+
}, [isOnScreen, readVisibleZone]);
|
|
73
256
|
// Handlers
|
|
74
257
|
useChangeDispatch(currentPagePos, pagePos => onPageChanged?.(pagePos, pages?.[pagePos]));
|
|
75
258
|
useChangeDispatch(contentVisible, onContentVisibilityChanged);
|
|
@@ -82,6 +265,21 @@ export const Scrllgngn = ({ pages, thresholdOffsetPercent, stickyBlocksLazyLoadD
|
|
|
82
265
|
return;
|
|
83
266
|
setCurrentPagePos(curPagePos);
|
|
84
267
|
};
|
|
268
|
+
const handlePageElementsChanged = elements => {
|
|
269
|
+
pageElementsRef.current = elements;
|
|
270
|
+
};
|
|
271
|
+
// Registers a block wrapper under the key its context is tracked by. Blocks come
|
|
272
|
+
// and go with lazy loading, so the map is kept clean on unmount rather than grown.
|
|
273
|
+
const registerWrapper = (key) => (element) => {
|
|
274
|
+
if (element === null)
|
|
275
|
+
trackedWrappersRef.current.delete(key);
|
|
276
|
+
else
|
|
277
|
+
trackedWrappersRef.current.set(key, element);
|
|
278
|
+
};
|
|
279
|
+
const handleProbeResized = ({ entry, boundingClientRect }) => {
|
|
280
|
+
probeRef.current = entry.target;
|
|
281
|
+
setVisibleZone(toVisibleZoneRect(boundingClientRect));
|
|
282
|
+
};
|
|
85
283
|
const handleResize = ({ boundingClientRect }) => {
|
|
86
284
|
if (partialBoundingRect === undefined
|
|
87
285
|
|| boundingClientRect.left !== partialBoundingRect.left
|
|
@@ -100,25 +298,31 @@ export const Scrllgngn = ({ pages, thresholdOffsetPercent, stickyBlocksLazyLoadD
|
|
|
100
298
|
'force-stick-blocks-after': forceStickBlocks === 'after',
|
|
101
299
|
'force-stick-blocks-both': forceStickBlocks === 'both'
|
|
102
300
|
}), className);
|
|
103
|
-
const customCssProps =
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
'
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
301
|
+
const customCssProps = {
|
|
302
|
+
...toScreenCssProps(partialBoundingRect, visibleZone),
|
|
303
|
+
...toViewportOffsetCssProps({
|
|
304
|
+
top: viewportOffsetTop,
|
|
305
|
+
right: viewportOffsetRight,
|
|
306
|
+
bottom: viewportOffsetBottom,
|
|
307
|
+
left: viewportOffsetLeft
|
|
308
|
+
})
|
|
309
|
+
};
|
|
310
|
+
const visibleZoneRootMargin = toVisibleZoneRootMargin(visibleZone);
|
|
311
|
+
return _jsxs("div", { ref: rootRef, className: rootClss, "data-current-page-pos": currentPagePos, "data-current-page-id": pages?.[currentPagePos]?.id, style: { ...customCssProps }, children: [_jsx(ResizeObserverComponent, { className: c('viewport-probe'), onResized: handleProbeResized }), _jsxs(ResizeObserverComponent, { onResized: handleResize, children: [_jsx("div", { className: c('top-bound'), children: _jsx(IntersectionObserverComponent, { rootMargin: visibleZoneRootMargin, onIntersected: handleTopBoundDetect }) }), _jsx("div", { className: c('back-blocks'), children: lazyLoadedBackBlocks.map(([blockId, block], blockPos) => {
|
|
312
|
+
const isActive = block.displayOnPages.includes(currentPagePos);
|
|
313
|
+
const blockClss = c('back-block', {
|
|
314
|
+
active: isActive,
|
|
315
|
+
'lazy-loaded': !isActive
|
|
316
|
+
});
|
|
317
|
+
return _jsx("div", { ref: registerWrapper(stickyKey(blockId)), className: blockClss, style: { zIndex: blockPos }, children: block.children }, blockId);
|
|
318
|
+
}) }), _jsx("div", { className: c('front-blocks'), children: lazyLoadedFrontBlocks.map(([blockId, block], blockPos) => {
|
|
319
|
+
const isActive = block.displayOnPages.includes(currentPagePos);
|
|
320
|
+
const blockClss = c('front-block', {
|
|
321
|
+
active: isActive,
|
|
322
|
+
'lazy-loaded': !isActive
|
|
323
|
+
});
|
|
324
|
+
return _jsx("div", { ref: registerWrapper(stickyKey(blockId)), className: blockClss, style: { zIndex: blockPos }, children: block.children }, blockId);
|
|
325
|
+
}) }), _jsx("div", { className: c('scrolling-content'), children: _jsx(IntersectionObserverComponent, { rootMargin: visibleZoneRootMargin, onIntersected: handleCntDetect, children: _jsx(Paginator, { thresholdOffsetPercent: toPaginatorThresholdPercent(visibleZone, thresholdOffsetPercent), onPagesChanged: handlePagesChanged, onPageElementsChanged: handlePageElementsChanged, children: pages?.map((page, pagePos) => {
|
|
326
|
+
return _jsx(_Fragment, { children: scrollBlocksOf(page).map((block, blockPos) => _jsx("div", { ref: registerWrapper(scrollKey(pagePos, blockPos)), className: c('scroll-block'), children: block.children }, blockPos)) });
|
|
327
|
+
}) }) }) }), _jsx("div", { className: c('bottom-bound'), children: _jsx(IntersectionObserverComponent, { rootMargin: visibleZoneRootMargin, onIntersected: handleBtmBoundDetect }) })] })] });
|
|
124
328
|
};
|
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
.root {
|
|
2
2
|
position: relative;
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
The visible zone's height, derived from the offsets rather than measured: CSS
|
|
6
|
+
already knows them, and reading them back from JS would only add a frame of lag.
|
|
7
|
+
*/
|
|
8
|
+
--PRIVATE-viewport-height: calc(
|
|
9
|
+
100vh
|
|
10
|
+
- var(--PRIVATE-viewport-offset-top)
|
|
11
|
+
- var(--PRIVATE-viewport-offset-bottom)
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/*
|
|
16
|
+
Measured, never seen: its box is the visible zone, and observing it is how a CSS
|
|
17
|
+
length written by the consumer — a var, a clamp, a breakpoint — reaches JavaScript.
|
|
18
|
+
*/
|
|
19
|
+
.viewport-probe {
|
|
20
|
+
position: fixed;
|
|
21
|
+
top: var(--PRIVATE-viewport-offset-top);
|
|
22
|
+
right: var(--PRIVATE-viewport-offset-right);
|
|
23
|
+
bottom: var(--PRIVATE-viewport-offset-bottom);
|
|
24
|
+
left: var(--PRIVATE-viewport-offset-left);
|
|
25
|
+
visibility: hidden;
|
|
26
|
+
pointer-events: none;
|
|
27
|
+
z-index: -1;
|
|
3
28
|
}
|
|
4
29
|
|
|
5
30
|
/* MAIN LAYOUTS (back / front blocks, scrolling content) */
|
|
@@ -30,7 +55,7 @@
|
|
|
30
55
|
top: 0;
|
|
31
56
|
left: 0;
|
|
32
57
|
right: 0;
|
|
33
|
-
height:
|
|
58
|
+
height: var(--PRIVATE-viewport-height);
|
|
34
59
|
}
|
|
35
60
|
|
|
36
61
|
/*
|
|
@@ -51,20 +76,20 @@ Force stick blocks option
|
|
|
51
76
|
.root.root--bottom-visible.root--content-visible.root--force-stick-blocks-both .back-blocks,
|
|
52
77
|
.root.root--bottom-visible.root--content-visible.root--force-stick-blocks-both .front-blocks {
|
|
53
78
|
position: fixed;
|
|
54
|
-
top:
|
|
79
|
+
top: var(--PRIVATE-viewport-offset-top);
|
|
55
80
|
left: var(--PRIVATE-left);
|
|
56
81
|
width: var(--PRIVATE-width);
|
|
57
|
-
height:
|
|
82
|
+
height: var(--PRIVATE-viewport-height);
|
|
58
83
|
}
|
|
59
84
|
|
|
60
85
|
/* Scrllgngn leaving the viewport, blocks not fixed but have offset */
|
|
61
86
|
.root.root--bottom-visible.root--content-visible .back-blocks,
|
|
62
87
|
.root.root--bottom-visible.root--content-visible .front-blocks {
|
|
63
88
|
position: absolute;
|
|
64
|
-
top: calc(var(--PRIVATE-height) -
|
|
89
|
+
top: calc(var(--PRIVATE-height) - var(--PRIVATE-viewport-height));
|
|
65
90
|
left: 0;
|
|
66
91
|
right: 0;
|
|
67
|
-
height:
|
|
92
|
+
height: var(--PRIVATE-viewport-height);
|
|
68
93
|
}
|
|
69
94
|
|
|
70
95
|
/* INDIVIDUAL FIXED BLOCKS */
|