@design-edito/tools 0.5.4 → 0.5.6

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 (69) hide show
  1. package/agnostic/colors/index.d.ts +2 -2
  2. package/agnostic/colors/index.js +2 -2
  3. package/agnostic/html/hyper-json/smart-tags/coalesced/index.d.ts +6 -6
  4. package/agnostic/html/hyper-json/smart-tags/coalesced/index.js +6 -6
  5. package/agnostic/html/hyper-json/smart-tags/isolated/index.d.ts +2 -2
  6. package/agnostic/html/hyper-json/smart-tags/isolated/index.js +2 -2
  7. package/agnostic/html/index.d.ts +1 -1
  8. package/agnostic/html/index.js +1 -1
  9. package/agnostic/index.d.ts +2 -2
  10. package/agnostic/index.js +2 -2
  11. package/agnostic/misc/index.d.ts +1 -1
  12. package/agnostic/misc/index.js +1 -1
  13. package/agnostic/misc/logs/index.d.ts +1 -1
  14. package/agnostic/misc/logs/index.js +1 -1
  15. package/agnostic/numbers/index.d.ts +2 -2
  16. package/agnostic/numbers/index.js +2 -2
  17. package/agnostic/objects/index.d.ts +1 -1
  18. package/agnostic/objects/index.js +1 -1
  19. package/agnostic/strings/index.d.ts +2 -2
  20. package/agnostic/strings/index.js +2 -2
  21. package/components/Clippable/index.d.ts +6 -2
  22. package/components/Clippable/index.js +8 -4
  23. package/components/Drawer/index.d.ts +6 -0
  24. package/components/Drawer/index.js +3 -1
  25. package/components/Gallery/index.d.ts +6 -0
  26. package/components/Gallery/index.js +3 -1
  27. package/components/Paginator/index.d.ts +5 -0
  28. package/components/Paginator/index.js +2 -1
  29. package/components/Scrllgngn/index.d.ts +35 -2
  30. package/components/Scrllgngn/index.js +160 -12
  31. package/components/Scrllgngn/utils.d.ts +138 -3
  32. package/components/Scrllgngn/utils.js +197 -4
  33. package/components/ScrollListener/index.d.ts +7 -0
  34. package/components/ScrollListener/index.js +22 -2
  35. package/components/ScrollListener/utils.d.ts +8 -0
  36. package/components/ScrollListener/utils.js +11 -0
  37. package/components/Sequencer/index.d.ts +8 -0
  38. package/components/Sequencer/index.js +19 -2
  39. package/components/Theatre/index.d.ts +16 -6
  40. package/components/Theatre/index.js +15 -8
  41. package/components/index.d.ts +6 -6
  42. package/components/index.js +6 -6
  43. package/index.d.ts +1 -1
  44. package/index.js +1 -1
  45. package/node/@aws-s3/storage/directory/index.d.ts +1 -1
  46. package/node/@aws-s3/storage/directory/index.js +1 -1
  47. package/node/@aws-s3/storage/index.d.ts +1 -1
  48. package/node/@aws-s3/storage/index.js +1 -1
  49. package/node/@google-cloud/storage/directory/index.d.ts +2 -2
  50. package/node/@google-cloud/storage/directory/index.js +2 -2
  51. package/node/@google-cloud/storage/file/index.d.ts +4 -4
  52. package/node/@google-cloud/storage/file/index.js +4 -4
  53. package/node/cloud-storage/operations/index.d.ts +1 -1
  54. package/node/cloud-storage/operations/index.js +1 -1
  55. package/node/ftps/file/index.d.ts +2 -2
  56. package/node/ftps/file/index.js +2 -2
  57. package/node/images/index.d.ts +1 -1
  58. package/node/images/index.js +1 -1
  59. package/node/images/transform/operations/index.d.ts +1 -1
  60. package/node/images/transform/operations/index.js +1 -1
  61. package/node/index.d.ts +1 -1
  62. package/node/index.js +1 -1
  63. package/node/process/index.d.ts +1 -1
  64. package/node/process/index.js +1 -1
  65. package/node/sftp/file/index.d.ts +1 -1
  66. package/node/sftp/file/index.js +1 -1
  67. package/node/sftp/index.d.ts +1 -1
  68. package/node/sftp/index.js +1 -1
  69. package/package.json +1 -1
@@ -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, toScreenCssProps, toTrackedBlockContext, toTrackedBlockCssProps, toTrackedBlockDataAttributes } from './utils.js';
10
12
  import cssModule from './styles.module.css';
11
13
  /**
12
14
  * Scrollytelling engine component. Orchestrates layered sticky blocks (`back`
@@ -50,13 +52,40 @@ import cssModule from './styles.module.css';
50
52
  * - An inline `z-index` derived from the block's position in the sorted stack
51
53
  * (overridden by {@link PropsStickyBlock.zIndex} if provided).
52
54
  *
55
+ * ### Scroll block elements
56
+ * Each scroll block sits in its own wrapper, whatever it does — a block that isn't
57
+ * tracked still gets one, so the DOM keeps the same shape either way.
58
+ *
59
+ * ### Tracked block elements
60
+ * A block declaring {@link PropsCommonBlock.onScrolled} also carries its
61
+ * {@link TrackedBlockContext} on its wrapper, for scrollytelling driven in CSS
62
+ * alone. Written straight to the element on the frames they change, so they never
63
+ * re-render the sequence.
64
+ *
65
+ * Progressions, as unitless `0`–`1` ratios:
66
+ * - `--lm-scrllgngn-block-current-page-progression-ratio`
67
+ * - `--lm-scrllgngn-block-display-zone-progression-ratio`
68
+ * - `--lm-scrllgngn-block-contiguous-display-zone-progression-ratio`
69
+ *
70
+ * Position, which only moves with the page:
71
+ * - `data-current-page`
72
+ * - `data-display-zone` — comma-separated page positions.
73
+ * - `data-index-of-current-page-in-display-zone`
74
+ * - `data-contiguous-display-zone`
75
+ * - `data-index-of-current-page-in-contiguous-display-zone`
76
+ *
53
77
  * @param props - Component properties.
54
78
  * @see {@link Props}
55
79
  * @returns A div wrapping the full scrollytelling structure: top-bound sentinel,
56
80
  * back-blocks layer, front-blocks layer, paginated scrolling content, and
57
81
  * bottom-bound sentinel.
82
+ *
83
+ * @remarks
84
+ * Tracking costs nothing until a block asks for it: with no `onScrolled` anywhere,
85
+ * the component never joins the shared scroll listener. Once it does, each frame
86
+ * measures only the pages the displayed tracked blocks span, not the whole sequence.
58
87
  */
59
- export const Scrllgngn = ({ pages, thresholdOffsetPercent, stickyBlocksLazyLoadDistance = 2, forceStickBlocks, onPageChanged, className }) => {
88
+ export const Scrllgngn = ({ pages, thresholdOffsetPercent, stickyBlocksLazyLoadDistance = 2, forceStickBlocks, onPageChanged, onContentVisibilityChanged, className }) => {
60
89
  // State
61
90
  const [topVisible, setTopVis] = useState(false);
62
91
  const [contentVisible, setCntVis] = useState(false);
@@ -64,14 +93,125 @@ export const Scrllgngn = ({ pages, thresholdOffsetPercent, stickyBlocksLazyLoadD
64
93
  const [currentPagePos, setCurrentPagePos] = useState(0);
65
94
  const [stickyBlocks, setStickyBlocks] = useState(new Map());
66
95
  const [partialBoundingRect, setPartialBoundingRect] = useState();
96
+ // Scroll tracking. Everything the per-frame pass reads lives in a ref: it is
97
+ // registered once with the shared scroll listener, and writes straight to the DOM
98
+ // rather than through state, which would re-render the whole sequence per frame.
99
+ const rootRef = useRef(null);
100
+ const subscriptionIdRef = useRef(randomHash(8));
101
+ const pageElementsRef = useRef([]);
102
+ const trackedBlocksRef = useRef(new Map());
103
+ const trackedWrappersRef = useRef(new Map());
104
+ const lastContextsRef = useRef(new Map());
105
+ const currentPagePosRef = useRef(currentPagePos);
67
106
  // Sticky blocks calculations
68
107
  useEffect(() => {
69
108
  setStickyBlocks(consolidateStickyBlocks(pages));
70
109
  }, [pages]);
71
110
  const lazyLoadedBackBlocks = lazyLoadedBlocks(stickyBlocks, 'back', currentPagePos, stickyBlocksLazyLoadDistance);
72
111
  const lazyLoadedFrontBlocks = lazyLoadedBlocks(stickyBlocks, 'front', currentPagePos, stickyBlocksLazyLoadDistance);
112
+ // Tracked blocks, keyed the same way their wrappers are
113
+ const displayZones = blockDisplayZones(pages);
114
+ const trackedBlocks = new Map();
115
+ for (const [blockId, block] of stickyBlocks) {
116
+ if (block.onScrolled === undefined)
117
+ continue;
118
+ trackedBlocks.set(stickyKey(blockId), {
119
+ displayZone: block.id === undefined
120
+ ? block.displayOnPages
121
+ : displayZones.get(block.id) ?? block.displayOnPages,
122
+ onScrolled: block.onScrolled
123
+ });
124
+ }
125
+ pages?.forEach((page, pagePos) => {
126
+ scrollBlocksOf(page).forEach((block, blockPos) => {
127
+ if (block.onScrolled === undefined)
128
+ return;
129
+ trackedBlocks.set(scrollKey(pagePos, blockPos), {
130
+ displayZone: block.id === undefined
131
+ ? [pagePos]
132
+ : displayZones.get(block.id) ?? [pagePos],
133
+ onScrolled: block.onScrolled
134
+ });
135
+ });
136
+ });
137
+ const hasTrackedBlocks = trackedBlocks.size > 0;
138
+ // Fx. no dep. - Keep what the per-frame pass reads in sync with the last render
139
+ useEffect(() => {
140
+ trackedBlocksRef.current = trackedBlocks;
141
+ currentPagePosRef.current = currentPagePos;
142
+ });
143
+ // The per-frame pass: measure only the pages the displayed tracked blocks span,
144
+ // then hand each block its context — once, and only when something moved.
145
+ const handleScrolled = useCallback(() => {
146
+ const trackedBlocks = trackedBlocksRef.current;
147
+ const lastContexts = lastContextsRef.current;
148
+ const currentPage = currentPagePosRef.current;
149
+ const pending = [];
150
+ for (const [key, block] of trackedBlocks) {
151
+ if (block.displayZone.includes(currentPage))
152
+ pending.push([key, block, currentPage]);
153
+ }
154
+ // A block that just left the current page gets one last pass, on the page it was
155
+ // on: its progressions are clamped, so it lands on the edge it crossed instead of
156
+ // freezing part-way.
157
+ for (const [key, lastContext] of lastContexts) {
158
+ const block = trackedBlocks.get(key);
159
+ if (block === undefined) {
160
+ lastContexts.delete(key);
161
+ continue;
162
+ }
163
+ if (block.displayZone.includes(currentPage))
164
+ continue;
165
+ pending.push([key, block, lastContext.currentPage]);
166
+ }
167
+ if (pending.length === 0)
168
+ return;
169
+ const pagesToMeasure = new Set();
170
+ for (const [, block, page] of pending) {
171
+ pagesToMeasure.add(page);
172
+ for (const pagePos of block.displayZone)
173
+ pagesToMeasure.add(pagePos);
174
+ }
175
+ const metrics = measurePages(pageElementsRef.current, pagesToMeasure, thresholdOffsetPercent);
176
+ for (const [key, block, page] of pending) {
177
+ const context = toTrackedBlockContext(block.displayZone, page, metrics);
178
+ const lastContext = lastContexts.get(key);
179
+ if (contextsAreEqual(context, lastContext))
180
+ continue;
181
+ const wrapper = trackedWrappersRef.current.get(key);
182
+ if (wrapper !== undefined) {
183
+ for (const [name, value] of Object.entries(toTrackedBlockCssProps(context))) {
184
+ wrapper.style.setProperty(name, value);
185
+ }
186
+ // Discrete values only move with the page, so they don't need a write per frame
187
+ if (!sameDiscretePart(context, lastContext)) {
188
+ for (const [name, value] of Object.entries(toTrackedBlockDataAttributes(context))) {
189
+ wrapper.setAttribute(name, value);
190
+ }
191
+ }
192
+ }
193
+ block.onScrolled(context);
194
+ if (page === currentPage)
195
+ lastContexts.set(key, context);
196
+ else
197
+ lastContexts.delete(key);
198
+ }
199
+ }, [thresholdOffsetPercent]);
200
+ // Fx. dep. hasTrackedBlocks, handleScrolled - Join the shared scroll listener, and
201
+ // only then: no tracked block, no measurement pass at all.
202
+ useEffect(() => {
203
+ if (!hasTrackedBlocks)
204
+ return;
205
+ const subscriptionId = subscriptionIdRef.current;
206
+ subscribe(subscriptionId, {
207
+ rootRef,
208
+ onScrollStateChange: handleScrolled
209
+ });
210
+ return () => unsubscribe(subscriptionId);
211
+ }, [hasTrackedBlocks, handleScrolled]);
73
212
  // Handlers
74
213
  useChangeDispatch(currentPagePos, pagePos => onPageChanged?.(pagePos, pages?.[pagePos]));
214
+ useChangeDispatch(contentVisible, onContentVisibilityChanged);
75
215
  const handleTopBoundDetect = e => setTopVis(e.ioEntry?.isIntersecting ?? false);
76
216
  const handleCntDetect = e => setCntVis(e.ioEntry?.isIntersecting ?? false);
77
217
  const handleBtmBoundDetect = e => setBtmVis(e.ioEntry?.isIntersecting ?? false);
@@ -81,6 +221,17 @@ export const Scrllgngn = ({ pages, thresholdOffsetPercent, stickyBlocksLazyLoadD
81
221
  return;
82
222
  setCurrentPagePos(curPagePos);
83
223
  };
224
+ const handlePageElementsChanged = elements => {
225
+ pageElementsRef.current = elements;
226
+ };
227
+ // Registers a block wrapper under the key its context is tracked by. Blocks come
228
+ // and go with lazy loading, so the map is kept clean on unmount rather than grown.
229
+ const registerWrapper = (key) => (element) => {
230
+ if (element === null)
231
+ trackedWrappersRef.current.delete(key);
232
+ else
233
+ trackedWrappersRef.current.set(key, element);
234
+ };
84
235
  const handleResize = ({ boundingClientRect }) => {
85
236
  if (partialBoundingRect === undefined
86
237
  || boundingClientRect.left !== partialBoundingRect.left
@@ -100,24 +251,21 @@ export const Scrllgngn = ({ pages, thresholdOffsetPercent, stickyBlocksLazyLoadD
100
251
  'force-stick-blocks-both': forceStickBlocks === 'both'
101
252
  }), className);
102
253
  const customCssProps = toScreenCssProps(partialBoundingRect);
103
- 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) => {
254
+ return _jsx("div", { ref: rootRef, 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(([blockId, block], blockPos) => {
104
255
  const isActive = block.displayOnPages.includes(currentPagePos);
105
256
  const blockClss = c('back-block', {
106
257
  active: isActive,
107
258
  'lazy-loaded': !isActive
108
259
  });
109
- return _jsx("div", { className: blockClss, style: { zIndex: blockPos }, children: block.children });
110
- }) }), _jsx("div", { className: c('front-blocks'), children: lazyLoadedFrontBlocks.map((block, blockPos) => {
260
+ return _jsx("div", { ref: registerWrapper(stickyKey(blockId)), className: blockClss, style: { zIndex: blockPos }, children: block.children }, blockId);
261
+ }) }), _jsx("div", { className: c('front-blocks'), children: lazyLoadedFrontBlocks.map(([blockId, block], blockPos) => {
111
262
  const isActive = block.displayOnPages.includes(currentPagePos);
112
263
  const blockClss = c('front-block', {
113
264
  active: isActive,
114
265
  'lazy-loaded': !isActive
115
266
  });
116
- return _jsx("div", { className: blockClss, style: { zIndex: blockPos }, children: block.children });
117
- }) }), _jsx("div", { className: c('scrolling-content'), children: _jsx(IntersectionObserverComponent, { onIntersected: handleCntDetect, children: _jsx(Paginator, { thresholdOffsetPercent: thresholdOffsetPercent, onPagesChanged: handlePagesChanged, children: pages?.map(page => {
118
- const scrollBlocks = page.blocks
119
- ?.filter(b => b.depth === 'scroll' || b.depth === undefined) ?? [];
120
- // eslint-disable-next-line @typescript-eslint/promise-function-async
121
- return _jsx(_Fragment, { children: scrollBlocks.map(b => b.children) });
267
+ return _jsx("div", { ref: registerWrapper(stickyKey(blockId)), className: blockClss, style: { zIndex: blockPos }, children: block.children }, blockId);
268
+ }) }), _jsx("div", { className: c('scrolling-content'), children: _jsx(IntersectionObserverComponent, { onIntersected: handleCntDetect, children: _jsx(Paginator, { thresholdOffsetPercent: thresholdOffsetPercent, onPagesChanged: handlePagesChanged, onPageElementsChanged: handlePageElementsChanged, children: pages?.map((page, pagePos) => {
269
+ return _jsx(_Fragment, { children: scrollBlocksOf(page).map((block, blockPos) => _jsx("div", { ref: registerWrapper(scrollKey(pagePos, blockPos)), className: c('scroll-block'), children: block.children }, blockPos)) });
122
270
  }) }) }) }), _jsx("div", { className: c('bottom-bound'), children: _jsx(IntersectionObserverComponent, { onIntersected: handleBtmBoundDetect }) })] }) });
123
271
  };
@@ -1,4 +1,4 @@
1
- import type { PropsPage, PropsStickyBlock } from './index.js';
1
+ import type { PropsBlock, PropsPage, PropsStickyBlock } from './index.js';
2
2
  /** The subset of a bounding rect a {@link Scrllgngn} tracks and exposes. */
3
3
  export type ScreenRect = {
4
4
  left: number;
@@ -33,9 +33,11 @@ export declare function consolidateStickyBlocks(pages?: PropsPage[]): Map<string
33
33
  * @param currentPagePos - Zero-based position of the page in view.
34
34
  * @param lazyLoadDistance - How many pages around the current one still mount
35
35
  * their blocks.
36
- * @returns The blocks to render, stacked by ascending `zIndex`.
36
+ * @returns The blocks to render with the id they were consolidated under, stacked
37
+ * by ascending `zIndex`. The id comes along because it is what identifies the block
38
+ * across renders — its own `id` is optional, a consolidation key never is.
37
39
  */
38
- export declare function lazyLoadedBlocks(blocks: Map<string, ConsolidatedStickyBlock>, depth: 'back' | 'front', currentPagePos: number, lazyLoadDistance: number): ConsolidatedStickyBlock[];
40
+ export declare function lazyLoadedBlocks(blocks: Map<string, ConsolidatedStickyBlock>, depth: 'back' | 'front', currentPagePos: number, lazyLoadDistance: number): Array<[string, ConsolidatedStickyBlock]>;
39
41
  /**
40
42
  * Builds the CSS custom properties exposed on a {@link Scrllgngn} root.
41
43
  *
@@ -50,3 +52,136 @@ export declare function lazyLoadedBlocks(blocks: Map<string, ConsolidatedStickyB
50
52
  * @returns The custom properties, keyed by their full name.
51
53
  */
52
54
  export declare function toScreenCssProps(rect?: ScreenRect): Record<string, string>;
55
+ /**
56
+ * Where one tracked block stands in the scroll, as handed to its `onScrolled`.
57
+ *
58
+ * Every progression is a `0`–`1` ratio measured against the threshold line, and
59
+ * weighted **by pixel height**: a zone of unequal pages is not the same as
60
+ * `(indexOfCurrentPage + currentPageProgression) / displayZone.length`.
61
+ *
62
+ * @property currentPage - Zero-based position of the page in view.
63
+ * @property currentPageProgression - `0` when the current page's top meets the
64
+ * threshold, `1` when its bottom does.
65
+ * @property displayZone - Every page the block's `id` appears on. A block without
66
+ * an `id` shows on one page, so its zone holds that page alone and its three
67
+ * progressions are equal.
68
+ * @property indexOfCurrentPageInDisplayZone - Where `currentPage` sits in
69
+ * `displayZone`.
70
+ * @property displayZoneProgression - Progression across the whole zone. On a zone
71
+ * like `[2, 3, 5]` it runs 0 → ~0.66 over pages 2–3, pauses while page 4 shows the
72
+ * block nowhere, then resumes to 1 on page 5.
73
+ * @property contiguousDisplayZone - The unbroken run of `displayZone` containing
74
+ * `currentPage` — `[2, 3]` for the zone above while on page 2 or 3.
75
+ * @property indexOfCurrentPageInContiguousDisplayZone - Where `currentPage` sits in
76
+ * `contiguousDisplayZone`.
77
+ * @property contiguousDisplayZoneProgression - Progression across that run alone,
78
+ * so it always spans a full 0 → 1.
79
+ */
80
+ export type TrackedBlockContext = {
81
+ currentPage: number;
82
+ currentPageProgression: number;
83
+ displayZone: number[];
84
+ indexOfCurrentPageInDisplayZone: number;
85
+ displayZoneProgression: number;
86
+ contiguousDisplayZone: number[];
87
+ indexOfCurrentPageInContiguousDisplayZone: number;
88
+ contiguousDisplayZoneProgression: number;
89
+ };
90
+ /** How far the threshold line has travelled through one page, in pixels. */
91
+ export type PageScrollMetrics = {
92
+ scrolled: number;
93
+ height: number;
94
+ };
95
+ /**
96
+ * Lists the pages each block id shows on.
97
+ *
98
+ * @param pages - The pages to walk, in order.
99
+ * @returns Zero-based page positions per id, ordered. Blocks without an `id` are
100
+ * left out: they show on their own page and nowhere else.
101
+ */
102
+ export declare function blockDisplayZones(pages?: PropsPage[]): Map<string, number[]>;
103
+ /**
104
+ * Isolates the unbroken run of pages around one page.
105
+ *
106
+ * @param zone - The pages a block shows on, in any order.
107
+ * @param page - The page the run must contain.
108
+ * @returns The run holding `page`, ascending, or an empty array when `zone` misses
109
+ * it entirely.
110
+ */
111
+ export declare function contiguousRunContaining(zone: number[], page: number): number[];
112
+ /**
113
+ * Measures how far the threshold line has travelled through each of the pages
114
+ * asked for.
115
+ *
116
+ * Only those pages are measured, not all of them: a long sequence would otherwise
117
+ * pay for a forced layout per page on every frame, where the tracked blocks rarely
118
+ * span more than a handful.
119
+ *
120
+ * @param pageElements - Every page slot, indexed by position.
121
+ * @param pagePositions - The positions worth measuring.
122
+ * @param thresholdOffsetPercent - The same offset the internal `Paginator` uses,
123
+ * as a percentage of the viewport height.
124
+ * @returns The metrics of each measurable page, keyed by position.
125
+ */
126
+ export declare function measurePages(pageElements: HTMLElement[], pagePositions: Iterable<number>, thresholdOffsetPercent?: number): Map<number, PageScrollMetrics>;
127
+ /**
128
+ * Assembles what one tracked block is told about the scroll.
129
+ *
130
+ * @param displayZone - The pages the block shows on.
131
+ * @param currentPage - Zero-based position of the page in view.
132
+ * @param metrics - Measurements covering at least `displayZone`.
133
+ * @returns The context to hand to `onScrolled`.
134
+ */
135
+ export declare function toTrackedBlockContext(displayZone: number[], currentPage: number, metrics: Map<number, PageScrollMetrics>): TrackedBlockContext;
136
+ /** Whether two contexts hold the same discrete fields, ignoring the progressions. */
137
+ export declare function sameDiscretePart(a: TrackedBlockContext, b?: TrackedBlockContext): boolean;
138
+ /** Whether two contexts are equal down to the progressions. */
139
+ export declare function contextsAreEqual(a: TrackedBlockContext, b?: TrackedBlockContext): boolean;
140
+ /**
141
+ * Builds the CSS custom properties carrying a tracked block's progressions.
142
+ *
143
+ * Only the continuous fields land here — the discrete ones are `data-` attributes,
144
+ * written by {@link toTrackedBlockDataAttributes}.
145
+ *
146
+ * @param context - The block's current context.
147
+ * @returns The custom properties, keyed by their full name.
148
+ */
149
+ export declare function toTrackedBlockCssProps(context: TrackedBlockContext): Record<string, string>;
150
+ /**
151
+ * Builds the `data-` attributes carrying a tracked block's discrete position.
152
+ *
153
+ * @param context - The block's current context.
154
+ * @returns The attributes, keyed by their full name.
155
+ */
156
+ export declare function toTrackedBlockDataAttributes(context: TrackedBlockContext): Record<string, string>;
157
+ /** What the per-frame pass needs to know about one tracked block. */
158
+ export type TrackedBlock = {
159
+ displayZone: number[];
160
+ onScrolled: (context: TrackedBlockContext) => void;
161
+ };
162
+ /**
163
+ * Keys a tracked sticky block.
164
+ *
165
+ * Sticky and scroll blocks share one map of wrappers, and a sticky block is one
166
+ * element for the whole sequence where a scroll block is one per page — hence two
167
+ * key shapes rather than a single ambiguous one.
168
+ *
169
+ * @param blockId - The id the block was consolidated under.
170
+ * @returns The key its wrapper is registered as.
171
+ */
172
+ export declare function stickyKey(blockId: string): string;
173
+ /**
174
+ * Keys a tracked scroll block.
175
+ *
176
+ * @param pagePos - Zero-based position of the page it belongs to.
177
+ * @param blockPos - Its position among that page's scroll blocks.
178
+ * @returns The key its wrapper is registered as.
179
+ */
180
+ export declare function scrollKey(pagePos: number, blockPos: number): string;
181
+ /**
182
+ * Picks the blocks of a page that scroll with the content.
183
+ *
184
+ * @param page - The page to read.
185
+ * @returns Its scroll blocks, in order. `depth` left out means `'scroll'`.
186
+ */
187
+ export declare function scrollBlocksOf(page: PropsPage): PropsBlock[];
@@ -1,3 +1,4 @@
1
+ import { clamp } from '../../agnostic/numbers/clamp/index.js';
1
2
  import { randomHash } from '../../agnostic/random/uuid/index.js';
2
3
  /**
3
4
  * Merges the sticky blocks of every page into a single entry per block id.
@@ -50,18 +51,19 @@ export function consolidateStickyBlocks(pages) {
50
51
  * @param currentPagePos - Zero-based position of the page in view.
51
52
  * @param lazyLoadDistance - How many pages around the current one still mount
52
53
  * their blocks.
53
- * @returns The blocks to render, stacked by ascending `zIndex`.
54
+ * @returns The blocks to render with the id they were consolidated under, stacked
55
+ * by ascending `zIndex`. The id comes along because it is what identifies the block
56
+ * across renders — its own `id` is optional, a consolidation key never is.
54
57
  */
55
58
  export function lazyLoadedBlocks(blocks, depth, currentPagePos, lazyLoadDistance) {
56
59
  return Array
57
60
  .from(blocks)
58
- .map(([, block]) => block)
59
- .filter(block => block.depth === depth
61
+ .filter(([, block]) => block.depth === depth
60
62
  && block.displayOnPages.some(dispPage => {
61
63
  const absDiff = Math.abs(dispPage - currentPagePos);
62
64
  return absDiff <= lazyLoadDistance;
63
65
  }))
64
- .sort((a, b) => (a.zIndex ?? -Infinity) - (b.zIndex ?? -Infinity));
66
+ .sort(([, a], [, b]) => (a.zIndex ?? -Infinity) - (b.zIndex ?? -Infinity));
65
67
  }
66
68
  /**
67
69
  * Builds the CSS custom properties exposed on a {@link Scrllgngn} root.
@@ -100,3 +102,194 @@ export function toScreenCssProps(rect) {
100
102
  function toPrivateLength(value) {
101
103
  return value === undefined ? 'initial' : `${value}px`;
102
104
  }
105
+ /**
106
+ * Lists the pages each block id shows on.
107
+ *
108
+ * @param pages - The pages to walk, in order.
109
+ * @returns Zero-based page positions per id, ordered. Blocks without an `id` are
110
+ * left out: they show on their own page and nowhere else.
111
+ */
112
+ export function blockDisplayZones(pages) {
113
+ const zones = new Map();
114
+ (pages ?? []).forEach((page, pagePos) => {
115
+ for (const block of page.blocks ?? []) {
116
+ if (block.id === undefined)
117
+ continue;
118
+ const zone = zones.get(block.id) ?? [];
119
+ if (!zone.includes(pagePos))
120
+ zone.push(pagePos);
121
+ zones.set(block.id, zone);
122
+ }
123
+ });
124
+ return zones;
125
+ }
126
+ /**
127
+ * Isolates the unbroken run of pages around one page.
128
+ *
129
+ * @param zone - The pages a block shows on, in any order.
130
+ * @param page - The page the run must contain.
131
+ * @returns The run holding `page`, ascending, or an empty array when `zone` misses
132
+ * it entirely.
133
+ */
134
+ export function contiguousRunContaining(zone, page) {
135
+ const ascending = [...zone].sort((a, b) => a - b);
136
+ let run = [];
137
+ for (const pagePos of ascending) {
138
+ const previous = run[run.length - 1];
139
+ if (previous !== undefined && pagePos !== previous + 1) {
140
+ if (run.includes(page))
141
+ return run;
142
+ run = [];
143
+ }
144
+ run.push(pagePos);
145
+ }
146
+ return run.includes(page) ? run : [];
147
+ }
148
+ /**
149
+ * Measures how far the threshold line has travelled through each of the pages
150
+ * asked for.
151
+ *
152
+ * Only those pages are measured, not all of them: a long sequence would otherwise
153
+ * pay for a forced layout per page on every frame, where the tracked blocks rarely
154
+ * span more than a handful.
155
+ *
156
+ * @param pageElements - Every page slot, indexed by position.
157
+ * @param pagePositions - The positions worth measuring.
158
+ * @param thresholdOffsetPercent - The same offset the internal `Paginator` uses,
159
+ * as a percentage of the viewport height.
160
+ * @returns The metrics of each measurable page, keyed by position.
161
+ */
162
+ export function measurePages(pageElements, pagePositions, thresholdOffsetPercent) {
163
+ const thresholdY = window.innerHeight * (thresholdOffsetPercent ?? 0) / 100;
164
+ const measured = new Map();
165
+ for (const pagePos of pagePositions) {
166
+ const element = pageElements[pagePos];
167
+ if (element === undefined)
168
+ continue;
169
+ const { top, height } = element.getBoundingClientRect();
170
+ measured.set(pagePos, {
171
+ scrolled: clamp(thresholdY - top, 0, height),
172
+ height
173
+ });
174
+ }
175
+ return measured;
176
+ }
177
+ /** Pixel-weighted progression across a set of pages, `0` when none is measured. */
178
+ function zoneProgression(zone, metrics) {
179
+ let scrolled = 0;
180
+ let height = 0;
181
+ for (const pagePos of zone) {
182
+ const pageMetrics = metrics.get(pagePos);
183
+ if (pageMetrics === undefined)
184
+ continue;
185
+ scrolled += pageMetrics.scrolled;
186
+ height += pageMetrics.height;
187
+ }
188
+ return height === 0 ? 0 : scrolled / height;
189
+ }
190
+ /**
191
+ * Assembles what one tracked block is told about the scroll.
192
+ *
193
+ * @param displayZone - The pages the block shows on.
194
+ * @param currentPage - Zero-based position of the page in view.
195
+ * @param metrics - Measurements covering at least `displayZone`.
196
+ * @returns The context to hand to `onScrolled`.
197
+ */
198
+ export function toTrackedBlockContext(displayZone, currentPage, metrics) {
199
+ const contiguousDisplayZone = contiguousRunContaining(displayZone, currentPage);
200
+ return {
201
+ currentPage,
202
+ currentPageProgression: zoneProgression([currentPage], metrics),
203
+ displayZone,
204
+ indexOfCurrentPageInDisplayZone: displayZone.indexOf(currentPage),
205
+ displayZoneProgression: zoneProgression(displayZone, metrics),
206
+ contiguousDisplayZone,
207
+ indexOfCurrentPageInContiguousDisplayZone: contiguousDisplayZone.indexOf(currentPage),
208
+ contiguousDisplayZoneProgression: zoneProgression(contiguousDisplayZone, metrics)
209
+ };
210
+ }
211
+ /** The fields that only move when the page does, so they can be written sparingly. */
212
+ function discretePart(context) {
213
+ return [
214
+ context.currentPage,
215
+ context.indexOfCurrentPageInDisplayZone,
216
+ context.indexOfCurrentPageInContiguousDisplayZone,
217
+ context.displayZone.join(','),
218
+ context.contiguousDisplayZone.join(',')
219
+ ].join('|');
220
+ }
221
+ /** Whether two contexts hold the same discrete fields, ignoring the progressions. */
222
+ export function sameDiscretePart(a, b) {
223
+ return b !== undefined && discretePart(a) === discretePart(b);
224
+ }
225
+ /** Whether two contexts are equal down to the progressions. */
226
+ export function contextsAreEqual(a, b) {
227
+ return sameDiscretePart(a, b)
228
+ && b !== undefined
229
+ && a.currentPageProgression === b.currentPageProgression
230
+ && a.displayZoneProgression === b.displayZoneProgression
231
+ && a.contiguousDisplayZoneProgression === b.contiguousDisplayZoneProgression;
232
+ }
233
+ /**
234
+ * Builds the CSS custom properties carrying a tracked block's progressions.
235
+ *
236
+ * Only the continuous fields land here — the discrete ones are `data-` attributes,
237
+ * written by {@link toTrackedBlockDataAttributes}.
238
+ *
239
+ * @param context - The block's current context.
240
+ * @returns The custom properties, keyed by their full name.
241
+ */
242
+ export function toTrackedBlockCssProps(context) {
243
+ return {
244
+ '--lm-scrllgngn-block-current-page-progression-ratio': `${context.currentPageProgression}`,
245
+ '--lm-scrllgngn-block-display-zone-progression-ratio': `${context.displayZoneProgression}`,
246
+ '--lm-scrllgngn-block-contiguous-display-zone-progression-ratio': `${context.contiguousDisplayZoneProgression}`
247
+ };
248
+ }
249
+ /**
250
+ * Builds the `data-` attributes carrying a tracked block's discrete position.
251
+ *
252
+ * @param context - The block's current context.
253
+ * @returns The attributes, keyed by their full name.
254
+ */
255
+ export function toTrackedBlockDataAttributes(context) {
256
+ return {
257
+ 'data-current-page': `${context.currentPage}`,
258
+ 'data-display-zone': context.displayZone.join(','),
259
+ 'data-index-of-current-page-in-display-zone': `${context.indexOfCurrentPageInDisplayZone}`,
260
+ 'data-contiguous-display-zone': context.contiguousDisplayZone.join(','),
261
+ 'data-index-of-current-page-in-contiguous-display-zone': `${context.indexOfCurrentPageInContiguousDisplayZone}`
262
+ };
263
+ }
264
+ /**
265
+ * Keys a tracked sticky block.
266
+ *
267
+ * Sticky and scroll blocks share one map of wrappers, and a sticky block is one
268
+ * element for the whole sequence where a scroll block is one per page — hence two
269
+ * key shapes rather than a single ambiguous one.
270
+ *
271
+ * @param blockId - The id the block was consolidated under.
272
+ * @returns The key its wrapper is registered as.
273
+ */
274
+ export function stickyKey(blockId) {
275
+ return `sticky:${blockId}`;
276
+ }
277
+ /**
278
+ * Keys a tracked scroll block.
279
+ *
280
+ * @param pagePos - Zero-based position of the page it belongs to.
281
+ * @param blockPos - Its position among that page's scroll blocks.
282
+ * @returns The key its wrapper is registered as.
283
+ */
284
+ export function scrollKey(pagePos, blockPos) {
285
+ return `scroll:${pagePos}:${blockPos}`;
286
+ }
287
+ /**
288
+ * Picks the blocks of a page that scroll with the content.
289
+ *
290
+ * @param page - The page to read.
291
+ * @returns Its scroll blocks, in order. `depth` left out means `'scroll'`.
292
+ */
293
+ export function scrollBlocksOf(page) {
294
+ return page.blocks?.filter(block => block.depth === 'scroll' || block.depth === undefined) ?? [];
295
+ }
@@ -12,6 +12,11 @@ import { type ScrollState } from './utils.js';
12
12
  * changed. Receives `undefined` until the first measurement lands.
13
13
  * @property onVisibilityChanged - Called on every intersection change, with
14
14
  * `true` when the component intersects the viewport.
15
+ * @property onScrollProgressChanged - Called after the element's vertical outer
16
+ * scroll progress changed: `0` when it is about to enter the viewport, `1` once
17
+ * it has fully left it. Never on mount.
18
+ * @property onScrollDirectionChanged - Called after the document scroll
19
+ * direction changed, with `'up'` or `'down'`. Never on mount.
15
20
  * @property className - Optional additional class name(s) applied to the root element.
16
21
  * @property children - React nodes rendered inside the scroll listener container.
17
22
  */
@@ -20,6 +25,8 @@ export type Props = PropsWithChildren<WithClassName<{
20
25
  stopOnHidden?: boolean;
21
26
  onScrollStateChanged?: (scrollState?: ScrollState) => void;
22
27
  onVisibilityChanged?: (isVisible: boolean) => void;
28
+ onScrollProgressChanged?: (progress: number) => void;
29
+ onScrollDirectionChanged?: (direction: 'up' | 'down') => void;
23
30
  }>>;
24
31
  /**
25
32
  * Exposes scroll metrics — both the document's and its own — as CSS custom