@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.
Files changed (67) hide show
  1. package/agnostic/css/index.d.ts +1 -1
  2. package/agnostic/css/index.js +1 -1
  3. package/agnostic/errors/index.d.ts +1 -1
  4. package/agnostic/errors/index.js +1 -1
  5. package/agnostic/html/hyper-json/smart-tags/coalesced/index.d.ts +8 -8
  6. package/agnostic/html/hyper-json/smart-tags/coalesced/index.js +8 -8
  7. package/agnostic/html/hyper-json/smart-tags/isolated/index.d.ts +2 -2
  8. package/agnostic/html/hyper-json/smart-tags/isolated/index.js +2 -2
  9. package/agnostic/index.d.ts +1 -0
  10. package/agnostic/index.js +1 -0
  11. package/agnostic/misc/index.d.ts +1 -1
  12. package/agnostic/misc/index.js +1 -1
  13. package/agnostic/objects/index.d.ts +2 -2
  14. package/agnostic/objects/index.js +2 -2
  15. package/agnostic/random/index.d.ts +1 -1
  16. package/agnostic/random/index.js +1 -1
  17. package/agnostic/strings/index.d.ts +1 -1
  18. package/agnostic/strings/index.js +1 -1
  19. package/agnostic/subtitles/index.d.ts +2 -0
  20. package/agnostic/subtitles/index.js +2 -0
  21. package/agnostic/subtitles/parse-srt/index.d.ts +17 -0
  22. package/agnostic/subtitles/parse-srt/index.js +67 -0
  23. package/agnostic/subtitles/parse-srt/index.test.d.ts +1 -0
  24. package/agnostic/subtitles/parse-srt/index.test.js +94 -0
  25. package/agnostic/subtitles/parse-srt/types.d.ts +22 -0
  26. package/agnostic/subtitles/parse-srt/types.js +1 -0
  27. package/agnostic/subtitles/srt-timecode-to-ms/index.d.ts +14 -0
  28. package/agnostic/subtitles/srt-timecode-to-ms/index.js +36 -0
  29. package/agnostic/subtitles/srt-timecode-to-ms/index.test.d.ts +1 -0
  30. package/agnostic/subtitles/srt-timecode-to-ms/index.test.js +44 -0
  31. package/agnostic/time/dates/index.d.ts +1 -1
  32. package/agnostic/time/dates/index.js +1 -1
  33. package/components/ListLoader/index.controlled.d.ts +8 -3
  34. package/components/ListLoader/index.controlled.js +2 -2
  35. package/components/ListLoader/index.d.ts +1 -1
  36. package/components/ListLoader/index.js +2 -2
  37. package/components/Paginator/index.d.ts +5 -0
  38. package/components/Paginator/index.js +2 -1
  39. package/components/Scrllgngn/index.d.ts +68 -8
  40. package/components/Scrllgngn/index.js +231 -27
  41. package/components/Scrllgngn/styles.module.css +30 -5
  42. package/components/Scrllgngn/utils.d.ts +222 -4
  43. package/components/Scrllgngn/utils.js +335 -8
  44. package/components/ScrollListener/index.d.ts +7 -3
  45. package/components/ScrollListener/index.js +2 -2
  46. package/components/Sequencer/index.d.ts +10 -1
  47. package/components/Sequencer/index.js +7 -3
  48. package/components/Subtitles/index.d.ts +53 -62
  49. package/components/Subtitles/index.js +90 -158
  50. package/components/Subtitles/utils.d.ts +60 -0
  51. package/components/Subtitles/utils.js +76 -0
  52. package/components/Video/index.controlled.d.ts +5 -0
  53. package/components/Video/index.controlled.js +22 -3
  54. package/components/Video/index.d.ts +47 -9
  55. package/components/Video/index.js +71 -20
  56. package/components/Video/utils.d.ts +14 -0
  57. package/components/Video/utils.js +18 -0
  58. package/components/utils/types.d.ts +32 -0
  59. package/index.d.ts +1 -1
  60. package/index.js +1 -1
  61. package/node/@google-cloud/storage/file/index.d.ts +1 -1
  62. package/node/@google-cloud/storage/file/index.js +1 -1
  63. package/node/files/index.d.ts +1 -1
  64. package/node/files/index.js +1 -1
  65. package/node/images/transform/operations/index.d.ts +1 -1
  66. package/node/images/transform/operations/index.js +1 -1
  67. 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 trackScroll - Whether scroll tracking is enabled for this block.
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
- trackScroll?: boolean;
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 - Forwarded to the internal
63
- * {@link Paginator}. Defines the viewport offset percentage used to determine
64
- * which page is considered current.
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: top-bound sentinel,
135
- * back-blocks layer, front-blocks layer, paginated scrolling content, and
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 { useState, useEffect } from 'react';
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: top-bound sentinel,
56
- * back-blocks layer, front-blocks layer, paginated scrolling content, and
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 = toScreenCssProps(partialBoundingRect);
104
- return _jsx("div", { className: rootClss, "data-current-page-pos": currentPagePos, "data-current-page-id": pages?.[currentPagePos]?.id, style: { ...customCssProps }, children: _jsxs(ResizeObserverComponent, { onResized: handleResize, children: [_jsx("div", { className: c('top-bound'), children: _jsx(IntersectionObserverComponent, { onIntersected: handleTopBoundDetect }) }), _jsx("div", { className: c('back-blocks'), children: lazyLoadedBackBlocks.map((block, blockPos) => {
105
- const isActive = block.displayOnPages.includes(currentPagePos);
106
- const blockClss = c('back-block', {
107
- active: isActive,
108
- 'lazy-loaded': !isActive
109
- });
110
- return _jsx("div", { className: blockClss, style: { zIndex: blockPos }, children: block.children });
111
- }) }), _jsx("div", { className: c('front-blocks'), children: lazyLoadedFrontBlocks.map((block, blockPos) => {
112
- const isActive = block.displayOnPages.includes(currentPagePos);
113
- const blockClss = c('front-block', {
114
- active: isActive,
115
- 'lazy-loaded': !isActive
116
- });
117
- return _jsx("div", { className: blockClss, style: { zIndex: blockPos }, children: block.children });
118
- }) }), _jsx("div", { className: c('scrolling-content'), children: _jsx(IntersectionObserverComponent, { onIntersected: handleCntDetect, children: _jsx(Paginator, { thresholdOffsetPercent: thresholdOffsetPercent, onPagesChanged: handlePagesChanged, children: pages?.map(page => {
119
- const scrollBlocks = page.blocks
120
- ?.filter(b => b.depth === 'scroll' || b.depth === undefined) ?? [];
121
- // eslint-disable-next-line @typescript-eslint/promise-function-async
122
- return _jsx(_Fragment, { children: scrollBlocks.map(b => b.children) });
123
- }) }) }) }), _jsx("div", { className: c('bottom-bound'), children: _jsx(IntersectionObserverComponent, { onIntersected: handleBtmBoundDetect }) })] }) });
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: 100vh;
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: 0;
79
+ top: var(--PRIVATE-viewport-offset-top);
55
80
  left: var(--PRIVATE-left);
56
81
  width: var(--PRIVATE-width);
57
- height: 100vh;
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) - 100vh);
89
+ top: calc(var(--PRIVATE-height) - var(--PRIVATE-viewport-height));
65
90
  left: 0;
66
91
  right: 0;
67
- height: 100vh;
92
+ height: var(--PRIVATE-viewport-height);
68
93
  }
69
94
 
70
95
  /* INDIVIDUAL FIXED BLOCKS */