@design-edito/tools 0.5.6 → 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 (79) hide show
  1. package/agnostic/colors/index.d.ts +2 -2
  2. package/agnostic/colors/index.js +2 -2
  3. package/agnostic/css/index.d.ts +1 -1
  4. package/agnostic/css/index.js +1 -1
  5. package/agnostic/errors/index.d.ts +1 -1
  6. package/agnostic/errors/index.js +1 -1
  7. package/agnostic/html/hyper-json/smart-tags/coalesced/index.d.ts +8 -8
  8. package/agnostic/html/hyper-json/smart-tags/coalesced/index.js +8 -8
  9. package/agnostic/html/hyper-json/smart-tags/isolated/index.d.ts +4 -4
  10. package/agnostic/html/hyper-json/smart-tags/isolated/index.js +4 -4
  11. package/agnostic/html/index.d.ts +1 -1
  12. package/agnostic/html/index.js +1 -1
  13. package/agnostic/index.d.ts +1 -0
  14. package/agnostic/index.js +1 -0
  15. package/agnostic/misc/index.d.ts +2 -2
  16. package/agnostic/misc/index.js +2 -2
  17. package/agnostic/numbers/index.d.ts +1 -1
  18. package/agnostic/numbers/index.js +1 -1
  19. package/agnostic/objects/index.d.ts +3 -3
  20. package/agnostic/objects/index.js +3 -3
  21. package/agnostic/random/index.d.ts +1 -1
  22. package/agnostic/random/index.js +1 -1
  23. package/agnostic/strings/index.d.ts +1 -1
  24. package/agnostic/strings/index.js +1 -1
  25. package/agnostic/subtitles/index.d.ts +2 -0
  26. package/agnostic/subtitles/index.js +2 -0
  27. package/agnostic/subtitles/parse-srt/index.d.ts +17 -0
  28. package/agnostic/subtitles/parse-srt/index.js +67 -0
  29. package/agnostic/subtitles/parse-srt/index.test.d.ts +1 -0
  30. package/agnostic/subtitles/parse-srt/index.test.js +94 -0
  31. package/agnostic/subtitles/parse-srt/types.d.ts +22 -0
  32. package/agnostic/subtitles/parse-srt/types.js +1 -0
  33. package/agnostic/subtitles/srt-timecode-to-ms/index.d.ts +14 -0
  34. package/agnostic/subtitles/srt-timecode-to-ms/index.js +36 -0
  35. package/agnostic/subtitles/srt-timecode-to-ms/index.test.d.ts +1 -0
  36. package/agnostic/subtitles/srt-timecode-to-ms/index.test.js +44 -0
  37. package/agnostic/time/dates/index.d.ts +1 -1
  38. package/agnostic/time/dates/index.js +1 -1
  39. package/components/ListLoader/index.controlled.d.ts +8 -3
  40. package/components/ListLoader/index.controlled.js +2 -2
  41. package/components/ListLoader/index.d.ts +1 -1
  42. package/components/ListLoader/index.js +2 -2
  43. package/components/Scrllgngn/index.d.ts +37 -7
  44. package/components/Scrllgngn/index.js +81 -24
  45. package/components/Scrllgngn/styles.module.css +30 -5
  46. package/components/Scrllgngn/utils.d.ts +87 -4
  47. package/components/Scrllgngn/utils.js +142 -8
  48. package/components/ScrollListener/index.d.ts +7 -3
  49. package/components/ScrollListener/index.js +2 -2
  50. package/components/Sequencer/index.d.ts +10 -1
  51. package/components/Sequencer/index.js +7 -3
  52. package/components/Subtitles/index.d.ts +53 -62
  53. package/components/Subtitles/index.js +90 -158
  54. package/components/Subtitles/utils.d.ts +60 -0
  55. package/components/Subtitles/utils.js +76 -0
  56. package/components/Video/index.controlled.d.ts +5 -0
  57. package/components/Video/index.controlled.js +22 -3
  58. package/components/Video/index.d.ts +47 -9
  59. package/components/Video/index.js +71 -20
  60. package/components/Video/utils.d.ts +14 -0
  61. package/components/Video/utils.js +18 -0
  62. package/components/utils/types.d.ts +32 -0
  63. package/index.d.ts +1 -1
  64. package/index.js +1 -1
  65. package/node/@aws-s3/storage/directory/index.d.ts +1 -1
  66. package/node/@aws-s3/storage/directory/index.js +1 -1
  67. package/node/@google-cloud/storage/file/index.d.ts +2 -2
  68. package/node/@google-cloud/storage/file/index.js +2 -2
  69. package/node/cloud-storage/operations/index.d.ts +1 -1
  70. package/node/cloud-storage/operations/index.js +1 -1
  71. package/node/files/index.d.ts +1 -1
  72. package/node/files/index.js +1 -1
  73. package/node/images/index.d.ts +1 -1
  74. package/node/images/index.js +1 -1
  75. package/node/index.d.ts +1 -1
  76. package/node/index.js +1 -1
  77. package/node/sftp/file/index.d.ts +1 -1
  78. package/node/sftp/file/index.js +1 -1
  79. package/package.json +26 -1
@@ -0,0 +1,44 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { srtTimecodeToMs } from './index.js';
3
+ describe('srtTimecodeToMs', () => {
4
+ it('adds up hours, minutes, seconds and milliseconds', () => {
5
+ expect(srtTimecodeToMs('01:02:03,004')).toBe(3723004);
6
+ });
7
+ it('sits at zero for the start of a file', () => {
8
+ expect(srtTimecodeToMs('00:00:00,000')).toBe(0);
9
+ });
10
+ describe('the fractional field', () => {
11
+ it('reads a single digit as tenths of a second', () => {
12
+ expect(srtTimecodeToMs('00:00:01,5')).toBe(1500);
13
+ });
14
+ it('reads two digits as hundredths', () => {
15
+ expect(srtTimecodeToMs('00:00:01,05')).toBe(1050);
16
+ });
17
+ it('reads three digits as milliseconds', () => {
18
+ expect(srtTimecodeToMs('00:00:01,005')).toBe(1005);
19
+ });
20
+ it('drops precision the format does not carry', () => {
21
+ expect(srtTimecodeToMs('00:00:01,5009')).toBe(1500);
22
+ });
23
+ });
24
+ it('tolerates surrounding whitespace, as found around an arrow', () => {
25
+ expect(srtTimecodeToMs(' 00:00:02,000 ')).toBe(2000);
26
+ });
27
+ it('accepts hour counts beyond two digits', () => {
28
+ expect(srtTimecodeToMs('100:00:00,000')).toBe(360000000);
29
+ });
30
+ describe('refusing what is not a timecode', () => {
31
+ it('returns null on a dot separator, which SRT does not use', () => {
32
+ expect(srtTimecodeToMs('00:00:01.500')).toBeNull();
33
+ });
34
+ it('returns null on a missing field', () => {
35
+ expect(srtTimecodeToMs('00:01,000')).toBeNull();
36
+ });
37
+ it('returns null on plain text', () => {
38
+ expect(srtTimecodeToMs('Bonjour')).toBeNull();
39
+ });
40
+ it('returns null rather than zero, so a caller can tell the two apart', () => {
41
+ expect(srtTimecodeToMs('')).toBeNull();
42
+ });
43
+ });
44
+ });
@@ -1,2 +1,2 @@
1
- export * as formatDate from './format-date/index.js'
2
1
  export * as toFrenchRepublican from './to-french-republican/index.js'
2
+ export * as formatDate from './format-date/index.js'
@@ -1,2 +1,2 @@
1
- export * as formatDate from './format-date/index.js'
2
1
  export * as toFrenchRepublican from './to-french-republican/index.js'
2
+ export * as formatDate from './format-date/index.js'
@@ -1,5 +1,5 @@
1
1
  import type { ReactNode } from 'react';
2
- import type { WithClassName } from '../utils/types.js';
2
+ import type { ViewportObserverOptions, WithClassName } from '../utils/types.js';
3
3
  /**
4
4
  * Props for the {@link ControlledListLoader} component.
5
5
  *
@@ -26,9 +26,14 @@ import type { WithClassName } from '../utils/types.js';
26
26
  * @property autoLoadPrevWhenVisible - Wraps the leading load button in an
27
27
  * {@link IntersectionObserverComponent} and fires `onLoadPageClicked` when it enters the viewport.
28
28
  * @property autoLoadNextWhenVisible - Same, for the trailing load button.
29
+ * @property threshold - How much of a load button has to be in view before it
30
+ * counts as visible, forwarded to each observer.
31
+ * @property root - Their root. Defaults to the viewport.
32
+ * @property rootMargin - Grows or shrinks that root before measuring — the way to
33
+ * start loading a page before its button actually shows.
29
34
  * @property className - Additional class name(s) applied to the root element.
30
35
  */
31
- export type Props<T> = WithClassName<{
36
+ export type Props<T> = WithClassName<ViewportObserverOptions & {
32
37
  pages: number[];
33
38
  firstPagePos: number;
34
39
  lastPagePos: number;
@@ -75,4 +80,4 @@ export type Props<T> = WithClassName<{
75
80
  * Deduplication runs after `filter`, so an item hidden by `filter` never evicts
76
81
  * an earlier duplicate.
77
82
  */
78
- export declare const ControlledListLoader: <T>({ className, pages, firstPagePos, lastPagePos, itemsPages, filter, display, getIdentifier, loadingPages, onLoadPageClicked, autoLoadPrevWhenVisible, autoLoadNextWhenVisible }: Props<T>) => ReactNode;
83
+ export declare const ControlledListLoader: <T>({ className, pages, firstPagePos, lastPagePos, itemsPages, filter, display, getIdentifier, loadingPages, onLoadPageClicked, autoLoadPrevWhenVisible, autoLoadNextWhenVisible, threshold, root, rootMargin }: Props<T>) => ReactNode;
@@ -39,7 +39,7 @@ import cssModule from './styles.module.css';
39
39
  * Deduplication runs after `filter`, so an item hidden by `filter` never evicts
40
40
  * an earlier duplicate.
41
41
  */
42
- export const ControlledListLoader = ({ className, pages, firstPagePos, lastPagePos, itemsPages, filter, display, getIdentifier, loadingPages, onLoadPageClicked, autoLoadPrevWhenVisible, autoLoadNextWhenVisible }) => {
42
+ export const ControlledListLoader = ({ className, pages, firstPagePos, lastPagePos, itemsPages, filter, display, getIdentifier, loadingPages, onLoadPageClicked, autoLoadPrevWhenVisible, autoLoadNextWhenVisible, threshold, root, rootMargin }) => {
43
43
  const inBoundsPages = pages.filter(pagePos => pagePos >= firstPagePos && pagePos <= lastPagePos);
44
44
  const filteredItems = Array.from(itemsPages)
45
45
  .filter(([pagePos]) => inBoundsPages.includes(pagePos))
@@ -85,7 +85,7 @@ export const ControlledListLoader = ({ className, pages, firstPagePos, lastPageP
85
85
  || (kind === 'next' && autoLoadNextWhenVisible === true);
86
86
  if (!autoLoads)
87
87
  return button;
88
- return _jsx(IntersectionObserverComponent, { className: c('load-observer', kind), onIntersected: ({ ioEntry }) => {
88
+ return _jsx(IntersectionObserverComponent, { className: c('load-observer', kind), threshold: threshold, root: root, rootMargin: rootMargin, onIntersected: ({ ioEntry }) => {
89
89
  if (ioEntry?.isIntersecting !== true)
90
90
  return;
91
91
  onLoadPageClicked?.(pagePos);
@@ -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
  };
@@ -1,6 +1,6 @@
1
1
  import { type PropsWithChildren, type FunctionComponent } from 'react';
2
2
  import type { WithClassName } from '../utils/types.js';
3
- import { type TrackedBlockContext } from './utils.js';
3
+ import { type TrackedBlockContext, type ViewportOffset } from './utils.js';
4
4
  /**
5
5
  * Common properties shared by all block types.
6
6
  *
@@ -62,9 +62,17 @@ export type PropsPage = {
62
62
  *
63
63
  * @property pages - Ordered list of pages that compose the scrollytelling
64
64
  * sequence. Each page may contain any mix of {@link PropsBlock} variants.
65
- * @property thresholdOffsetPercent - Forwarded to the internal
66
- * {@link Paginator}. Defines the viewport offset percentage used to determine
67
- * 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.
68
76
  * @property stickyBlocksLazyLoadDistance - Number of pages around the current
69
77
  * page within which sticky blocks are mounted. Blocks outside this window are
70
78
  * unmounted to save resources. Defaults to `2`.
@@ -85,6 +93,10 @@ export type PropsPage = {
85
93
  export type Props = WithClassName<{
86
94
  pages?: PropsPage[];
87
95
  thresholdOffsetPercent?: number;
96
+ viewportOffsetTop?: ViewportOffset;
97
+ viewportOffsetRight?: ViewportOffset;
98
+ viewportOffsetBottom?: ViewportOffset;
99
+ viewportOffsetLeft?: ViewportOffset;
88
100
  stickyBlocksLazyLoadDistance?: number;
89
101
  forceStickBlocks?: 'before' | 'after' | 'both' | 'none';
90
102
  onPageChanged?: (currentPagePos: number, pageData?: PropsPage) => void;
@@ -125,6 +137,16 @@ export type Props = WithClassName<{
125
137
  * `-width` and `-height` for the component's own stylesheet. They are internal:
126
138
  * do not read or override them.
127
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
+ *
128
150
  * ### Sticky block elements
129
151
  * Each lazy-loaded sticky block receives:
130
152
  * - `--active` modifier when the block's page range includes the current page.
@@ -156,11 +178,19 @@ export type Props = WithClassName<{
156
178
  *
157
179
  * @param props - Component properties.
158
180
  * @see {@link Props}
159
- * @returns A div wrapping the full scrollytelling structure: top-bound sentinel,
160
- * back-blocks layer, front-blocks layer, paginated scrolling content, and
161
- * 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.
162
184
  *
163
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
+ *
164
194
  * Tracking costs nothing until a block asks for it: with no `onScrolled` anywhere,
165
195
  * the component never joins the shared scroll listener. Once it does, each frame
166
196
  * measures only the pages the displayed tracked blocks span, not the whole sequence.
@@ -8,7 +8,7 @@ import { ResizeObserverComponent } from '../ResizeObserver/index.js';
8
8
  import { subscribe, unsubscribe } from '../ScrollListener/utils.js';
9
9
  import { mergeClassNames, useChangeDispatch } from '../utils/index.js';
10
10
  import { scrllgngn as publicClassName } from '../public-classnames.js';
11
- import { blockDisplayZones, consolidateStickyBlocks, contextsAreEqual, lazyLoadedBlocks, measurePages, sameDiscretePart, scrollBlocksOf, scrollKey, stickyKey, toScreenCssProps, toTrackedBlockContext, toTrackedBlockCssProps, toTrackedBlockDataAttributes } 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';
12
12
  import cssModule from './styles.module.css';
13
13
  /**
14
14
  * Scrollytelling engine component. Orchestrates layered sticky blocks (`back`
@@ -45,6 +45,16 @@ import cssModule from './styles.module.css';
45
45
  * `-width` and `-height` for the component's own stylesheet. They are internal:
46
46
  * do not read or override them.
47
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
+ *
48
58
  * ### Sticky block elements
49
59
  * Each lazy-loaded sticky block receives:
50
60
  * - `--active` modifier when the block's page range includes the current page.
@@ -76,16 +86,24 @@ import cssModule from './styles.module.css';
76
86
  *
77
87
  * @param props - Component properties.
78
88
  * @see {@link Props}
79
- * @returns A div wrapping the full scrollytelling structure: top-bound sentinel,
80
- * back-blocks layer, front-blocks layer, paginated scrolling content, and
81
- * 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.
82
92
  *
83
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
+ *
84
102
  * Tracking costs nothing until a block asks for it: with no `onScrolled` anywhere,
85
103
  * the component never joins the shared scroll listener. Once it does, each frame
86
104
  * measures only the pages the displayed tracked blocks span, not the whole sequence.
87
105
  */
88
- 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 }) => {
89
107
  // State
90
108
  const [topVisible, setTopVis] = useState(false);
91
109
  const [contentVisible, setCntVis] = useState(false);
@@ -93,6 +111,7 @@ export const Scrllgngn = ({ pages, thresholdOffsetPercent, stickyBlocksLazyLoadD
93
111
  const [currentPagePos, setCurrentPagePos] = useState(0);
94
112
  const [stickyBlocks, setStickyBlocks] = useState(new Map());
95
113
  const [partialBoundingRect, setPartialBoundingRect] = useState();
114
+ const [visibleZone, setVisibleZone] = useState();
96
115
  // Scroll tracking. Everything the per-frame pass reads lives in a ref: it is
97
116
  // registered once with the shared scroll listener, and writes straight to the DOM
98
117
  // rather than through state, which would re-render the whole sequence per frame.
@@ -103,6 +122,8 @@ export const Scrllgngn = ({ pages, thresholdOffsetPercent, stickyBlocksLazyLoadD
103
122
  const trackedWrappersRef = useRef(new Map());
104
123
  const lastContextsRef = useRef(new Map());
105
124
  const currentPagePosRef = useRef(currentPagePos);
125
+ const visibleZoneRef = useRef(visibleZone);
126
+ const probeRef = useRef(null);
106
127
  // Sticky blocks calculations
107
128
  useEffect(() => {
108
129
  setStickyBlocks(consolidateStickyBlocks(pages));
@@ -139,7 +160,18 @@ export const Scrllgngn = ({ pages, thresholdOffsetPercent, stickyBlocksLazyLoadD
139
160
  useEffect(() => {
140
161
  trackedBlocksRef.current = trackedBlocks;
141
162
  currentPagePosRef.current = currentPagePos;
163
+ visibleZoneRef.current = visibleZone;
142
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
+ }, []);
143
175
  // The per-frame pass: measure only the pages the displayed tracked blocks span,
144
176
  // then hand each block its context — once, and only when something moved.
145
177
  const handleScrolled = useCallback(() => {
@@ -172,7 +204,8 @@ export const Scrllgngn = ({ pages, thresholdOffsetPercent, stickyBlocksLazyLoadD
172
204
  for (const pagePos of block.displayZone)
173
205
  pagesToMeasure.add(pagePos);
174
206
  }
175
- const metrics = measurePages(pageElementsRef.current, pagesToMeasure, thresholdOffsetPercent);
207
+ const thresholdY = toThresholdY(visibleZoneRef.current, thresholdOffsetPercent);
208
+ const metrics = measurePages(pageElementsRef.current, pagesToMeasure, thresholdY);
176
209
  for (const [key, block, page] of pending) {
177
210
  const context = toTrackedBlockContext(block.displayZone, page, metrics);
178
211
  const lastContext = lastContexts.get(key);
@@ -209,6 +242,17 @@ export const Scrllgngn = ({ pages, thresholdOffsetPercent, stickyBlocksLazyLoadD
209
242
  });
210
243
  return () => unsubscribe(subscriptionId);
211
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]);
212
256
  // Handlers
213
257
  useChangeDispatch(currentPagePos, pagePos => onPageChanged?.(pagePos, pages?.[pagePos]));
214
258
  useChangeDispatch(contentVisible, onContentVisibilityChanged);
@@ -232,6 +276,10 @@ export const Scrllgngn = ({ pages, thresholdOffsetPercent, stickyBlocksLazyLoadD
232
276
  else
233
277
  trackedWrappersRef.current.set(key, element);
234
278
  };
279
+ const handleProbeResized = ({ entry, boundingClientRect }) => {
280
+ probeRef.current = entry.target;
281
+ setVisibleZone(toVisibleZoneRect(boundingClientRect));
282
+ };
235
283
  const handleResize = ({ boundingClientRect }) => {
236
284
  if (partialBoundingRect === undefined
237
285
  || boundingClientRect.left !== partialBoundingRect.left
@@ -250,22 +298,31 @@ export const Scrllgngn = ({ pages, thresholdOffsetPercent, stickyBlocksLazyLoadD
250
298
  'force-stick-blocks-after': forceStickBlocks === 'after',
251
299
  'force-stick-blocks-both': forceStickBlocks === 'both'
252
300
  }), className);
253
- const customCssProps = toScreenCssProps(partialBoundingRect);
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) => {
255
- const isActive = block.displayOnPages.includes(currentPagePos);
256
- const blockClss = c('back-block', {
257
- active: isActive,
258
- 'lazy-loaded': !isActive
259
- });
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) => {
262
- const isActive = block.displayOnPages.includes(currentPagePos);
263
- const blockClss = c('front-block', {
264
- active: isActive,
265
- 'lazy-loaded': !isActive
266
- });
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)) });
270
- }) }) }) }), _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 }) })] })] });
271
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 */
@@ -48,10 +48,16 @@ export declare function lazyLoadedBlocks(blocks: Map<string, ConsolidatedStickyB
48
48
  * never inherit the same names from an ancestor component. `initial` leaves them
49
49
  * behaving exactly as an unset variable would, without opening the hole.
50
50
  *
51
+ * The public set describes the component's own box and nothing else. The private
52
+ * one is narrowed to what the fixed layers may actually occupy — the component's
53
+ * box **intersected** with the visible zone — so a viewport offset pushes them off
54
+ * whatever covers that edge without the stylesheet doing arithmetic.
55
+ *
51
56
  * @param rect - The measured bounding rect, absent until the first resize lands.
57
+ * @param zone - The measured visible zone, absent until the probe reports.
52
58
  * @returns The custom properties, keyed by their full name.
53
59
  */
54
- export declare function toScreenCssProps(rect?: ScreenRect): Record<string, string>;
60
+ export declare function toScreenCssProps(rect?: ScreenRect, zone?: VisibleZoneRect): Record<string, string>;
55
61
  /**
56
62
  * Where one tracked block stands in the scroll, as handed to its `onScrolled`.
57
63
  *
@@ -119,11 +125,11 @@ export declare function contiguousRunContaining(zone: number[], page: number): n
119
125
  *
120
126
  * @param pageElements - Every page slot, indexed by position.
121
127
  * @param pagePositions - The positions worth measuring.
122
- * @param thresholdOffsetPercent - The same offset the internal `Paginator` uses,
123
- * as a percentage of the viewport height.
128
+ * @param thresholdY - The threshold line, in pixels from the viewport top. See
129
+ * {@link toThresholdY}.
124
130
  * @returns The metrics of each measurable page, keyed by position.
125
131
  */
126
- export declare function measurePages(pageElements: HTMLElement[], pagePositions: Iterable<number>, thresholdOffsetPercent?: number): Map<number, PageScrollMetrics>;
132
+ export declare function measurePages(pageElements: HTMLElement[], pagePositions: Iterable<number>, thresholdY: number): Map<number, PageScrollMetrics>;
127
133
  /**
128
134
  * Assembles what one tracked block is told about the scroll.
129
135
  *
@@ -185,3 +191,80 @@ export declare function scrollKey(pagePos: number, blockPos: number): string;
185
191
  * @returns Its scroll blocks, in order. `depth` left out means `'scroll'`.
186
192
  */
187
193
  export declare function scrollBlocksOf(page: PropsPage): PropsBlock[];
194
+ /** How much of each viewport edge is covered by something else, in pixels. */
195
+ export type VisibleZoneRect = {
196
+ top: number;
197
+ right: number;
198
+ bottom: number;
199
+ left: number;
200
+ width: number;
201
+ height: number;
202
+ };
203
+ /** A length as authored: a bare number means pixels, a string is CSS's problem. */
204
+ export type ViewportOffset = number | string;
205
+ /** The four edges, as authored on the component. */
206
+ export type ViewportOffsets = {
207
+ top?: ViewportOffset;
208
+ right?: ViewportOffset;
209
+ bottom?: ViewportOffset;
210
+ left?: ViewportOffset;
211
+ };
212
+ /**
213
+ * Builds the custom properties carrying the visible zone's insets.
214
+ *
215
+ * They are emitted whatever the props hold, `0px` standing in for an edge left
216
+ * unset: the probe and the fixed layers position themselves against them, and an
217
+ * absent name would let an enclosing component's value inherit through.
218
+ *
219
+ * @param offsets - The edges as authored.
220
+ * @returns The custom properties, keyed by their full name.
221
+ */
222
+ export declare function toViewportOffsetCssProps(offsets: ViewportOffsets): Record<string, string>;
223
+ /**
224
+ * Reads the visible zone off the probe element.
225
+ *
226
+ * The probe is fixed and inset by the four offsets, so its rect *is* the zone —
227
+ * which is how a `var()`, a `clamp()` or a breakpoint-dependent length reaches
228
+ * JavaScript without anyone parsing a CSS length.
229
+ *
230
+ * @param probeRect - The probe's bounding rect.
231
+ * @returns The zone, as insets from each viewport edge plus its own size.
232
+ */
233
+ export declare function toVisibleZoneRect(probeRect: DOMRect): VisibleZoneRect;
234
+ /**
235
+ * Shrinks an {@link IntersectionObserver} root down to the visible zone.
236
+ *
237
+ * @param zone - The measured zone, absent until the probe reports.
238
+ * @returns A `rootMargin`, or `undefined` to leave the observer on the full
239
+ * viewport.
240
+ */
241
+ export declare function toVisibleZoneRootMargin(zone?: VisibleZoneRect): string | undefined;
242
+ /**
243
+ * Places the threshold line, in pixels from the viewport top.
244
+ *
245
+ * `thresholdOffsetPercent` reads as a percentage **of the visible zone**, not of
246
+ * the viewport: a threshold at 50% has to sit halfway down what the reader can
247
+ * actually see, or it lands under whatever covers the top of the screen.
248
+ *
249
+ * @param zone - The measured zone, absent until the probe reports.
250
+ * @param thresholdOffsetPercent - The offset as authored.
251
+ * @returns The line's distance from the viewport top.
252
+ */
253
+ export declare function toThresholdY(zone?: VisibleZoneRect, thresholdOffsetPercent?: number): number;
254
+ /**
255
+ * Restates the threshold as the percentage of the **viewport** an
256
+ * {@link IntersectionObserver} needs, since that is the only frame `rootMargin`
257
+ * knows.
258
+ *
259
+ * With no offsets set the zone spans the viewport and the number comes back
260
+ * unchanged, so nothing moves for a component that never asked for any of this.
261
+ *
262
+ * @param zone - The measured zone, absent until the probe reports.
263
+ * @param thresholdOffsetPercent - The offset as authored.
264
+ * @returns The equivalent viewport percentage, to hand to the internal `Paginator`.
265
+ */
266
+ export declare function toPaginatorThresholdPercent(zone?: VisibleZoneRect, thresholdOffsetPercent?: number): number | undefined;
267
+ /** How often the visible zone is re-read while the component is on screen, in ms. */
268
+ export declare const visibleZonePollInterval = 100;
269
+ /** Whether two zones hold the same six measurements. */
270
+ export declare function visibleZonesAreEqual(a?: VisibleZoneRect, b?: VisibleZoneRect): boolean;