From 3e4d87a05f4f30d9c538648d2d695332ab308f69 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 14 Aug 2026 16:05:00 +0200 Subject: [PATCH 01/14] Add view timeline progress API for content elements Content elements can now observe how far they have travelled through the viewport, not only whether they are visible. Registering a content element type with `viewTimeline: true` makes the `useContentElementViewTimelineProgress` hook available, which invokes a callback with a progress value between 0 and 1. Vocabulary follows CSS scroll driven animations: The content element acts as the subject of a view timeline and the `range` option selects which part of it to measure (`cover`, `contain`, `entry` or `exit`). Progress is passed to a callback instead of being returned by the hook to prevent rerendering content elements on every scroll frame. It is meant to drive imperative APIs like animation players. Since content element types can support view timelines without always observing scroll position, scroll and resize handlers are only registered while there are subscriptions. Specs can drive progress via the new `simulateScrollProgress` function returned by `renderInContentElement`. --- .../doc/creating_content_element_types.md | 69 ++++++++ .../scrolled/package/documentation.yml | 1 + ...ContentElementViewTimelineProgress-spec.js | 141 +++++++++++++++ .../spec/frontend/viewTimelineRanges-spec.js | 106 +++++++++++ .../package/src/frontend/ContentElement.js | 19 +- .../scrolled/package/src/frontend/index.js | 4 + .../useContentElementViewTimelineProgress.js | 165 ++++++++++++++++++ .../src/frontend/viewTimelineRanges.js | 36 ++++ .../src/testHelpers/renderInContentElement.js | 26 ++- 9 files changed, 557 insertions(+), 10 deletions(-) create mode 100644 entry_types/scrolled/package/spec/frontend/useContentElementViewTimelineProgress-spec.js create mode 100644 entry_types/scrolled/package/spec/frontend/viewTimelineRanges-spec.js create mode 100644 entry_types/scrolled/package/src/frontend/useContentElementViewTimelineProgress.js create mode 100644 entry_types/scrolled/package/src/frontend/viewTimelineRanges.js diff --git a/entry_types/scrolled/doc/creating_content_element_types.md b/entry_types/scrolled/doc/creating_content_element_types.md index 6f1393a7b1..d31a174978 100644 --- a/entry_types/scrolled/doc/creating_content_element_types.md +++ b/entry_types/scrolled/doc/creating_content_element_types.md @@ -199,6 +199,75 @@ function LoopingVideo(props) { } ``` +### View Timeline Progress + +While the lifecycle hook tells a content element when it enters or +leaves the viewport, the `useContentElementViewTimelineProgress` hook +tells it how far it has travelled through the viewport. The content +element acts as the subject of a view timeline, using the same +concepts as CSS scroll driven animations. Requires the `viewTimeline` +option to be set to true when registering the content element type. + +The `range` option determines which part of the timeline to measure: + +* `cover` (default): From the moment the content element starts + entering the viewport until it has completely left it. + +* `contain`: While the content element is completely inside the + viewport. For content elements taller than the viewport, while the + content element completely covers the viewport. + +* `entry`: While the content element is entering the viewport. + +* `exit`: While the content element is leaving the viewport. + +Progress is passed to the `onProgress` callback as a number between 0 +and 1 instead of being returned by the hook. This prevents rerendering +the content element on every scroll frame. Use it to drive imperative +APIs: + +```javascript +// frontend.js + +frontend.contentElementTypes.register('scrollAnimation', { + viewTimeline: true, + component: Component +}); + +function Component() { + const playerRef = useRef(); + + useContentElementViewTimelineProgress({ + range: 'cover', + onProgress: progress => playerRef.current.seekTo(progress) + }); + + // ... +} +``` + +Pass a falsy `onProgress` value to not observe scroll position at all, +for example if scroll coupled behavior is optional: + +```javascript +useContentElementViewTimelineProgress({ + onProgress: configuration.playbackMode === 'scroll' ? seek : null +}); +``` + +Note that content elements with `sticky` or `standAlone` position stop +moving with the page while they are pinned. Progress along their view +timeline stalls accordingly. + +In specs, `renderInContentElement` provides a `simulateScrollProgress` +function to invoke the callback: + +```javascript +const {simulateScrollProgress} = renderInContentElement(); + +simulateScrollProgress(0.5); +``` + ## Using the Storybook Pageflow Scrolled uses [Storybook](https://storybook.js.org/) to ease diff --git a/entry_types/scrolled/package/documentation.yml b/entry_types/scrolled/package/documentation.yml index b5bcc90471..606f662b21 100644 --- a/entry_types/scrolled/package/documentation.yml +++ b/entry_types/scrolled/package/documentation.yml @@ -34,6 +34,7 @@ toc: - useAudioFocus - useContentElementEditorState - useContentElementLifecycle + - useContentElementViewTimelineProgress - useCurrentChapter - useCredits - useDarkBackground diff --git a/entry_types/scrolled/package/spec/frontend/useContentElementViewTimelineProgress-spec.js b/entry_types/scrolled/package/spec/frontend/useContentElementViewTimelineProgress-spec.js new file mode 100644 index 0000000000..e947f1db84 --- /dev/null +++ b/entry_types/scrolled/package/spec/frontend/useContentElementViewTimelineProgress-spec.js @@ -0,0 +1,141 @@ +import React from 'react'; +import {act} from '@testing-library/react'; + +import {frontend, Entry, useContentElementViewTimelineProgress} from 'pageflow-scrolled/frontend'; + +import {renderInEntry} from 'support'; +import {fakeBoundingClientRectsByTestId} from 'support/fakeBoundingClientRects'; + +describe('useContentElementViewTimelineProgress', () => { + beforeEach(() => { + window.innerHeight = 1000; + }); + + afterEach(() => jest.restoreAllMocks()); + + function renderTestContentElement({onProgress, range, viewTimeline = true} = {}) { + frontend.contentElementTypes.register('test', { + viewTimeline, + + component: function Test() { + useContentElementViewTimelineProgress({range, onProgress}); + return
; + } + }); + + return renderInEntry(, { + seed: {contentElements: [{typeName: 'test'}]} + }); + } + + function simulateScrollTo({top, height = 500}) { + fakeBoundingClientRectsByTestId({testElement: {top, height}}); + + act(() => { + window.dispatchEvent(new Event('scroll')); + }); + } + + it('invokes onProgress with current progress on mount', () => { + const onProgress = jest.fn(); + fakeBoundingClientRectsByTestId({testElement: {top: 250, height: 500}}); + + renderTestContentElement({onProgress}); + + expect(onProgress).toHaveBeenCalledWith(0.5); + }); + + it('invokes onProgress when scrolling', () => { + const onProgress = jest.fn(); + fakeBoundingClientRectsByTestId({testElement: {top: 1000, height: 500}}); + + renderTestContentElement({onProgress}); + simulateScrollTo({top: -500}); + + expect(onProgress).toHaveBeenLastCalledWith(1); + }); + + it('invokes onProgress when resizing', () => { + const onProgress = jest.fn(); + fakeBoundingClientRectsByTestId({testElement: {top: 1000, height: 500}}); + + renderTestContentElement({onProgress}); + + fakeBoundingClientRectsByTestId({testElement: {top: 250, height: 500}}); + + act(() => { + window.dispatchEvent(new Event('resize')); + }); + + expect(onProgress).toHaveBeenLastCalledWith(0.5); + }); + + it('does not invoke onProgress again if progress has not changed', () => { + const onProgress = jest.fn(); + fakeBoundingClientRectsByTestId({testElement: {top: 1000, height: 500}}); + + renderTestContentElement({onProgress}); + simulateScrollTo({top: 1200}); + + expect(onProgress).toHaveBeenCalledTimes(1); + }); + + it('measures progress for the passed range', () => { + const onProgress = jest.fn(); + fakeBoundingClientRectsByTestId({testElement: {top: 750, height: 500}}); + + renderTestContentElement({onProgress, range: 'entry'}); + + expect(onProgress).toHaveBeenCalledWith(0.5); + }); + + it('does not listen for scroll events if onProgress is falsy', () => { + const addEventListener = jest.spyOn(window, 'addEventListener'); + + renderTestContentElement({onProgress: null}); + + expect(addEventListener).not.toHaveBeenCalledWith('scroll', expect.any(Function)); + }); + + it('listens for scroll events if onProgress is present', () => { + const addEventListener = jest.spyOn(window, 'addEventListener'); + + renderTestContentElement({onProgress: () => {}}); + + expect(addEventListener).toHaveBeenCalledWith('scroll', expect.any(Function)); + }); + + it('does not observe scroll position if onProgress is falsy', () => { + const getBoundingClientRect = jest.spyOn(HTMLElement.prototype, 'getBoundingClientRect'); + + renderTestContentElement({onProgress: null}); + getBoundingClientRect.mockClear(); + + act(() => { + window.dispatchEvent(new Event('scroll')); + }); + + expect(getBoundingClientRect).not.toHaveBeenCalled(); + }); + + it('stops invoking onProgress after unmount', () => { + const onProgress = jest.fn(); + fakeBoundingClientRectsByTestId({testElement: {top: 1000, height: 500}}); + + const {unmount} = renderTestContentElement({onProgress}); + unmount(); + simulateScrollTo({top: 250}); + + expect(onProgress).not.toHaveBeenCalledWith(0.5); + }); + + it('throws descriptive error if content element type is missing flag', () => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + + renderTestContentElement({onProgress: () => {}, viewTimeline: false}); + + expect(console.error).toHaveBeenCalledWith(expect.stringMatching( + /only available in content elements for which `viewTimeline: true`/ + ), expect.anything()); + }); +}); diff --git a/entry_types/scrolled/package/spec/frontend/viewTimelineRanges-spec.js b/entry_types/scrolled/package/spec/frontend/viewTimelineRanges-spec.js new file mode 100644 index 0000000000..5abe308f81 --- /dev/null +++ b/entry_types/scrolled/package/spec/frontend/viewTimelineRanges-spec.js @@ -0,0 +1,106 @@ +import {getViewTimelineProgress} from 'frontend/viewTimelineRanges'; + +describe('getViewTimelineProgress', () => { + function progress({range = 'cover', top, height = 500, viewportHeight = 1000}) { + return getViewTimelineProgress({ + range, + rect: {top, height}, + viewportHeight + }); + } + + describe('cover range', () => { + it('is 0 while subject is about to enter viewport', () => { + expect(progress({top: 1000})).toEqual(0); + }); + + it('is 1 once subject has completely left viewport', () => { + expect(progress({top: -500})).toEqual(1); + }); + + it('is 0.5 when subject has covered half the distance', () => { + expect(progress({top: 250})).toEqual(0.5); + }); + + it('is clamped below viewport', () => { + expect(progress({top: 2000})).toEqual(0); + }); + + it('is clamped above viewport', () => { + expect(progress({top: -1500})).toEqual(1); + }); + }); + + describe('entry range', () => { + it('is 0 while subject is about to enter viewport', () => { + expect(progress({range: 'entry', top: 1000})).toEqual(0); + }); + + it('is 1 once subject is completely inside viewport', () => { + expect(progress({range: 'entry', top: 500})).toEqual(1); + }); + + it('is 0.5 when subject has entered halfway', () => { + expect(progress({range: 'entry', top: 750})).toEqual(0.5); + }); + + it('stays 1 while subject moves further up', () => { + expect(progress({range: 'entry', top: 0})).toEqual(1); + }); + }); + + describe('exit range', () => { + it('is 0 while subject is about to leave viewport', () => { + expect(progress({range: 'exit', top: 0})).toEqual(0); + }); + + it('is 1 once subject has completely left viewport', () => { + expect(progress({range: 'exit', top: -500})).toEqual(1); + }); + + it('is 0.5 when subject has left halfway', () => { + expect(progress({range: 'exit', top: -250})).toEqual(0.5); + }); + + it('stays 0 while subject is still completely inside viewport', () => { + expect(progress({range: 'exit', top: 300})).toEqual(0); + }); + }); + + describe('contain range for subject smaller than viewport', () => { + it('is 0 once subject is completely inside viewport', () => { + expect(progress({range: 'contain', top: 500})).toEqual(0); + }); + + it('is 1 while subject is about to leave viewport', () => { + expect(progress({range: 'contain', top: 0})).toEqual(1); + }); + + it('is 0.5 in the middle of the viewport', () => { + expect(progress({range: 'contain', top: 250})).toEqual(0.5); + }); + }); + + describe('contain range for subject taller than viewport', () => { + it('is 0 once subject covers viewport', () => { + expect(progress({range: 'contain', top: 0, height: 1500})).toEqual(0); + }); + + it('is 1 while subject is about to stop covering viewport', () => { + expect(progress({range: 'contain', top: -500, height: 1500})).toEqual(1); + }); + + it('is 0.5 in the middle', () => { + expect(progress({range: 'contain', top: -250, height: 1500})).toEqual(0.5); + }); + }); + + it('is 1 for subject of exactly viewport height in contain range', () => { + expect(progress({range: 'contain', top: 0, height: 1000})).toEqual(1); + }); + + it('throws descriptive error for unknown range', () => { + expect(() => progress({range: 'crossing', top: 0})) + .toThrow(/Unknown view timeline range 'crossing'/); + }); +}); diff --git a/entry_types/scrolled/package/src/frontend/ContentElement.js b/entry_types/scrolled/package/src/frontend/ContentElement.js index 1e278aba39..c9b4088926 100644 --- a/entry_types/scrolled/package/src/frontend/ContentElement.js +++ b/entry_types/scrolled/package/src/frontend/ContentElement.js @@ -4,6 +4,7 @@ import {api} from './api'; import {extensible} from './extensionRegistry'; import {ContentElementAttributesProvider} from './useContentElementAttributes'; import {ContentElementLifecycleProvider} from './useContentElementLifecycle'; +import {ContentElementViewTimelineProvider} from './useContentElementViewTimelineProgress'; import {ContentElementMargin} from './ContentElementMargin'; import {ContentElementErrorBoundary} from './ContentElementErrorBoundary'; @@ -26,14 +27,16 @@ export const ContentElement = React.memo(extensible( top={props.itemProps.marginTop} bottom={props.marginBottom} previousBottom={props.previousMarginBottom}> - - - + + + + + diff --git a/entry_types/scrolled/package/src/frontend/index.js b/entry_types/scrolled/package/src/frontend/index.js index e837c4dc79..93a6e84822 100644 --- a/entry_types/scrolled/package/src/frontend/index.js +++ b/entry_types/scrolled/package/src/frontend/index.js @@ -99,6 +99,10 @@ export { useContentElementLifecycle, ContentElementLifecycleContext } from './useContentElementLifecycle'; +export { + useContentElementViewTimelineProgress, + ContentElementViewTimelineContext +} from './useContentElementViewTimelineProgress'; export {useCurrentChapter} from './useCurrentChapter'; export {useIsStaticPreview} from './useScrollPositionLifecycle'; export {useMediaMuted, useOnUnmuteMedia} from './useMediaMuted'; diff --git a/entry_types/scrolled/package/src/frontend/useContentElementViewTimelineProgress.js b/entry_types/scrolled/package/src/frontend/useContentElementViewTimelineProgress.js new file mode 100644 index 0000000000..0f9d6846b1 --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/useContentElementViewTimelineProgress.js @@ -0,0 +1,165 @@ +import React, {createContext, useContext, useEffect, useMemo, useRef, useState} from 'react'; + +import {api} from './api'; +import {getViewTimelineProgress} from './viewTimelineRanges'; + +export const ContentElementViewTimelineContext = createContext(); + +export function ContentElementViewTimelineProvider({type, children}) { + const {viewTimeline} = api.contentElementTypes.getOptions(type); + + if (viewTimeline) { + return ( + + {children} + + ); + } + else { + return children; + } +} + +function ViewTimelineProvider({children}) { + const subjectRef = useRef(); + const subscriptionsRef = useRef(new Set()); + + // Content element types can support view timelines without always + // observing scroll position. Only listen while there are + // subscriptions to prevent each of them from adding a handler. + const [hasSubscriptions, setHasSubscriptions] = useState(false); + + const viewTimeline = useMemo(() => ({ + subscribe(range, callback) { + const subscription = {range, callback}; + + subscriptionsRef.current.add(subscription); + setHasSubscriptions(true); + update(subjectRef.current, [subscription]); + + return () => { + subscriptionsRef.current.delete(subscription); + setHasSubscriptions(subscriptionsRef.current.size > 0); + }; + } + }), []); + + useEffect(() => { + if (!hasSubscriptions) { + return; + } + + const subject = subjectRef.current; + const subscriptions = subscriptionsRef.current; + + let animationFrame; + + function handle() { + if (animationFrame) { + return; + } + + animationFrame = requestAnimationFrame(() => { + animationFrame = null; + update(subject, subscriptions); + }); + } + + window.addEventListener('scroll', handle); + window.addEventListener('resize', handle); + + return () => { + cancelAnimationFrame(animationFrame); + + window.removeEventListener('scroll', handle); + window.removeEventListener('resize', handle); + }; + }, [hasSubscriptions]); + + return ( +
+ + {children} + +
+ ); +} + +function update(subject, subscriptions) { + const rect = subject.getBoundingClientRect(); + const viewportHeight = window.innerHeight; + + subscriptions.forEach(subscription => { + const progress = getViewTimelineProgress({ + range: subscription.range, + rect, + viewportHeight + }); + + if (progress !== subscription.lastProgress) { + subscription.lastProgress = progress; + subscription.callback(progress); + } + }); +} + +/** + * Invokes a callback with the progress of the content element along a + * range of its view timeline. Mirrors the concepts of CSS scroll + * driven animations: The content element acts as the subject of a + * view timeline of the page's scroll container. Requires the + * `viewTimeline` option to be set to true in the + * `frontend.contentElementTypes.register` call for the content + * element's type. + * + * Progress is passed to a callback instead of being returned to + * prevent rerendering the content element on every scroll frame. + * + * @param {Object} options + * + * @param {string} [options.range='cover'] - + * Which part of the content element's view timeline to measure: + * + * * `cover`: From the moment the content element starts entering + * the viewport until it has completely left it. + * + * * `contain`: While the content element is completely inside the + * viewport. For content elements taller than the viewport, while + * the content element completely covers the viewport. + * + * * `entry`: While the content element is entering the viewport. + * + * * `exit`: While the content element is leaving the viewport. + * + * @param {Function} [options.onProgress] - + * Invoked with a number between 0 and 1 whenever progress along the + * range changes. Pass a falsy value to not observe scroll position + * at all. + * + * @example + * + * useContentElementViewTimelineProgress({ + * range: 'cover', + * onProgress: progress => player.seekTo(progress) + * }); + */ +export function useContentElementViewTimelineProgress({range = 'cover', onProgress} = {}) { + const viewTimeline = useContext(ContentElementViewTimelineContext); + + const onProgressRef = useRef(); + onProgressRef.current = onProgress; + + const enabled = !!onProgress; + + useEffect(() => { + if (viewTimeline && enabled) { + return viewTimeline.subscribe(range, progress => onProgressRef.current(progress)); + } + }, [viewTimeline, range, enabled]); + + if (!viewTimeline) { + throw new Error('useContentElementViewTimelineProgress is only available in ' + + 'content elements for which `viewTimeline: true` has ' + + 'been passed to frontend.contentElementTypes.register'); + } +} diff --git a/entry_types/scrolled/package/src/frontend/viewTimelineRanges.js b/entry_types/scrolled/package/src/frontend/viewTimelineRanges.js new file mode 100644 index 0000000000..2a1786e814 --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/viewTimelineRanges.js @@ -0,0 +1,36 @@ +// Offsets of the subject's top edge relative to the viewport's top +// edge at the start and end of each range. Mirrors the named ranges of +// CSS scroll driven animations. +const rangeEdges = { + cover: (subjectHeight, viewportHeight) => [viewportHeight, -subjectHeight], + + contain: (subjectHeight, viewportHeight) => [ + Math.max(viewportHeight - subjectHeight, 0), + Math.min(viewportHeight - subjectHeight, 0) + ], + + entry: (subjectHeight, viewportHeight) => [viewportHeight, viewportHeight - subjectHeight], + + exit: (subjectHeight, viewportHeight) => [0, -subjectHeight] +}; + +export function getViewTimelineProgress({range, rect, viewportHeight}) { + const edges = rangeEdges[range]; + + if (!edges) { + throw new Error(`Unknown view timeline range '${range}'. ` + + `Supported ranges: ${Object.keys(rangeEdges).join(', ')}.`); + } + + const [start, end] = edges(rect.height, viewportHeight); + + if (start === end) { + return rect.top <= start ? 1 : 0; + } + + return clamp((start - rect.top) / (start - end)); +} + +function clamp(value) { + return Math.min(Math.max(value, 0), 1); +} diff --git a/entry_types/scrolled/package/src/testHelpers/renderInContentElement.js b/entry_types/scrolled/package/src/testHelpers/renderInContentElement.js index 2332c83c7c..ae47242c09 100644 --- a/entry_types/scrolled/package/src/testHelpers/renderInContentElement.js +++ b/entry_types/scrolled/package/src/testHelpers/renderInContentElement.js @@ -9,6 +9,7 @@ import { ContentElementEditorCommandEmitterContext, ContentElementEditorStateContext, ContentElementLifecycleContext, + ContentElementViewTimelineContext, MainStorylineActivity } from 'pageflow-scrolled/frontend'; @@ -20,9 +21,14 @@ import {renderInEntryWithScrollPositionLifecycle} from './scrollPositionLifecycl * Provide context as if component was rendered inside of a content element. * * Returns additional functions to control content element scroll - * lifecycle, editor commands, and storyline mode: `simulateScrollPosition`, + * lifecycle, view timeline progress, editor commands, and storyline + * mode: `simulateScrollPosition`, `simulateScrollProgress`, * `triggerEditorCommand`, and `simulateStorylineMode`. * + * `simulateScrollProgress` passes the given progress to all + * `useContentElementViewTimelineProgress` callbacks, no matter which + * range they observe. + * * @param {Function} callback - React component or function returning a React component. * @param {Object} [options] - Supports all options supported by {@link `renderInEntry`}. * @param {boolean|Object} [options.inlineEditing] - @@ -47,6 +53,7 @@ import {renderInEntryWithScrollPositionLifecycle} from './scrollPositionLifecycl * inlineEditing: {isSelected: true} * }); * simulateScrollPosition('near viewport'); + * simulateScrollProgress(0.5); * triggerEditorCommand({type: 'HIGHLIGHT'}); * simulateStorylineMode('background'); */ @@ -58,6 +65,14 @@ export function renderInContentElement(ui, {inlineEditing, ...options} = {}) { const emitter = Object.assign({}, BackboneEvents); const storylineEmitter = Object.assign({}, BackboneEvents); + const viewTimelineEmitter = Object.assign({}, BackboneEvents); + + const viewTimeline = { + subscribe(range, callback) { + viewTimelineEmitter.on('progress', callback); + return () => viewTimelineEmitter.off('progress', callback); + } + }; const inlineEditingConfig = resolveInlineEditing(inlineEditing); @@ -86,7 +101,9 @@ export function renderInContentElement(ui, {inlineEditing, return ( - {tree} + + {tree} + ); @@ -116,6 +133,11 @@ export function renderInContentElement(ui, {inlineEditing, act(() => { storylineEmitter.trigger('storylineMode', mode) }); + }, + simulateScrollProgress(progress) { + act(() => { + viewTimelineEmitter.trigger('progress', progress) + }); } }; } From 2e054dffcf28e25bf7801d8666fa2c42d6d08994 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 14 Aug 2026 16:08:10 +0200 Subject: [PATCH 02/14] Support coupling lottie playback to scroll position The new `scroll` playback mode turns the animation into a scrubbing target: Instead of playing on its own, the current frame follows the element's progress along the `cover` range of its view timeline. The animation starts as soon as the element enters the viewport and reaches its last frame once the element has completely left it. Frames can only be set once the animation has loaded. Progress observed before that is applied as soon as the load event fires. --- .../lottieAnimation/LottieAnimation-spec.js | 62 +++++++++++++++++++ .../lottieAnimation/LottieAnimation.js | 45 +++++++++++--- .../lottieAnimation/frontend.js | 3 +- 3 files changed, 102 insertions(+), 8 deletions(-) diff --git a/entry_types/scrolled/package/spec/contentElements/lottieAnimation/LottieAnimation-spec.js b/entry_types/scrolled/package/spec/contentElements/lottieAnimation/LottieAnimation-spec.js index 94583d903a..07570866d0 100644 --- a/entry_types/scrolled/package/spec/contentElements/lottieAnimation/LottieAnimation-spec.js +++ b/entry_types/scrolled/package/spec/contentElements/lottieAnimation/LottieAnimation-spec.js @@ -103,6 +103,68 @@ describe('LottieAnimation', () => { expect(players[0].pause).toHaveBeenCalled(); }); + describe('scroll playback mode', () => { + const configuration = {id: 100, playbackMode: 'scroll'}; + + it('does not loop', () => { + renderLottieAnimation({configuration}); + + expect(players[0].config.loop).toBe(false); + }); + + it('does not play animation', () => { + renderLottieAnimation({configuration}); + + players[0].emit('load'); + + expect(players[0].play).not.toHaveBeenCalled(); + }); + + it('sets frame matching scroll progress', () => { + const {simulateScrollProgress} = renderLottieAnimation({configuration}); + players[0].emit('load'); + + simulateScrollProgress(0.5); + + expect(players[0].setFrame).toHaveBeenCalledWith(4.5); + }); + + it('sets last frame at end of scroll progress', () => { + const {simulateScrollProgress} = renderLottieAnimation({configuration}); + players[0].emit('load'); + + simulateScrollProgress(1); + + expect(players[0].setFrame).toHaveBeenCalledWith(9); + }); + + it('does not set frame before animation has loaded', () => { + const {simulateScrollProgress} = renderLottieAnimation({configuration}); + + simulateScrollProgress(0.5); + + expect(players[0].setFrame).not.toHaveBeenCalled(); + }); + + it('applies scroll progress from before load once animation has loaded', () => { + const {simulateScrollProgress} = renderLottieAnimation({configuration}); + + simulateScrollProgress(0.5); + players[0].emit('load'); + + expect(players[0].setFrame).toHaveBeenCalledWith(4.5); + }); + + it('does not set frame in other playback modes', () => { + const {simulateScrollProgress} = renderLottieAnimation(); + players[0].emit('load'); + + simulateScrollProgress(0.5); + + expect(players[0].setFrame).not.toHaveBeenCalled(); + }); + }); + it('destroys player on unmount', () => { const {unmount} = renderLottieAnimation(); diff --git a/entry_types/scrolled/package/src/contentElements/lottieAnimation/LottieAnimation.js b/entry_types/scrolled/package/src/contentElements/lottieAnimation/LottieAnimation.js index 4fae924b02..112c3306b0 100644 --- a/entry_types/scrolled/package/src/contentElements/lottieAnimation/LottieAnimation.js +++ b/entry_types/scrolled/package/src/contentElements/lottieAnimation/LottieAnimation.js @@ -1,4 +1,4 @@ -import React, {useEffect, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useRef, useState} from 'react'; import { ContentElementBox, @@ -8,6 +8,7 @@ import { InlineFileRights, processImageModifiers, useContentElementLifecycle, + useContentElementViewTimelineProgress, useFileWithInlineRights } from 'pageflow-scrolled/frontend'; @@ -26,6 +27,8 @@ export function LottieAnimation({configuration}) { const {aspectRatio, rounded} = processImageModifiers(configuration.imageModifiers); const isCircleCrop = rounded === 'circle'; + const {playbackMode = 'loop'} = configuration; + return ( @@ -39,8 +42,9 @@ export function LottieAnimation({configuration}) { {lottieFile && shouldLoad && { + progressRef.current = progress; + + if (isLoadedRef.current) { + const dotLottie = dotLottieRef.current; + dotLottie.setFrame(progress * (dotLottie.totalFrames - 1)); + } + }, []); + + useContentElementViewTimelineProgress({ + range: 'cover', + onProgress: seekOnScroll ? seek : null + }); + useEffect(() => { const dotLottie = new DotLottie({ canvas: canvasRef.current, @@ -79,7 +100,7 @@ function Player({ }); // Playback is only started here since the animation cannot be - // played before it has been loaded. + // played or seeked before it has been loaded. dotLottie.addEventListener('load', () => { const {width, height} = dotLottie.animationSize(); @@ -87,7 +108,12 @@ function Player({ onAspectRatioChange(height / width); } - if (playRef.current) { + isLoadedRef.current = true; + + if (seekOnScroll) { + seek(progressRef.current); + } + else if (playRef.current) { dotLottie.play(); } }); @@ -95,19 +121,24 @@ function Player({ dotLottieRef.current = dotLottie; return () => { + isLoadedRef.current = false; dotLottieRef.current = null; dotLottie.destroy(); }; - }, [lottieFile.urls.original, loop, fit, cropPositionX, cropPositionY, onAspectRatioChange]); + }, [lottieFile.urls.original, loop, fit, seekOnScroll, seek, cropPositionX, cropPositionY, onAspectRatioChange]); useEffect(() => { + if (seekOnScroll) { + return; + } + if (play) { dotLottieRef.current.play(); } else { dotLottieRef.current.pause(); } - }, [play]); + }, [play, seekOnScroll]); return ( diff --git a/entry_types/scrolled/package/src/contentElements/lottieAnimation/frontend.js b/entry_types/scrolled/package/src/contentElements/lottieAnimation/frontend.js index db6b8aafdf..c4eb0642a2 100644 --- a/entry_types/scrolled/package/src/contentElements/lottieAnimation/frontend.js +++ b/entry_types/scrolled/package/src/contentElements/lottieAnimation/frontend.js @@ -4,5 +4,6 @@ import {LottieAnimation} from './LottieAnimation'; frontend.contentElementTypes.register('lottieAnimation', { component: LottieAnimation, - lifecycle: true + lifecycle: true, + viewTimeline: true }); From 3e39bda187765f4f6146450afba6817e77e17b1f Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 14 Aug 2026 16:09:47 +0200 Subject: [PATCH 03/14] Add scroll playback mode to lottie animation editor Lets editors pick the new playback mode that couples the animation to the scroll position of the element. --- entry_types/scrolled/config/locales/de.yml | 3 ++- entry_types/scrolled/config/locales/en.yml | 3 ++- .../spec/contentElements/lottieAnimation/editor/index-spec.js | 2 +- .../src/contentElements/lottieAnimation/editor/index.js | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index 90745d17fc..90dbd8a283 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -775,11 +775,12 @@ de: id: label: Animation playbackMode: - inline_help_html: Bestimmt, wie die Animation abgespielt wird, sobald sie sichtbar wird:
  • Endlosschleife: Immer wieder, solange sie sichtbar ist.
  • Einmal abspielen: Einmal von Anfang bis Ende.
+ inline_help_html: Bestimmt, wie die Animation abgespielt wird, sobald sie sichtbar wird:
  • Endlosschleife: Immer wieder, solange sie sichtbar ist.
  • Einmal abspielen: Einmal von Anfang bis Ende.
  • Scrollposition: Bild für Bild beim Scrollen. Die Animation beginnt, wenn das Element in den Viewport scrollt, und endet, sobald es den Viewport wieder verlassen hat.
label: Wiedergabe-Modus values: loop: Endlosschleife playOnce: Einmal abspielen + scroll: Scrollposition description: Eine als dotLottie-Datei exportierte Animation einbinden name: Lottie-Animation tabs: diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml index ba71870733..955a7ba7ef 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -760,11 +760,12 @@ en: id: label: Animation playbackMode: - inline_help_html: Determines how the animation is played once it becomes visible:
  • Loop: Again and again as long as it is visible.
  • Play once: Once from start to end.
+ inline_help_html: Determines how the animation is played once it becomes visible:
  • Loop: Again and again as long as it is visible.
  • Play once: Once from start to end.
  • Scroll position: Frame by frame while scrolling. The animation starts when the element enters the viewport and ends once it has left the viewport again.
label: Playback Mode values: loop: Loop playOnce: Play once + scroll: Scroll position description: Embed an animation exported as dotLottie file name: Lottie animation tabs: diff --git a/entry_types/scrolled/package/spec/contentElements/lottieAnimation/editor/index-spec.js b/entry_types/scrolled/package/spec/contentElements/lottieAnimation/editor/index-spec.js index 9aaa757b00..2db559bc42 100644 --- a/entry_types/scrolled/package/spec/contentElements/lottieAnimation/editor/index-spec.js +++ b/entry_types/scrolled/package/spec/contentElements/lottieAnimation/editor/index-spec.js @@ -58,7 +58,7 @@ describe('lottieAnimation/editor', () => { inView: configurationEditor }); - expect(input.values()).toEqual(['loop', 'playOnce']); + expect(input.values()).toEqual(['loop', 'playOnce', 'scroll']); }); it('displays image modifiers input if animation is present', () => { diff --git a/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/index.js b/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/index.js index 3b78b3692d..96276e9655 100644 --- a/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/index.js +++ b/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/index.js @@ -22,7 +22,7 @@ editor.fileTypes.register('lottie_files', { matchUpload: upload => /\.lottie$/i.test(upload.name) }); -const playbackModes = ['loop', 'playOnce']; +const playbackModes = ['loop', 'playOnce', 'scroll']; editor.contentElementTypes.register('lottieAnimation', { pictogram, From bfb5a7362e6728fb68133118619caef4c1e5e527 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 14 Aug 2026 16:27:56 +0200 Subject: [PATCH 04/14] Measure view timeline progress of standAlone elements along scroll space Content elements with standAlone position are pinned in the center of the viewport while their scroll space passes by. Measuring their own bounding rect would make progress stall for exactly the part of the scroll space that the extra scrolling was added for. Let components that add scroll space around a content element pass the element that keeps moving with the page as the subject of the view timeline. --- .../doc/creating_content_element_types.md | 8 ++-- ...ContentElementViewTimelineProgress-spec.js | 45 +++++++++++++++++-- .../spec/support/fakeBoundingClientRects.js | 20 +++++++++ .../src/frontend/ContentElementScrollSpace.js | 30 ++++++++----- .../useContentElementViewTimelineProgress.js | 45 ++++++++++++++----- 5 files changed, 120 insertions(+), 28 deletions(-) diff --git a/entry_types/scrolled/doc/creating_content_element_types.md b/entry_types/scrolled/doc/creating_content_element_types.md index d31a174978..30cef41378 100644 --- a/entry_types/scrolled/doc/creating_content_element_types.md +++ b/entry_types/scrolled/doc/creating_content_element_types.md @@ -255,9 +255,11 @@ useContentElementViewTimelineProgress({ }); ``` -Note that content elements with `sticky` or `standAlone` position stop -moving with the page while they are pinned. Progress along their view -timeline stalls accordingly. +Content elements with `standAlone` position are pinned in the center +of the viewport for part of their scroll space. Their progress is +measured along that scroll space, so it keeps advancing while the +element is pinned. Content elements with `sticky` position, on the +other hand, stop making progress while they are sticky. In specs, `renderInContentElement` provides a `simulateScrollProgress` function to invoke the callback: diff --git a/entry_types/scrolled/package/spec/frontend/useContentElementViewTimelineProgress-spec.js b/entry_types/scrolled/package/spec/frontend/useContentElementViewTimelineProgress-spec.js index e947f1db84..2ad23db3d5 100644 --- a/entry_types/scrolled/package/spec/frontend/useContentElementViewTimelineProgress-spec.js +++ b/entry_types/scrolled/package/spec/frontend/useContentElementViewTimelineProgress-spec.js @@ -4,7 +4,12 @@ import {act} from '@testing-library/react'; import {frontend, Entry, useContentElementViewTimelineProgress} from 'pageflow-scrolled/frontend'; import {renderInEntry} from 'support'; -import {fakeBoundingClientRectsByTestId} from 'support/fakeBoundingClientRects'; +import { + fakeBoundingClientRectsByClassName, + fakeBoundingClientRectsByTestId +} from 'support/fakeBoundingClientRects'; + +import scrollSpaceStyles from 'frontend/ContentElementScrollSpace.module.css'; describe('useContentElementViewTimelineProgress', () => { beforeEach(() => { @@ -13,7 +18,7 @@ describe('useContentElementViewTimelineProgress', () => { afterEach(() => jest.restoreAllMocks()); - function renderTestContentElement({onProgress, range, viewTimeline = true} = {}) { + function renderTestContentElement({onProgress, range, viewTimeline = true, position} = {}) { frontend.contentElementTypes.register('test', { viewTimeline, @@ -24,7 +29,7 @@ describe('useContentElementViewTimelineProgress', () => { }); return renderInEntry(, { - seed: {contentElements: [{typeName: 'test'}]} + seed: {contentElements: [{typeName: 'test', configuration: {position}}]} }); } @@ -129,6 +134,40 @@ describe('useContentElementViewTimelineProgress', () => { expect(onProgress).not.toHaveBeenCalledWith(0.5); }); + describe('for standAlone content elements', () => { + it('measures progress along the scroll space', () => { + const onProgress = jest.fn(); + fakeBoundingClientRectsByClassName({ + [scrollSpaceStyles.wrapper]: {top: 0, height: 1000}, + [scrollSpaceStyles.inner]: {top: 500, height: 500} + }); + + renderTestContentElement({onProgress, position: 'standAlone'}); + + expect(onProgress).toHaveBeenCalledWith(0.5); + }); + + it('keeps measuring progress while element is pinned', () => { + const onProgress = jest.fn(); + fakeBoundingClientRectsByClassName({ + [scrollSpaceStyles.wrapper]: {top: 0, height: 1000}, + [scrollSpaceStyles.inner]: {top: 500, height: 500} + }); + + renderTestContentElement({onProgress, position: 'standAlone'}); + + fakeBoundingClientRectsByClassName({ + [scrollSpaceStyles.wrapper]: {top: -500, height: 1000}, + [scrollSpaceStyles.inner]: {top: 500, height: 500} + }); + act(() => { + window.dispatchEvent(new Event('scroll')); + }); + + expect(onProgress).toHaveBeenLastCalledWith(0.75); + }); + }); + it('throws descriptive error if content element type is missing flag', () => { jest.spyOn(console, 'error').mockImplementation(() => {}); diff --git a/entry_types/scrolled/package/spec/support/fakeBoundingClientRects.js b/entry_types/scrolled/package/spec/support/fakeBoundingClientRects.js index cd6df7b269..e284a2bbe9 100644 --- a/entry_types/scrolled/package/spec/support/fakeBoundingClientRects.js +++ b/entry_types/scrolled/package/spec/support/fakeBoundingClientRects.js @@ -1,3 +1,23 @@ +export function fakeBoundingClientRectsByClassName(rectsByClassName, {otherElements} = {}) { + jest.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function() { + // CSS module class names consist of multiple classes when the + // rule composes others. + const className = Object.keys(rectsByClassName).find( + name => name.split(' ').every(single => this.classList.contains(single)) + ); + + return { + top: 0, + left: 0, + width: 0, + height: 0, + bottom: 0, + right: 0, + ...(className ? rectsByClassName[className] : otherElements) + }; + }); +} + export function fakeBoundingClientRectsByTestId(rectsByTestId) { jest.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function() { const testId = this.getAttribute('data-testid') || diff --git a/entry_types/scrolled/package/src/frontend/ContentElementScrollSpace.js b/entry_types/scrolled/package/src/frontend/ContentElementScrollSpace.js index 9bf7d99554..67e76407f4 100644 --- a/entry_types/scrolled/package/src/frontend/ContentElementScrollSpace.js +++ b/entry_types/scrolled/package/src/frontend/ContentElementScrollSpace.js @@ -1,20 +1,28 @@ -import React from 'react'; +import React, {useCallback, useRef} from 'react'; import Measure from 'react-measure'; +import {ViewTimelineSubjectContext} from './useContentElementViewTimelineProgress'; + import styles from './ContentElementScrollSpace.module.css'; export function ContentElementScrollSpace({children}) { + const ref = useRef(); + + const getViewTimelineSubject = useCallback(() => ref.current, []); + return ( -
- - {({measureRef, contentRect}) => -
- {children} -
- } -
+
+ + + {({measureRef, contentRect}) => +
+ {children} +
+ } +
+
); } diff --git a/entry_types/scrolled/package/src/frontend/useContentElementViewTimelineProgress.js b/entry_types/scrolled/package/src/frontend/useContentElementViewTimelineProgress.js index 0f9d6846b1..6866a4faf3 100644 --- a/entry_types/scrolled/package/src/frontend/useContentElementViewTimelineProgress.js +++ b/entry_types/scrolled/package/src/frontend/useContentElementViewTimelineProgress.js @@ -1,10 +1,19 @@ -import React, {createContext, useContext, useEffect, useMemo, useRef, useState} from 'react'; +import React, {createContext, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; import {api} from './api'; import {getViewTimelineProgress} from './viewTimelineRanges'; export const ContentElementViewTimelineContext = createContext(); +// Lets components that pin content elements in the viewport for part +// of their scroll space pass a function returning the element that +// keeps moving with the page. Pinned content elements would otherwise +// stop making progress along their view timeline for exactly the part +// of the page that the extra scroll space was added for. The subject +// is looked up on demand since it may only be known once the content +// element has been mounted. +export const ViewTimelineSubjectContext = createContext(); + export function ContentElementViewTimelineProvider({type, children}) { const {viewTimeline} = api.contentElementTypes.getOptions(type); @@ -21,7 +30,9 @@ export function ContentElementViewTimelineProvider({type, children}) { } function ViewTimelineProvider({children}) { - const subjectRef = useRef(); + const getOuterSubject = useContext(ViewTimelineSubjectContext); + const ownSubjectRef = useRef(); + const subscriptionsRef = useRef(new Set()); // Content element types can support view timelines without always @@ -29,27 +40,31 @@ function ViewTimelineProvider({children}) { // subscriptions to prevent each of them from adding a handler. const [hasSubscriptions, setHasSubscriptions] = useState(false); + const getSubject = useCallback( + () => getOuterSubject ? getOuterSubject() : ownSubjectRef.current, + [getOuterSubject] + ); + const viewTimeline = useMemo(() => ({ subscribe(range, callback) { const subscription = {range, callback}; subscriptionsRef.current.add(subscription); setHasSubscriptions(true); - update(subjectRef.current, [subscription]); + update(getSubject(), [subscription]); return () => { subscriptionsRef.current.delete(subscription); setHasSubscriptions(subscriptionsRef.current.size > 0); }; } - }), []); + }), [getSubject]); useEffect(() => { if (!hasSubscriptions) { return; } - const subject = subjectRef.current; const subscriptions = subscriptionsRef.current; let animationFrame; @@ -61,7 +76,7 @@ function ViewTimelineProvider({children}) { animationFrame = requestAnimationFrame(() => { animationFrame = null; - update(subject, subscriptions); + update(getSubject(), subscriptions); }); } @@ -74,13 +89,21 @@ function ViewTimelineProvider({children}) { window.removeEventListener('scroll', handle); window.removeEventListener('resize', handle); }; - }, [hasSubscriptions]); + }, [getSubject, hasSubscriptions]); + + const content = ( + + {children} + + ); + + if (getOuterSubject) { + return content; + } return ( -
- - {children} - +
+ {content}
); } From 4e96a66f3b36a704dc93794a6d1f40d5484222f2 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 14 Aug 2026 16:39:15 +0200 Subject: [PATCH 05/14] Measure view timeline progress of sticky elements along their group Content elements with sticky position stay pinned next to the text while the rest of their group scrolls past. Measuring their own bounding rect would make progress stall for exactly that part of the section. The group is the containing block that constrains the sticky box, so its bounding rect covers the same range of the page: from the element scrolling in at the top of the group to it leaving with the group's bottom edge. On narrow viewports, sticky boxes are rendered inline. Progress is then measured along the element itself again. Extract a Box component for the box element on the way, since looking up the group requires a ref. --- .../doc/creating_content_element_types.md | 12 +++-- ...ContentElementViewTimelineProgress-spec.js | 48 +++++++++++++++++++ .../package/src/frontend/layouts/TwoColumn.js | 35 +++++++++++--- 3 files changed, 84 insertions(+), 11 deletions(-) diff --git a/entry_types/scrolled/doc/creating_content_element_types.md b/entry_types/scrolled/doc/creating_content_element_types.md index 30cef41378..1d2dab1eaf 100644 --- a/entry_types/scrolled/doc/creating_content_element_types.md +++ b/entry_types/scrolled/doc/creating_content_element_types.md @@ -255,11 +255,13 @@ useContentElementViewTimelineProgress({ }); ``` -Content elements with `standAlone` position are pinned in the center -of the viewport for part of their scroll space. Their progress is -measured along that scroll space, so it keeps advancing while the -element is pinned. Content elements with `sticky` position, on the -other hand, stop making progress while they are sticky. +Content elements that are pinned in the viewport for part of the page +keep making progress while they stick: For `standAlone` position, +progress is measured along the scroll space added around the element. +For `sticky` position, it is measured along the group of content +elements that scrolls past the element. On narrow viewports, where +sticky elements are rendered inline, progress is measured along the +element itself again. In specs, `renderInContentElement` provides a `simulateScrollProgress` function to invoke the callback: diff --git a/entry_types/scrolled/package/spec/frontend/useContentElementViewTimelineProgress-spec.js b/entry_types/scrolled/package/spec/frontend/useContentElementViewTimelineProgress-spec.js index 2ad23db3d5..40e66f47ed 100644 --- a/entry_types/scrolled/package/spec/frontend/useContentElementViewTimelineProgress-spec.js +++ b/entry_types/scrolled/package/spec/frontend/useContentElementViewTimelineProgress-spec.js @@ -10,6 +10,7 @@ import { } from 'support/fakeBoundingClientRects'; import scrollSpaceStyles from 'frontend/ContentElementScrollSpace.module.css'; +import twoColumnStyles from 'frontend/layouts/TwoColumn.module.css'; describe('useContentElementViewTimelineProgress', () => { beforeEach(() => { @@ -168,6 +169,53 @@ describe('useContentElementViewTimelineProgress', () => { }); }); + describe('for sticky content elements', () => { + it('measures progress along the group the element sticks in', () => { + const onProgress = jest.fn(); + fakeBoundingClientRectsByClassName({ + [twoColumnStyles.group]: {top: 0, height: 1000}, + [twoColumnStyles.sticky]: {top: 500, height: 500} + }); + + renderTestContentElement({onProgress, position: 'sticky'}); + + expect(onProgress).toHaveBeenCalledWith(0.5); + }); + + it('keeps measuring progress while element is sticky', () => { + const onProgress = jest.fn(); + fakeBoundingClientRectsByClassName({ + [twoColumnStyles.group]: {top: 0, height: 1000}, + [twoColumnStyles.sticky]: {top: 500, height: 500} + }); + + renderTestContentElement({onProgress, position: 'sticky'}); + + fakeBoundingClientRectsByClassName({ + [twoColumnStyles.group]: {top: -500, height: 1000}, + [twoColumnStyles.sticky]: {top: 500, height: 500} + }); + act(() => { + window.dispatchEvent(new Event('scroll')); + }); + + expect(onProgress).toHaveBeenLastCalledWith(0.75); + }); + + it('measures progress of element itself if sticky position is inlined', () => { + const onProgress = jest.fn(); + window.matchMedia.mockViewportWidth(500); + fakeBoundingClientRectsByClassName( + {[twoColumnStyles.group]: {top: 0, height: 1000}}, + {otherElements: {top: 500, height: 500}} + ); + + renderTestContentElement({onProgress, position: 'sticky'}); + + expect(onProgress).toHaveBeenCalledWith(1 / 3); + }); + }); + it('throws descriptive error if content element type is missing flag', () => { jest.spyOn(console, 'error').mockImplementation(() => {}); diff --git a/entry_types/scrolled/package/src/frontend/layouts/TwoColumn.js b/entry_types/scrolled/package/src/frontend/layouts/TwoColumn.js index 0e7080350c..0770949a2a 100644 --- a/entry_types/scrolled/package/src/frontend/layouts/TwoColumn.js +++ b/entry_types/scrolled/package/src/frontend/layouts/TwoColumn.js @@ -1,8 +1,9 @@ -import React from 'react'; +import React, {useCallback, useRef} from 'react'; import classNames from 'classnames'; import {api} from '../api'; import {ContentElements} from '../ContentElements'; +import {ViewTimelineSubjectContext} from '../useContentElementViewTimelineProgress'; import useMediaQuery from '../useMediaQuery'; import {useTheme} from 'pageflow-scrolled/entryState'; import {widths, widthName} from './widths'; @@ -68,10 +69,7 @@ function renderItems(props, shouldInline) { function renderItemGroup(props, box, key) { if (box.items.length) { return ( -
+ {props.children( + ); } } +function Box({box, children}) { + const ref = useRef(); + + // Sticky boxes stay pinned while the rest of their group scrolls + // past. The group therefore is the element that drives view + // timelines of content elements inside the box. + const getViewTimelineSubject = useCallback( + () => ref.current.closest(`.${styles.group}`), + [] + ); + + return ( +
+ {box.position === 'sticky' ? + : + children} +
+ ); +} + function restrictWidth(width, alignment, children) { if (width >= 0) { return children; From 6e2bcf49f16f81397b5a071e1e7acd400eae2d2f Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Tue, 18 Aug 2026 11:40:30 +0200 Subject: [PATCH 06/14] Measure view timeline ranges of pinned elements against their height Content elements that are pinned in the viewport are measured along a taller subject: the scroll space for standAlone position, the group for sticky position. Ranges other than cover took the subject's height for the element's height, so contain meant "while the group is completely inside the viewport" instead of "while the element is" - a range that collapses into a jump from 0 to 1 as soon as the group is taller than the viewport. Express ranges as pairs of milestones along the page instead, and let pinning components pass the pinned element next to the subject via a provider component, so the element's height can be taken into account. Elements that overflow their subject never reach their pinned position and keep moving with the page. Fall back to measuring their own rect, which then covers the same range of the page. --- .../doc/creating_content_element_types.md | 12 +++- ...ContentElementViewTimelineProgress-spec.js | 12 ++++ .../spec/frontend/viewTimelineRanges-spec.js | 53 +++++++++++++++- .../src/frontend/ContentElementScrollSpace.js | 14 +++-- .../package/src/frontend/layouts/TwoColumn.js | 31 ++++++---- .../useContentElementViewTimelineProgress.js | 56 ++++++++++------- .../src/frontend/viewTimelineRanges.js | 60 +++++++++++++------ 7 files changed, 180 insertions(+), 58 deletions(-) diff --git a/entry_types/scrolled/doc/creating_content_element_types.md b/entry_types/scrolled/doc/creating_content_element_types.md index 1d2dab1eaf..7d90a9d1d1 100644 --- a/entry_types/scrolled/doc/creating_content_element_types.md +++ b/entry_types/scrolled/doc/creating_content_element_types.md @@ -259,9 +259,15 @@ Content elements that are pinned in the viewport for part of the page keep making progress while they stick: For `standAlone` position, progress is measured along the scroll space added around the element. For `sticky` position, it is measured along the group of content -elements that scrolls past the element. On narrow viewports, where -sticky elements are rendered inline, progress is measured along the -element itself again. +elements that scrolls past the element. Ranges still refer to the +element itself, so `contain` covers the page from the element being +completely inside the viewport to it starting to leave again, no matter +how long it stays pinned in between. + +Progress is measured along the element itself again whenever it is not +actually pinned: On narrow viewports, where sticky elements are +rendered inline, and if there is not enough content next to a sticky +element for it to ever reach its sticky position. In specs, `renderInContentElement` provides a `simulateScrollProgress` function to invoke the callback: diff --git a/entry_types/scrolled/package/spec/frontend/useContentElementViewTimelineProgress-spec.js b/entry_types/scrolled/package/spec/frontend/useContentElementViewTimelineProgress-spec.js index 40e66f47ed..b5ef3a02f2 100644 --- a/entry_types/scrolled/package/spec/frontend/useContentElementViewTimelineProgress-spec.js +++ b/entry_types/scrolled/package/spec/frontend/useContentElementViewTimelineProgress-spec.js @@ -202,6 +202,18 @@ describe('useContentElementViewTimelineProgress', () => { expect(onProgress).toHaveBeenLastCalledWith(0.75); }); + it('measures ranges relative to the height of the element', () => { + const onProgress = jest.fn(); + fakeBoundingClientRectsByClassName({ + [twoColumnStyles.group]: {top: 0, height: 2000}, + [twoColumnStyles.sticky]: {top: 0, height: 500} + }); + + renderTestContentElement({onProgress, range: 'contain', position: 'sticky'}); + + expect(onProgress).toHaveBeenCalledWith(0.25); + }); + it('measures progress of element itself if sticky position is inlined', () => { const onProgress = jest.fn(); window.matchMedia.mockViewportWidth(500); diff --git a/entry_types/scrolled/package/spec/frontend/viewTimelineRanges-spec.js b/entry_types/scrolled/package/spec/frontend/viewTimelineRanges-spec.js index 5abe308f81..b878fb6545 100644 --- a/entry_types/scrolled/package/spec/frontend/viewTimelineRanges-spec.js +++ b/entry_types/scrolled/package/spec/frontend/viewTimelineRanges-spec.js @@ -4,7 +4,8 @@ describe('getViewTimelineProgress', () => { function progress({range = 'cover', top, height = 500, viewportHeight = 1000}) { return getViewTimelineProgress({ range, - rect: {top, height}, + subjectRect: {top, height}, + elementRect: {top, height}, viewportHeight }); } @@ -99,6 +100,56 @@ describe('getViewTimelineProgress', () => { expect(progress({range: 'contain', top: 0, height: 1000})).toEqual(1); }); + describe('for element pinned along a taller subject', () => { + function pinnedElementProgress({range = 'cover', + subjectTop, + subjectHeight = 2000, + elementTop = 0, + elementHeight = 500, + viewportHeight = 1000}) { + return getViewTimelineProgress({ + range, + subjectRect: {top: subjectTop, height: subjectHeight}, + elementRect: {top: elementTop, height: elementHeight}, + viewportHeight + }); + } + + it('measures cover range along the subject', () => { + expect(pinnedElementProgress({subjectTop: 1000})).toEqual(0); + expect(pinnedElementProgress({subjectTop: -500})).toEqual(0.5); + expect(pinnedElementProgress({subjectTop: -2000})).toEqual(1); + }); + + it('starts contain range once element is completely inside viewport', () => { + expect(pinnedElementProgress({range: 'contain', subjectTop: 500})).toEqual(0); + }); + + it('ends contain range once element starts leaving viewport', () => { + expect(pinnedElementProgress({range: 'contain', subjectTop: -1500})).toEqual(1); + }); + + it('keeps advancing contain range while element is pinned', () => { + expect(pinnedElementProgress({range: 'contain', subjectTop: -500})).toEqual(0.5); + }); + + it('ends entry range once element is completely inside viewport', () => { + expect(pinnedElementProgress({range: 'entry', subjectTop: 750})).toEqual(0.5); + expect(pinnedElementProgress({range: 'entry', subjectTop: 500})).toEqual(1); + }); + + it('starts exit range once element starts leaving viewport', () => { + expect(pinnedElementProgress({range: 'exit', subjectTop: -1500})).toEqual(0); + expect(pinnedElementProgress({range: 'exit', subjectTop: -1750})).toEqual(0.5); + }); + + it('measures along element if subject is not taller than element', () => { + expect(pinnedElementProgress({ + range: 'contain', subjectTop: 250, subjectHeight: 300, elementTop: 250 + })).toEqual(0.5); + }); + }); + it('throws descriptive error for unknown range', () => { expect(() => progress({range: 'crossing', top: 0})) .toThrow(/Unknown view timeline range 'crossing'/); diff --git a/entry_types/scrolled/package/src/frontend/ContentElementScrollSpace.js b/entry_types/scrolled/package/src/frontend/ContentElementScrollSpace.js index 67e76407f4..774308b487 100644 --- a/entry_types/scrolled/package/src/frontend/ContentElementScrollSpace.js +++ b/entry_types/scrolled/package/src/frontend/ContentElementScrollSpace.js @@ -1,19 +1,23 @@ import React, {useCallback, useRef} from 'react'; import Measure from 'react-measure'; -import {ViewTimelineSubjectContext} from './useContentElementViewTimelineProgress'; +import {ViewTimelinePinProvider} from './useContentElementViewTimelineProgress'; import styles from './ContentElementScrollSpace.module.css'; export function ContentElementScrollSpace({children}) { const ref = useRef(); + const innerRef = useRef(); - const getViewTimelineSubject = useCallback(() => ref.current, []); + const getPinnedElements = useCallback( + () => ({subject: ref.current, element: innerRef.current}), + [] + ); return (
- - + + {({measureRef, contentRect}) =>
} - +
); } diff --git a/entry_types/scrolled/package/src/frontend/layouts/TwoColumn.js b/entry_types/scrolled/package/src/frontend/layouts/TwoColumn.js index 0770949a2a..d3337b56be 100644 --- a/entry_types/scrolled/package/src/frontend/layouts/TwoColumn.js +++ b/entry_types/scrolled/package/src/frontend/layouts/TwoColumn.js @@ -3,7 +3,7 @@ import classNames from 'classnames'; import {api} from '../api'; import {ContentElements} from '../ContentElements'; -import {ViewTimelineSubjectContext} from '../useContentElementViewTimelineProgress'; +import {ViewTimelinePinProvider} from '../useContentElementViewTimelineProgress'; import useMediaQuery from '../useMediaQuery'; import {useTheme} from 'pageflow-scrolled/entryState'; import {widths, widthName} from './widths'; @@ -97,14 +97,6 @@ function renderItemGroup(props, box, key) { function Box({box, children}) { const ref = useRef(); - // Sticky boxes stay pinned while the rest of their group scrolls - // past. The group therefore is the element that drives view - // timelines of content elements inside the box. - const getViewTimelineSubject = useCallback( - () => ref.current.closest(`.${styles.group}`), - [] - ); - return (
{box.position === 'sticky' ? - : + : children}
); } +// Sticky boxes stay pinned while the rest of their group scrolls past. +// The group therefore is the subject that drives view timelines of +// content elements inside the box. +function ViewTimelinePin({boxRef, children}) { + const getPinnedElements = useCallback( + () => ({ + subject: boxRef.current.closest(`.${styles.group}`), + element: boxRef.current + }), + [boxRef] + ); + + return ( + + ); +} + function restrictWidth(width, alignment, children) { if (width >= 0) { return children; diff --git a/entry_types/scrolled/package/src/frontend/useContentElementViewTimelineProgress.js b/entry_types/scrolled/package/src/frontend/useContentElementViewTimelineProgress.js index 6866a4faf3..206c7ed025 100644 --- a/entry_types/scrolled/package/src/frontend/useContentElementViewTimelineProgress.js +++ b/entry_types/scrolled/package/src/frontend/useContentElementViewTimelineProgress.js @@ -6,13 +6,23 @@ import {getViewTimelineProgress} from './viewTimelineRanges'; export const ContentElementViewTimelineContext = createContext(); // Lets components that pin content elements in the viewport for part -// of their scroll space pass a function returning the element that -// keeps moving with the page. Pinned content elements would otherwise -// stop making progress along their view timeline for exactly the part -// of the page that the extra scroll space was added for. The subject -// is looked up on demand since it may only be known once the content -// element has been mounted. -export const ViewTimelineSubjectContext = createContext(); +// of their scroll space pass a function returning the pinned element +// and the subject that keeps moving with the page. Pinned content +// elements would otherwise stop making progress along their view +// timeline for exactly the part of the page that the extra scroll +// space was added for. +// +// Passing a function instead of refs lets components resolve elements +// they do not render themselves: Sticky boxes in TwoColumn walk up the +// DOM to find their group. +const ViewTimelinePinContext = createContext(); + +export function ViewTimelinePinProvider({getPinnedElements, children}) { + return ( + + ); +} export function ContentElementViewTimelineProvider({type, children}) { const {viewTimeline} = api.contentElementTypes.getOptions(type); @@ -30,8 +40,8 @@ export function ContentElementViewTimelineProvider({type, children}) { } function ViewTimelineProvider({children}) { - const getOuterSubject = useContext(ViewTimelineSubjectContext); - const ownSubjectRef = useRef(); + const getPinnedElements = useContext(ViewTimelinePinContext); + const ownElementRef = useRef(); const subscriptionsRef = useRef(new Set()); @@ -40,9 +50,11 @@ function ViewTimelineProvider({children}) { // subscriptions to prevent each of them from adding a handler. const [hasSubscriptions, setHasSubscriptions] = useState(false); - const getSubject = useCallback( - () => getOuterSubject ? getOuterSubject() : ownSubjectRef.current, - [getOuterSubject] + const getElements = useCallback( + () => getPinnedElements ? + getPinnedElements() : + {subject: ownElementRef.current, element: ownElementRef.current}, + [getPinnedElements] ); const viewTimeline = useMemo(() => ({ @@ -51,14 +63,14 @@ function ViewTimelineProvider({children}) { subscriptionsRef.current.add(subscription); setHasSubscriptions(true); - update(getSubject(), [subscription]); + update(getElements(), [subscription]); return () => { subscriptionsRef.current.delete(subscription); setHasSubscriptions(subscriptionsRef.current.size > 0); }; } - }), [getSubject]); + }), [getElements]); useEffect(() => { if (!hasSubscriptions) { @@ -76,7 +88,7 @@ function ViewTimelineProvider({children}) { animationFrame = requestAnimationFrame(() => { animationFrame = null; - update(getSubject(), subscriptions); + update(getElements(), subscriptions); }); } @@ -89,7 +101,7 @@ function ViewTimelineProvider({children}) { window.removeEventListener('scroll', handle); window.removeEventListener('resize', handle); }; - }, [getSubject, hasSubscriptions]); + }, [getElements, hasSubscriptions]); const content = ( @@ -97,25 +109,27 @@ function ViewTimelineProvider({children}) { ); - if (getOuterSubject) { + if (getPinnedElements) { return content; } return ( -
+
{content}
); } -function update(subject, subscriptions) { - const rect = subject.getBoundingClientRect(); +function update({subject, element}, subscriptions) { + const subjectRect = subject.getBoundingClientRect(); + const elementRect = element === subject ? subjectRect : element.getBoundingClientRect(); const viewportHeight = window.innerHeight; subscriptions.forEach(subscription => { const progress = getViewTimelineProgress({ range: subscription.range, - rect, + subjectRect, + elementRect, viewportHeight }); diff --git a/entry_types/scrolled/package/src/frontend/viewTimelineRanges.js b/entry_types/scrolled/package/src/frontend/viewTimelineRanges.js index 2a1786e814..74d84f1805 100644 --- a/entry_types/scrolled/package/src/frontend/viewTimelineRanges.js +++ b/entry_types/scrolled/package/src/frontend/viewTimelineRanges.js @@ -1,34 +1,60 @@ // Offsets of the subject's top edge relative to the viewport's top -// edge at the start and end of each range. Mirrors the named ranges of -// CSS scroll driven animations. -const rangeEdges = { - cover: (subjectHeight, viewportHeight) => [viewportHeight, -subjectHeight], +// edge at the milestones of a content element's view timeline. +// +// Elements that are pinned in the viewport for part of their scroll +// space are measured along a taller subject: They move with the +// subject's top edge until they are pinned and continue with the +// subject's bottom edge once they have been. +const milestones = { + firstVisible: ({viewportHeight}) => viewportHeight, - contain: (subjectHeight, viewportHeight) => [ - Math.max(viewportHeight - subjectHeight, 0), - Math.min(viewportHeight - subjectHeight, 0) - ], + firstContained: ({viewportHeight, elementHeight}) => viewportHeight - elementHeight, - entry: (subjectHeight, viewportHeight) => [viewportHeight, viewportHeight - subjectHeight], + lastContained: ({subjectHeight, elementHeight}) => elementHeight - subjectHeight, - exit: (subjectHeight, viewportHeight) => [0, -subjectHeight] + lastVisible: ({subjectHeight}) => -subjectHeight }; -export function getViewTimelineProgress({range, rect, viewportHeight}) { - const edges = rangeEdges[range]; +// Mirrors the named ranges of CSS scroll driven animations. +const ranges = { + cover: ['firstVisible', 'lastVisible'], + contain: ['firstContained', 'lastContained'], + entry: ['firstVisible', 'firstContained'], + exit: ['lastContained', 'lastVisible'] +}; + +export function getViewTimelineProgress({range, subjectRect, elementRect, viewportHeight}) { + const milestoneNames = ranges[range]; - if (!edges) { + if (!milestoneNames) { throw new Error(`Unknown view timeline range '${range}'. ` + - `Supported ranges: ${Object.keys(rangeEdges).join(', ')}.`); + `Supported ranges: ${Object.keys(ranges).join(', ')}.`); } - const [start, end] = edges(rect.height, viewportHeight); + // Without enough content next to it, a pinned element never reaches + // its pinned position and keeps moving with the page. Its own rect + // then is the subject covering the same range of the page. + const subject = subjectRect.height > elementRect.height ? subjectRect : elementRect; + + const [start, end] = orderEdges(milestoneNames.map(name => milestones[name]({ + subjectHeight: subject.height, + elementHeight: elementRect.height, + viewportHeight + }))); if (start === end) { - return rect.top <= start ? 1 : 0; + return subject.top <= start ? 1 : 0; } - return clamp((start - rect.top) / (start - end)); + return clamp((start - subject.top) / (start - end)); +} + +// Milestones come out in reverse order for elements taller than the +// viewport: Such elements cover the viewport instead of being +// contained in it, so they stop being contained before they start +// being contained. +function orderEdges([start, end]) { + return start < end ? [end, start] : [start, end]; } function clamp(value) { From a747a58ee5c9ca9d547b2b7d90cdd1376842fd22 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Tue, 18 Aug 2026 11:43:37 +0200 Subject: [PATCH 07/14] Add center view timeline range Content elements count as active while they intersect the vertical center of the viewport. Autoplayed videos start playing at that point. Add a range covering the same part of the page, so scroll coupled animations can run exactly while the element holds the center of attention. Also correct the description of isActive in the docs, which claimed elements are active while completely inside the viewport. --- .../doc/creating_content_element_types.md | 12 ++++++-- .../spec/frontend/viewTimelineRanges-spec.js | 28 +++++++++++++++++++ .../useContentElementViewTimelineProgress.js | 4 +++ .../src/frontend/viewTimelineRanges.js | 12 ++++++-- 4 files changed, 51 insertions(+), 5 deletions(-) diff --git a/entry_types/scrolled/doc/creating_content_element_types.md b/entry_types/scrolled/doc/creating_content_element_types.md index 7d90a9d1d1..470046d6bc 100644 --- a/entry_types/scrolled/doc/creating_content_element_types.md +++ b/entry_types/scrolled/doc/creating_content_element_types.md @@ -120,9 +120,9 @@ registering the content element type. it to start media playback that should remain active even when the element is not fully centered. -* `isActive` is true if the content element is completely in the - viewport. Use it to activate some interactive behavior like an - animation or media playback. +* `isActive` is true if the content element intersects the vertical + center of the viewport. Use it to activate some interactive behavior + like an animation or media playback. * `inForeground` is true when the storyline containing the content element is active (not in background mode). Use it to distinguish @@ -221,6 +221,12 @@ The `range` option determines which part of the timeline to measure: * `exit`: While the content element is leaving the viewport. +* `center`: While the content element intersects the vertical center of + the viewport, i.e. from its top edge passing the center until its + bottom edge does. This is the same part of the page during which the + content element counts as active (see [Content Element + Lifecycle](#content-element-lifecycle)) and autoplayed videos play. + Progress is passed to the `onProgress` callback as a number between 0 and 1 instead of being returned by the hook. This prevents rerendering the content element on every scroll frame. Use it to drive imperative diff --git a/entry_types/scrolled/package/spec/frontend/viewTimelineRanges-spec.js b/entry_types/scrolled/package/spec/frontend/viewTimelineRanges-spec.js index b878fb6545..a34bea36f7 100644 --- a/entry_types/scrolled/package/spec/frontend/viewTimelineRanges-spec.js +++ b/entry_types/scrolled/package/spec/frontend/viewTimelineRanges-spec.js @@ -68,6 +68,28 @@ describe('getViewTimelineProgress', () => { }); }); + describe('center range', () => { + it('is 0 while subject is about to reach center of viewport', () => { + expect(progress({range: 'center', top: 500})).toEqual(0); + }); + + it('is 1 once subject has completely passed center of viewport', () => { + expect(progress({range: 'center', top: 0})).toEqual(1); + }); + + it('is 0.5 while subject is centered in viewport', () => { + expect(progress({range: 'center', top: 250})).toEqual(0.5); + }); + + it('is clamped below center of viewport', () => { + expect(progress({range: 'center', top: 1000})).toEqual(0); + }); + + it('is clamped above center of viewport', () => { + expect(progress({range: 'center', top: -500})).toEqual(1); + }); + }); + describe('contain range for subject smaller than viewport', () => { it('is 0 once subject is completely inside viewport', () => { expect(progress({range: 'contain', top: 500})).toEqual(0); @@ -143,6 +165,12 @@ describe('getViewTimelineProgress', () => { expect(pinnedElementProgress({range: 'exit', subjectTop: -1750})).toEqual(0.5); }); + it('measures center range along the subject', () => { + expect(pinnedElementProgress({range: 'center', subjectTop: 500})).toEqual(0); + expect(pinnedElementProgress({range: 'center', subjectTop: -500})).toEqual(0.5); + expect(pinnedElementProgress({range: 'center', subjectTop: -1500})).toEqual(1); + }); + it('measures along element if subject is not taller than element', () => { expect(pinnedElementProgress({ range: 'contain', subjectTop: 250, subjectHeight: 300, elementTop: 250 diff --git a/entry_types/scrolled/package/src/frontend/useContentElementViewTimelineProgress.js b/entry_types/scrolled/package/src/frontend/useContentElementViewTimelineProgress.js index 206c7ed025..13768474a5 100644 --- a/entry_types/scrolled/package/src/frontend/useContentElementViewTimelineProgress.js +++ b/entry_types/scrolled/package/src/frontend/useContentElementViewTimelineProgress.js @@ -168,6 +168,10 @@ function update({subject, element}, subscriptions) { * * * `exit`: While the content element is leaving the viewport. * + * * `center`: While the content element intersects the vertical + * center of the viewport, i.e. from its top edge passing the + * center until its bottom edge does. + * * @param {Function} [options.onProgress] - * Invoked with a number between 0 and 1 whenever progress along the * range changes. Pass a falsy value to not observe scroll position diff --git a/entry_types/scrolled/package/src/frontend/viewTimelineRanges.js b/entry_types/scrolled/package/src/frontend/viewTimelineRanges.js index 74d84f1805..3033ed1134 100644 --- a/entry_types/scrolled/package/src/frontend/viewTimelineRanges.js +++ b/entry_types/scrolled/package/src/frontend/viewTimelineRanges.js @@ -10,17 +10,25 @@ const milestones = { firstContained: ({viewportHeight, elementHeight}) => viewportHeight - elementHeight, + reachesCenter: ({viewportHeight}) => viewportHeight / 2, + + leavesCenter: ({subjectHeight, viewportHeight}) => viewportHeight / 2 - subjectHeight, + lastContained: ({subjectHeight, elementHeight}) => elementHeight - subjectHeight, lastVisible: ({subjectHeight}) => -subjectHeight }; -// Mirrors the named ranges of CSS scroll driven animations. const ranges = { + // Mirror the named ranges of CSS scroll driven animations. cover: ['firstVisible', 'lastVisible'], contain: ['firstContained', 'lastContained'], entry: ['firstVisible', 'firstContained'], - exit: ['lastContained', 'lastVisible'] + exit: ['lastContained', 'lastVisible'], + + // Same part of the page during which content elements become active + // and autoplayed videos play. + center: ['reachesCenter', 'leavesCenter'] }; export function getViewTimelineProgress({range, subjectRect, elementRect, viewportHeight}) { From fb54632bbe0afb6369fcc83921b372a1b882b8f8 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Tue, 18 Aug 2026 11:47:00 +0200 Subject: [PATCH 08/14] Add pinned view timeline range Content elements with sticky or standAlone position are pinned in the viewport for part of their scroll space. Add a range measuring exactly that part of the page, so scroll coupled animations can run while the element holds its featured spot. The position an element is pinned at is only known while it actually is pinned. Measure how far the element has slid within its subject instead: That distance grows from zero to the subject's extra scroll space while the element is pinned and stays constant before and after, which clamps progress on both ends. Elements that are not pinned at all have no such phase. Progress along the range stays 1 for them. --- .../doc/creating_content_element_types.md | 5 +++ ...ContentElementViewTimelineProgress-spec.js | 12 ++++++ .../spec/frontend/viewTimelineRanges-spec.js | 38 +++++++++++++++++++ .../useContentElementViewTimelineProgress.js | 5 +++ .../src/frontend/viewTimelineRanges.js | 20 +++++++++- 5 files changed, 78 insertions(+), 2 deletions(-) diff --git a/entry_types/scrolled/doc/creating_content_element_types.md b/entry_types/scrolled/doc/creating_content_element_types.md index 470046d6bc..63d748b311 100644 --- a/entry_types/scrolled/doc/creating_content_element_types.md +++ b/entry_types/scrolled/doc/creating_content_element_types.md @@ -227,6 +227,11 @@ The `range` option determines which part of the timeline to measure: content element counts as active (see [Content Element Lifecycle](#content-element-lifecycle)) and autoplayed videos play. +* `pinned`: While the content element stays pinned in the viewport, + i.e. from the moment it reaches the position it is pinned at until it + starts moving with the page again. Progress stays 1 for content + elements that are not pinned at all (see below). + Progress is passed to the `onProgress` callback as a number between 0 and 1 instead of being returned by the hook. This prevents rerendering the content element on every scroll frame. Use it to drive imperative diff --git a/entry_types/scrolled/package/spec/frontend/useContentElementViewTimelineProgress-spec.js b/entry_types/scrolled/package/spec/frontend/useContentElementViewTimelineProgress-spec.js index b5ef3a02f2..31cb340bde 100644 --- a/entry_types/scrolled/package/spec/frontend/useContentElementViewTimelineProgress-spec.js +++ b/entry_types/scrolled/package/spec/frontend/useContentElementViewTimelineProgress-spec.js @@ -214,6 +214,18 @@ describe('useContentElementViewTimelineProgress', () => { expect(onProgress).toHaveBeenCalledWith(0.25); }); + it('measures pinned range while element is sticky', () => { + const onProgress = jest.fn(); + fakeBoundingClientRectsByClassName({ + [twoColumnStyles.group]: {top: -450, height: 2000}, + [twoColumnStyles.sticky]: {top: 300, height: 500} + }); + + renderTestContentElement({onProgress, range: 'pinned', position: 'sticky'}); + + expect(onProgress).toHaveBeenCalledWith(0.5); + }); + it('measures progress of element itself if sticky position is inlined', () => { const onProgress = jest.fn(); window.matchMedia.mockViewportWidth(500); diff --git a/entry_types/scrolled/package/spec/frontend/viewTimelineRanges-spec.js b/entry_types/scrolled/package/spec/frontend/viewTimelineRanges-spec.js index a34bea36f7..900114c5bf 100644 --- a/entry_types/scrolled/package/spec/frontend/viewTimelineRanges-spec.js +++ b/entry_types/scrolled/package/spec/frontend/viewTimelineRanges-spec.js @@ -171,6 +171,44 @@ describe('getViewTimelineProgress', () => { expect(pinnedElementProgress({range: 'center', subjectTop: -1500})).toEqual(1); }); + describe('pinned range', () => { + it('is 0 before the element has reached its pinned position', () => { + expect(pinnedElementProgress({ + range: 'pinned', subjectTop: 500, elementTop: 500 + })).toEqual(0); + }); + + it('is 0 once the element reaches its pinned position', () => { + expect(pinnedElementProgress({ + range: 'pinned', subjectTop: 300, elementTop: 300 + })).toEqual(0); + }); + + it('is 0.5 halfway through the pinned phase', () => { + expect(pinnedElementProgress({ + range: 'pinned', subjectTop: -450, elementTop: 300 + })).toEqual(0.5); + }); + + it('is 1 once the element leaves its pinned position', () => { + expect(pinnedElementProgress({ + range: 'pinned', subjectTop: -1200, elementTop: 300 + })).toEqual(1); + }); + + it('is 1 after the element has left its pinned position', () => { + expect(pinnedElementProgress({ + range: 'pinned', subjectTop: -1500, elementTop: 0 + })).toEqual(1); + }); + + it('stays 1 for elements that never reach a pinned position', () => { + expect(pinnedElementProgress({ + range: 'pinned', subjectTop: 250, subjectHeight: 300, elementTop: 250 + })).toEqual(1); + }); + }); + it('measures along element if subject is not taller than element', () => { expect(pinnedElementProgress({ range: 'contain', subjectTop: 250, subjectHeight: 300, elementTop: 250 diff --git a/entry_types/scrolled/package/src/frontend/useContentElementViewTimelineProgress.js b/entry_types/scrolled/package/src/frontend/useContentElementViewTimelineProgress.js index 13768474a5..af4815e09b 100644 --- a/entry_types/scrolled/package/src/frontend/useContentElementViewTimelineProgress.js +++ b/entry_types/scrolled/package/src/frontend/useContentElementViewTimelineProgress.js @@ -172,6 +172,11 @@ function update({subject, element}, subscriptions) { * center of the viewport, i.e. from its top edge passing the * center until its bottom edge does. * + * * `pinned`: While the content element stays pinned in the + * viewport, i.e. from the moment it reaches the position it is + * pinned at until it starts moving with the page again. Progress + * stays 1 for content elements that are not pinned at all. + * * @param {Function} [options.onProgress] - * Invoked with a number between 0 and 1 whenever progress along the * range changes. Pass a falsy value to not observe scroll position diff --git a/entry_types/scrolled/package/src/frontend/viewTimelineRanges.js b/entry_types/scrolled/package/src/frontend/viewTimelineRanges.js index 3033ed1134..af3addce93 100644 --- a/entry_types/scrolled/package/src/frontend/viewTimelineRanges.js +++ b/entry_types/scrolled/package/src/frontend/viewTimelineRanges.js @@ -16,7 +16,18 @@ const milestones = { lastContained: ({subjectHeight, elementHeight}) => elementHeight - subjectHeight, - lastVisible: ({subjectHeight}) => -subjectHeight + lastVisible: ({subjectHeight}) => -subjectHeight, + + // The pinned position is only known while the element actually is + // pinned. That is exactly when progress along the pinned range is + // between 0 and 1, though: Before, the element's top edge coincides + // with the subject's, after, its bottom edge does. Both edges of the + // range therefore come out equally far off in those phases, which + // makes progress clamp to 0 respectively 1. + reachesPinnedPosition: ({elementTop}) => elementTop, + + leavesPinnedPosition: ({elementTop, subjectHeight, elementHeight}) => + elementTop - subjectHeight + elementHeight }; const ranges = { @@ -28,7 +39,11 @@ const ranges = { // Same part of the page during which content elements become active // and autoplayed videos play. - center: ['reachesCenter', 'leavesCenter'] + center: ['reachesCenter', 'leavesCenter'], + + // Only elements that components like TwoColumn or + // ContentElementScrollSpace pin in the viewport have a pinned phase. + pinned: ['reachesPinnedPosition', 'leavesPinnedPosition'] }; export function getViewTimelineProgress({range, subjectRect, elementRect, viewportHeight}) { @@ -46,6 +61,7 @@ export function getViewTimelineProgress({range, subjectRect, elementRect, viewpo const [start, end] = orderEdges(milestoneNames.map(name => milestones[name]({ subjectHeight: subject.height, + elementTop: elementRect.top, elementHeight: elementRect.height, viewportHeight }))); From 0e138913517bdddd394539a9eb5020262e3c90ec Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Tue, 18 Aug 2026 12:52:36 +0200 Subject: [PATCH 09/14] Add inFocus view timeline range Which part of the page a content element holds the reader's attention for depends on its position: Elements that are pinned in the viewport do so while they stay in place, all others while they pass the center of the viewport. Add a range that resolves to the pinned or the center range accordingly, so content elements do not have to know whether the layout pins them - which can change with the viewport width. --- .../doc/creating_content_element_types.md | 9 ++++++++- ...eContentElementViewTimelineProgress-spec.js | 12 ++++++++++++ .../spec/frontend/viewTimelineRanges-spec.js | 16 +++++++++++++++- .../useContentElementViewTimelineProgress.js | 4 ++++ .../package/src/frontend/viewTimelineRanges.js | 18 +++++++++++++++--- 5 files changed, 54 insertions(+), 5 deletions(-) diff --git a/entry_types/scrolled/doc/creating_content_element_types.md b/entry_types/scrolled/doc/creating_content_element_types.md index 63d748b311..385b740a60 100644 --- a/entry_types/scrolled/doc/creating_content_element_types.md +++ b/entry_types/scrolled/doc/creating_content_element_types.md @@ -232,6 +232,11 @@ The `range` option determines which part of the timeline to measure: starts moving with the page again. Progress stays 1 for content elements that are not pinned at all (see below). +* `inFocus`: While the content element holds the reader's attention: + `pinned` for content elements that are pinned in the viewport, + `center` for all others. Which of the two applies can change with the + viewport width. + Progress is passed to the `onProgress` callback as a number between 0 and 1 instead of being returned by the hook. This prevents rerendering the content element on every scroll frame. Use it to drive imperative @@ -278,7 +283,9 @@ how long it stays pinned in between. Progress is measured along the element itself again whenever it is not actually pinned: On narrow viewports, where sticky elements are rendered inline, and if there is not enough content next to a sticky -element for it to ever reach its sticky position. +element for it to ever reach its sticky position. The `inFocus` range +therefore measures the same part of the page as `center` for those +elements. In specs, `renderInContentElement` provides a `simulateScrollProgress` function to invoke the callback: diff --git a/entry_types/scrolled/package/spec/frontend/useContentElementViewTimelineProgress-spec.js b/entry_types/scrolled/package/spec/frontend/useContentElementViewTimelineProgress-spec.js index 31cb340bde..e0e8a899c7 100644 --- a/entry_types/scrolled/package/spec/frontend/useContentElementViewTimelineProgress-spec.js +++ b/entry_types/scrolled/package/spec/frontend/useContentElementViewTimelineProgress-spec.js @@ -226,6 +226,18 @@ describe('useContentElementViewTimelineProgress', () => { expect(onProgress).toHaveBeenCalledWith(0.5); }); + it('measures center range for inFocus range if element cannot become sticky', () => { + const onProgress = jest.fn(); + fakeBoundingClientRectsByClassName({ + [twoColumnStyles.group]: {top: 250, height: 300}, + [twoColumnStyles.sticky]: {top: 250, height: 500} + }); + + renderTestContentElement({onProgress, range: 'inFocus', position: 'sticky'}); + + expect(onProgress).toHaveBeenCalledWith(0.5); + }); + it('measures progress of element itself if sticky position is inlined', () => { const onProgress = jest.fn(); window.matchMedia.mockViewportWidth(500); diff --git a/entry_types/scrolled/package/spec/frontend/viewTimelineRanges-spec.js b/entry_types/scrolled/package/spec/frontend/viewTimelineRanges-spec.js index 900114c5bf..fbda4d0734 100644 --- a/entry_types/scrolled/package/spec/frontend/viewTimelineRanges-spec.js +++ b/entry_types/scrolled/package/spec/frontend/viewTimelineRanges-spec.js @@ -209,6 +209,20 @@ describe('getViewTimelineProgress', () => { }); }); + describe('inFocus range', () => { + it('measures pinned range while element has a pinned phase', () => { + expect(pinnedElementProgress({ + range: 'inFocus', subjectTop: -150, elementTop: 300 + })).toEqual(0.3); + }); + + it('measures center range if element is never pinned', () => { + expect(pinnedElementProgress({ + range: 'inFocus', subjectTop: 250, subjectHeight: 300, elementTop: 250 + })).toEqual(0.5); + }); + }); + it('measures along element if subject is not taller than element', () => { expect(pinnedElementProgress({ range: 'contain', subjectTop: 250, subjectHeight: 300, elementTop: 250 @@ -218,6 +232,6 @@ describe('getViewTimelineProgress', () => { it('throws descriptive error for unknown range', () => { expect(() => progress({range: 'crossing', top: 0})) - .toThrow(/Unknown view timeline range 'crossing'/); + .toThrow(/Unknown view timeline range 'crossing'.*inFocus/); }); }); diff --git a/entry_types/scrolled/package/src/frontend/useContentElementViewTimelineProgress.js b/entry_types/scrolled/package/src/frontend/useContentElementViewTimelineProgress.js index af4815e09b..9d9e66f837 100644 --- a/entry_types/scrolled/package/src/frontend/useContentElementViewTimelineProgress.js +++ b/entry_types/scrolled/package/src/frontend/useContentElementViewTimelineProgress.js @@ -177,6 +177,10 @@ function update({subject, element}, subscriptions) { * pinned at until it starts moving with the page again. Progress * stays 1 for content elements that are not pinned at all. * + * * `inFocus`: While the content element holds the reader's + * attention: `pinned` for content elements that are pinned in the + * viewport, `center` for all others. + * * @param {Function} [options.onProgress] - * Invoked with a number between 0 and 1 whenever progress along the * range changes. Pass a falsy value to not observe scroll position diff --git a/entry_types/scrolled/package/src/frontend/viewTimelineRanges.js b/entry_types/scrolled/package/src/frontend/viewTimelineRanges.js index af3addce93..b9d2c4dabd 100644 --- a/entry_types/scrolled/package/src/frontend/viewTimelineRanges.js +++ b/entry_types/scrolled/package/src/frontend/viewTimelineRanges.js @@ -46,18 +46,30 @@ const ranges = { pinned: ['reachesPinnedPosition', 'leavesPinnedPosition'] }; +const rangeAliases = { + // Elements that are pinned in the viewport hold the reader's + // attention while they stay in place. Elements that are not pinned + // at all do so while they pass the center of the viewport. + inFocus: hasPinnedPhase => hasPinnedPhase ? 'pinned' : 'center' +}; + export function getViewTimelineProgress({range, subjectRect, elementRect, viewportHeight}) { - const milestoneNames = ranges[range]; + const hasPinnedPhase = subjectRect.height > elementRect.height; + const alias = rangeAliases[range]; + + const milestoneNames = ranges[alias ? alias(hasPinnedPhase) : range]; if (!milestoneNames) { + const supportedRanges = [...Object.keys(ranges), ...Object.keys(rangeAliases)]; + throw new Error(`Unknown view timeline range '${range}'. ` + - `Supported ranges: ${Object.keys(ranges).join(', ')}.`); + `Supported ranges: ${supportedRanges.join(', ')}.`); } // Without enough content next to it, a pinned element never reaches // its pinned position and keeps moving with the page. Its own rect // then is the subject covering the same range of the page. - const subject = subjectRect.height > elementRect.height ? subjectRect : elementRect; + const subject = hasPinnedPhase ? subjectRect : elementRect; const [start, end] = orderEdges(milestoneNames.map(name => milestones[name]({ subjectHeight: subject.height, From ec7e47a595d2048cf716e497d795412e08b52dd1 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Tue, 18 Aug 2026 11:52:04 +0200 Subject: [PATCH 10/14] Let lottie animations choose their scroll range Scroll coupled animations so far always ran along the whole part of the page during which the element was visible. Add a select that lets editors pick which part of the element's scroll motion drives the animation, including the phase during which sticky and standAlone elements stay in place. The inFocus range means different things depending on position, so name it after the pinned phase for positions that keep the element in place and after the viewport center for all others. Since the texts of a select cannot depend on other attributes, there are two inputs for the same property toggled via visible bindings. Which of them applies follows the resolved position, since layouts that do not support sticky position render such elements inline. Extend simulateScrollProgress with a range option, so specs can tell which range a content element observes. Let the SelectInput domino read the texts a select offers and the input dominos filter for inputs that are currently visible. --- entry_types/scrolled/config/locales/de.yml | 11 ++- entry_types/scrolled/config/locales/en.yml | 11 ++- .../doc/creating_content_element_types.md | 7 ++ .../lottieAnimation/LottieAnimation-spec.js | 31 +++++++ .../lottieAnimation/editor/index-spec.js | 87 ++++++++++++++++++- .../lottieAnimation/LottieAnimation.js | 8 +- .../lottieAnimation/editor/index.js | 32 ++++++- .../src/testHelpers/renderInContentElement.js | 18 ++-- .../src/testHelpers/dominos/ui/inputs/Base.js | 7 +- .../dominos/ui/inputs/SelectInput.js | 6 ++ 10 files changed, 201 insertions(+), 17 deletions(-) diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index 90dbd8a283..ab86ffdf4d 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -775,12 +775,21 @@ de: id: label: Animation playbackMode: - inline_help_html: Bestimmt, wie die Animation abgespielt wird, sobald sie sichtbar wird:
  • Endlosschleife: Immer wieder, solange sie sichtbar ist.
  • Einmal abspielen: Einmal von Anfang bis Ende.
  • Scrollposition: Bild für Bild beim Scrollen. Die Animation beginnt, wenn das Element in den Viewport scrollt, und endet, sobald es den Viewport wieder verlassen hat.
+ inline_help_html: Bestimmt, wie die Animation abgespielt wird, sobald sie sichtbar wird:
  • Endlosschleife: Immer wieder, solange sie sichtbar ist.
  • Einmal abspielen: Einmal von Anfang bis Ende.
  • Scrollposition: Bild für Bild beim Scrollen. Über die Einstellung „Scroll-Bereich“ kann gewählt werden, wann das Scrollen der Seite die Animation antreibt.
label: Wiedergabe-Modus values: loop: Endlosschleife playOnce: Einmal abspielen scroll: Scrollposition + scrollRange: + inline_help_html: Bestimmt, wann das Scrollen der Seite die Animation antreibt:
  • Während sichtbar: Von dem Moment, in dem das Element in den Viewport scrollt, bis es ihn wieder vollständig verlassen hat.
  • Während vollständig sichtbar: Solange das Element vollständig im Viewport ist.
  • Beim Passieren der Viewport-Mitte: Das Scrollen der Seite treibt die Animation an, während das Element die vertikale Mitte des Viewports passiert. Verfügbar für alle Elemente außer solchen mit Position „Neben dem Text (Sticky)“ oder „Stand-Alone“.
  • Während eingerastet: Das Scrollen der Seite treibt die Animation an, solange das Element stehenbleibt. Bleibt das Element nicht stehen - in der Mobil-Darstellung, in der solche Elemente im Text verankert werden, oder wenn nicht genug Text daneben steht -, treibt das Scrollen die Animation stattdessen beim Passieren der Viewport-Mitte an. Verfügbar für Elemente mit Position „Neben dem Text (Sticky)“ oder „Stand-Alone“.
  • Beim Hereinscrollen: Solange das Element in den Viewport scrollt.
+ label: Scroll-Bereich + values: + contain: Während vollständig sichtbar + cover: Während sichtbar + entry: Beim Hereinscrollen + inFocus: Beim Passieren der Viewport-Mitte + inFocusWhenPinned: Während eingerastet description: Eine als dotLottie-Datei exportierte Animation einbinden name: Lottie-Animation tabs: diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml index 955a7ba7ef..8d4bca3d63 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -760,12 +760,21 @@ en: id: label: Animation playbackMode: - inline_help_html: Determines how the animation is played once it becomes visible:
  • Loop: Again and again as long as it is visible.
  • Play once: Once from start to end.
  • Scroll position: Frame by frame while scrolling. The animation starts when the element enters the viewport and ends once it has left the viewport again.
+ inline_help_html: Determines how the animation is played once it becomes visible:
  • Loop: Again and again as long as it is visible.
  • Play once: Once from start to end.
  • Scroll position: Frame by frame while scrolling. Use the scroll range setting to choose when scrolling the page drives the animation.
label: Playback Mode values: loop: Loop playOnce: Play once scroll: Scroll position + scrollRange: + inline_help_html: Determines when scrolling the page drives the animation:
  • While visible: From the moment the element starts entering the viewport until it has completely left it.
  • While completely visible: While the element is completely inside the viewport.
  • While crossing the viewport center: Scrolling the page drives the animation while the element crosses the vertical center of the viewport. Available for all elements except those with position "Alongside (sticky)" or "Stand alone".
  • While locked in place: Scrolling the page drives the animation while the element stays in place. If the element does not stay in place - in mobile view, where such elements are anchored in the text, or if there is not enough text next to it - scrolling drives the animation while crossing the viewport center instead. Available for elements with position "Alongside (sticky)" or "Stand alone".
  • While entering: While the element is entering the viewport.
+ label: Scroll Range + values: + contain: While completely visible + cover: While visible + entry: While entering + inFocus: While crossing the viewport center + inFocusWhenPinned: While locked in place description: Embed an animation exported as dotLottie file name: Lottie animation tabs: diff --git a/entry_types/scrolled/doc/creating_content_element_types.md b/entry_types/scrolled/doc/creating_content_element_types.md index 385b740a60..15c1cf3852 100644 --- a/entry_types/scrolled/doc/creating_content_element_types.md +++ b/entry_types/scrolled/doc/creating_content_element_types.md @@ -296,6 +296,13 @@ const {simulateScrollProgress} = renderInContentElement(); simulateScrollProgress(0.5); ``` +Callbacks are invoked no matter which range they observe. Pass a +`range` option to only invoke callbacks observing that range: + +```javascript +simulateScrollProgress(0.5, {range: 'pinned'}); +``` + ## Using the Storybook Pageflow Scrolled uses [Storybook](https://storybook.js.org/) to ease diff --git a/entry_types/scrolled/package/spec/contentElements/lottieAnimation/LottieAnimation-spec.js b/entry_types/scrolled/package/spec/contentElements/lottieAnimation/LottieAnimation-spec.js index 07570866d0..0224d682db 100644 --- a/entry_types/scrolled/package/spec/contentElements/lottieAnimation/LottieAnimation-spec.js +++ b/entry_types/scrolled/package/spec/contentElements/lottieAnimation/LottieAnimation-spec.js @@ -155,6 +155,37 @@ describe('LottieAnimation', () => { expect(players[0].setFrame).toHaveBeenCalledWith(4.5); }); + it('couples animation to cover range by default', () => { + const {simulateScrollProgress} = renderLottieAnimation({configuration}); + players[0].emit('load'); + + simulateScrollProgress(0.5, {range: 'cover'}); + + expect(players[0].setFrame).toHaveBeenCalledWith(4.5); + }); + + it('couples animation to configured scroll range', () => { + const {simulateScrollProgress} = renderLottieAnimation({ + configuration: {...configuration, scrollRange: 'inFocus'} + }); + players[0].emit('load'); + + simulateScrollProgress(0.5, {range: 'inFocus'}); + + expect(players[0].setFrame).toHaveBeenCalledWith(4.5); + }); + + it('ignores progress along other ranges', () => { + const {simulateScrollProgress} = renderLottieAnimation({ + configuration: {...configuration, scrollRange: 'inFocus'} + }); + players[0].emit('load'); + + simulateScrollProgress(0.5, {range: 'cover'}); + + expect(players[0].setFrame).not.toHaveBeenCalledWith(4.5); + }); + it('does not set frame in other playback modes', () => { const {simulateScrollProgress} = renderLottieAnimation(); players[0].emit('load'); diff --git a/entry_types/scrolled/package/spec/contentElements/lottieAnimation/editor/index-spec.js b/entry_types/scrolled/package/spec/contentElements/lottieAnimation/editor/index-spec.js index 2db559bc42..462c6f08a1 100644 --- a/entry_types/scrolled/package/spec/contentElements/lottieAnimation/editor/index-spec.js +++ b/entry_types/scrolled/package/spec/contentElements/lottieAnimation/editor/index-spec.js @@ -1,5 +1,5 @@ import {editor} from 'pageflow-scrolled/editor'; -import {FileInput, SelectInput, useFakeFeatures} from 'pageflow/testHelpers'; +import {FileInput, SelectInput, useFakeFeatures, useFakeTranslations} from 'pageflow/testHelpers'; import {renderContentElementConfigurationEditor, useEditorGlobals} from 'support'; @@ -39,10 +39,11 @@ describe('lottieAnimation/editor', () => { }); describe('configuration editor', () => { - function renderConfigurationEditor({configuration, lottieFiles = []}) { + function renderConfigurationEditor({configuration, lottieFiles = [], layout}) { const entry = createEntry({ filesAttributes: {lottie_files: lottieFiles}, - contentElements: [{id: 1, typeName: 'lottieAnimation', configuration}] + sections: [{id: 1, configuration: {layout}}], + contentElements: [{id: 1, sectionId: 1, typeName: 'lottieAnimation', configuration}] }); return renderContentElementConfigurationEditor({ @@ -61,6 +62,86 @@ describe('lottieAnimation/editor', () => { expect(input.values()).toEqual(['loop', 'playOnce', 'scroll']); }); + // The texts a select offers cannot depend on other attributes, so + // there is one input per wording of the inFocus range. + function visibleScrollRangeInput(configurationEditor) { + return SelectInput.findByPropertyName('scrollRange', { + inView: configurationEditor, + visible: true + }); + } + + useFakeTranslations({ + 'pageflow_scrolled.editor.content_elements.lottieAnimation.attributes.scrollRange.values.inFocus': + 'While crossing the viewport center', + 'pageflow_scrolled.editor.content_elements.lottieAnimation.attributes.scrollRange.values.inFocusWhenPinned': + 'While locked in place' + }); + + it('displays select to choose scroll range in scroll playback mode', () => { + const configurationEditor = renderConfigurationEditor({ + configuration: {playbackMode: 'scroll'} + }); + + expect(visibleScrollRangeInput(configurationEditor).values()) + .toEqual(['cover', 'contain', 'inFocus', 'entry']); + }); + + it('offers same scroll ranges for positions that pin the element', () => { + const configurationEditor = renderConfigurationEditor({ + configuration: {playbackMode: 'scroll', position: 'sticky'} + }); + + expect(visibleScrollRangeInput(configurationEditor).values()) + .toEqual(['cover', 'contain', 'inFocus', 'entry']); + }); + + it('names in focus range after viewport center by default', () => { + const configurationEditor = renderConfigurationEditor({ + configuration: {playbackMode: 'scroll'} + }); + + expect(visibleScrollRangeInput(configurationEditor).texts()) + .toContain('While crossing the viewport center'); + }); + + it('names in focus range after pinned phase for sticky position', () => { + const configurationEditor = renderConfigurationEditor({ + configuration: {playbackMode: 'scroll', position: 'sticky'} + }); + + expect(visibleScrollRangeInput(configurationEditor).texts()) + .toContain('While locked in place'); + }); + + it('names in focus range after viewport center if layout inlines sticky', () => { + const configurationEditor = renderConfigurationEditor({ + layout: 'center', + configuration: {playbackMode: 'scroll', position: 'sticky'} + }); + + expect(visibleScrollRangeInput(configurationEditor).texts()) + .toContain('While crossing the viewport center'); + }); + + it('names in focus range after pinned phase for standAlone position', () => { + const configurationEditor = renderConfigurationEditor({ + configuration: {playbackMode: 'scroll', position: 'standAlone'} + }); + + expect(visibleScrollRangeInput(configurationEditor).texts()) + .toContain('While locked in place'); + }); + + it('does not display select to choose scroll range in other playback modes', () => { + const configurationEditor = renderConfigurationEditor({ + configuration: {playbackMode: 'loop'} + }); + + expect(configurationEditor.visibleInputPropertyNames()) + .not.toContain('scrollRange'); + }); + it('displays image modifiers input if animation is present', () => { const configurationEditor = renderConfigurationEditor({ lottieFiles: [{perma_id: 100}], diff --git a/entry_types/scrolled/package/src/contentElements/lottieAnimation/LottieAnimation.js b/entry_types/scrolled/package/src/contentElements/lottieAnimation/LottieAnimation.js index 112c3306b0..2aec57c784 100644 --- a/entry_types/scrolled/package/src/contentElements/lottieAnimation/LottieAnimation.js +++ b/entry_types/scrolled/package/src/contentElements/lottieAnimation/LottieAnimation.js @@ -27,7 +27,7 @@ export function LottieAnimation({configuration}) { const {aspectRatio, rounded} = processImageModifiers(configuration.imageModifiers); const isCircleCrop = rounded === 'circle'; - const {playbackMode = 'loop'} = configuration; + const {playbackMode = 'loop', scrollRange = 'cover'} = configuration; return ( `${scrollRangeValuesKey}.${name}`); editor.contentElementTypes.register('lottieAnimation', { pictogram, @@ -39,7 +46,7 @@ editor.contentElementTypes.register('lottieAnimation', { this.input('playbackMode', SelectInputView, {values: playbackModes}); }, - configurationEditor({entry}) { + configurationEditor({entry, contentElement}) { this.tab('general', function() { this.input('id', FileInputView, { collection: 'lottie_files', @@ -61,6 +68,23 @@ editor.contentElementTypes.register('lottieAnimation', { visible: () => this.model.getReference('id', 'lottie_files') }); this.input('playbackMode', SelectInputView, {values: playbackModes}); + // Elements that stay in place while scrolling name the inFocus + // range after that phase instead of after the center of the + // viewport. Since the texts of a select cannot depend on other + // attributes, there is one input per wording. + this.input('scrollRange', SelectInputView, { + values: scrollRanges, + visibleBinding: ['playbackMode', 'position'], + visible: ([playbackMode]) => + playbackMode === 'scroll' && !staysInPlace(contentElement) + }); + this.input('scrollRange', SelectInputView, { + values: scrollRanges, + translationKeys: pinnedScrollRangeKeys, + visibleBinding: ['playbackMode', 'position'], + visible: ([playbackMode]) => + playbackMode === 'scroll' && staysInPlace(contentElement) + }); this.view(SeparatorView); @@ -73,3 +97,9 @@ editor.contentElementTypes.register('lottieAnimation', { }); } }); + +// Layouts that do not support sticky position render such elements +// inline, which does not keep them in place while scrolling. +function staysInPlace(contentElement) { + return pinnedPositions.includes(contentElement.getResolvedPosition()); +} diff --git a/entry_types/scrolled/package/src/testHelpers/renderInContentElement.js b/entry_types/scrolled/package/src/testHelpers/renderInContentElement.js index ae47242c09..fb8e85dcf9 100644 --- a/entry_types/scrolled/package/src/testHelpers/renderInContentElement.js +++ b/entry_types/scrolled/package/src/testHelpers/renderInContentElement.js @@ -27,7 +27,8 @@ import {renderInEntryWithScrollPositionLifecycle} from './scrollPositionLifecycl * * `simulateScrollProgress` passes the given progress to all * `useContentElementViewTimelineProgress` callbacks, no matter which - * range they observe. + * range they observe. Pass a `range` option to only invoke callbacks + * observing that range. * * @param {Function} callback - React component or function returning a React component. * @param {Object} [options] - Supports all options supported by {@link `renderInEntry`}. @@ -54,6 +55,7 @@ import {renderInEntryWithScrollPositionLifecycle} from './scrollPositionLifecycl * }); * simulateScrollPosition('near viewport'); * simulateScrollProgress(0.5); + * simulateScrollProgress(0.5, {range: 'pinned'}); * triggerEditorCommand({type: 'HIGHLIGHT'}); * simulateStorylineMode('background'); */ @@ -69,8 +71,14 @@ export function renderInContentElement(ui, {inlineEditing, const viewTimeline = { subscribe(range, callback) { - viewTimelineEmitter.on('progress', callback); - return () => viewTimelineEmitter.off('progress', callback); + function handleProgress(progress, options) { + if (!options.range || options.range === range) { + callback(progress); + } + } + + viewTimelineEmitter.on('progress', handleProgress); + return () => viewTimelineEmitter.off('progress', handleProgress); } }; @@ -134,9 +142,9 @@ export function renderInContentElement(ui, {inlineEditing, storylineEmitter.trigger('storylineMode', mode) }); }, - simulateScrollProgress(progress) { + simulateScrollProgress(progress, {range} = {}) { act(() => { - viewTimelineEmitter.trigger('progress', progress) + viewTimelineEmitter.trigger('progress', progress, {range}) }); } }; diff --git a/package/src/testHelpers/dominos/ui/inputs/Base.js b/package/src/testHelpers/dominos/ui/inputs/Base.js index 9a75ee0d57..966e120f57 100644 --- a/package/src/testHelpers/dominos/ui/inputs/Base.js +++ b/package/src/testHelpers/dominos/ui/inputs/Base.js @@ -4,11 +4,12 @@ export const Base = BaseDomino.extend({ selector: '.input' }); -Base.findByPropertyName = function(propertyName, options) { +Base.findByPropertyName = function(propertyName, {visible, ...options} = {}) { return this.findBy( - el => el.data('inputPropertyName') === propertyName, + el => el.data('inputPropertyName') === propertyName && + (!visible || !el.hasClass('hidden_via_binding')), { - predicateName: `input property name '${propertyName}'`, + predicateName: `${visible ? 'visible ' : ''}input property name '${propertyName}'`, ...options } ) diff --git a/package/src/testHelpers/dominos/ui/inputs/SelectInput.js b/package/src/testHelpers/dominos/ui/inputs/SelectInput.js index e8d460fd5c..292ac78b41 100644 --- a/package/src/testHelpers/dominos/ui/inputs/SelectInput.js +++ b/package/src/testHelpers/dominos/ui/inputs/SelectInput.js @@ -13,6 +13,12 @@ export const SelectInput = Base.extend({ }).get(); }, + texts: function() { + return this.$el.find('option').map(function() { + return $(this).text(); + }).get(); + }, + enabledValues: function() { return this.$el.find('option:not([disabled])').map(function() { return $(this).attr('value'); From 00320ed7e6c6486eb5dd0f277e4e8f0cc2e4a112 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Tue, 18 Aug 2026 17:04:08 +0200 Subject: [PATCH 11/14] Group visualizations used by input views in a directory Some input views render miniatures of sections or content elements to illustrate what an option does. Those components are not input views themselves and are shared between input views, so keep them in a directory of their own. SectionPaddingVisualizationView stays where it is despite its name: It is an input view. --- .../src/editor/views/inputs/AppearanceSelectInputView.js | 2 +- .../package/src/editor/views/inputs/LayoutSelectInputView.js | 2 +- .../views/inputs/{ => visualizations}/SectionVisualization.js | 0 .../inputs/{ => visualizations}/SectionVisualization.module.css | 0 4 files changed, 2 insertions(+), 2 deletions(-) rename entry_types/scrolled/package/src/editor/views/inputs/{ => visualizations}/SectionVisualization.js (100%) rename entry_types/scrolled/package/src/editor/views/inputs/{ => visualizations}/SectionVisualization.module.css (100%) diff --git a/entry_types/scrolled/package/src/editor/views/inputs/AppearanceSelectInputView.js b/entry_types/scrolled/package/src/editor/views/inputs/AppearanceSelectInputView.js index e15ca45e74..018c820fa6 100644 --- a/entry_types/scrolled/package/src/editor/views/inputs/AppearanceSelectInputView.js +++ b/entry_types/scrolled/package/src/editor/views/inputs/AppearanceSelectInputView.js @@ -1,7 +1,7 @@ import React from 'react'; import {ListboxInputView} from './ListboxInputView'; -import {SectionVisualization} from './SectionVisualization'; +import {SectionVisualization} from './visualizations/SectionVisualization'; export const AppearanceSelectInputView = ListboxInputView.extend({ modelEvents() { diff --git a/entry_types/scrolled/package/src/editor/views/inputs/LayoutSelectInputView.js b/entry_types/scrolled/package/src/editor/views/inputs/LayoutSelectInputView.js index d8e38be8c2..9466a72bfb 100644 --- a/entry_types/scrolled/package/src/editor/views/inputs/LayoutSelectInputView.js +++ b/entry_types/scrolled/package/src/editor/views/inputs/LayoutSelectInputView.js @@ -1,7 +1,7 @@ import React from 'react'; import {ListboxInputView} from './ListboxInputView'; -import {SectionVisualization} from './SectionVisualization'; +import {SectionVisualization} from './visualizations/SectionVisualization'; export const LayoutSelectInputView = ListboxInputView.extend({ modelEvents() { diff --git a/entry_types/scrolled/package/src/editor/views/inputs/SectionVisualization.js b/entry_types/scrolled/package/src/editor/views/inputs/visualizations/SectionVisualization.js similarity index 100% rename from entry_types/scrolled/package/src/editor/views/inputs/SectionVisualization.js rename to entry_types/scrolled/package/src/editor/views/inputs/visualizations/SectionVisualization.js diff --git a/entry_types/scrolled/package/src/editor/views/inputs/SectionVisualization.module.css b/entry_types/scrolled/package/src/editor/views/inputs/visualizations/SectionVisualization.module.css similarity index 100% rename from entry_types/scrolled/package/src/editor/views/inputs/SectionVisualization.module.css rename to entry_types/scrolled/package/src/editor/views/inputs/visualizations/SectionVisualization.module.css From e373443338e4fb5b8cdf0384d4d1b88cc1507107 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Tue, 18 Aug 2026 15:07:59 +0200 Subject: [PATCH 12/14] Extract content element visualization from position select input view The illustration of how a content element behaves while the page scrolls is useful beyond choosing a position: Scroll ranges can be demonstrated with the same miniature section. Extract a ContentElementVisualization component that renders the markup and takes position and layout as props, and a useScrollAnimation hook that scrolls a referenced element back and forth. Children of the visualization are rendered inside the rectangle representing the content element, so callers can add overlays. Name it after the element it visualizes rather than after the section it renders, to keep it apart from SectionVisualization, which illustrates layout and appearance of sections. Let callers compute the scroll position from the rendered visualization instead of passing a fixed distance, since demonstrating a whole view timeline requires knowing where the element sits. --- .../ContentElementVisualization-spec.js | 25 ++++ .../visualizations/useScrollAnimation-spec.js | 81 +++++++++++ .../views/inputs/PositionSelectInputView.js | 68 ++-------- .../inputs/PositionSelectInputView.module.css | 128 ------------------ .../ContentElementVisualization.js | 50 +++++++ .../ContentElementVisualization.module.css | 127 +++++++++++++++++ .../visualizations/useScrollAnimation.js | 42 ++++++ 7 files changed, 333 insertions(+), 188 deletions(-) create mode 100644 entry_types/scrolled/package/spec/editor/views/inputs/visualizations/ContentElementVisualization-spec.js create mode 100644 entry_types/scrolled/package/spec/editor/views/inputs/visualizations/useScrollAnimation-spec.js create mode 100644 entry_types/scrolled/package/src/editor/views/inputs/visualizations/ContentElementVisualization.js create mode 100644 entry_types/scrolled/package/src/editor/views/inputs/visualizations/ContentElementVisualization.module.css create mode 100644 entry_types/scrolled/package/src/editor/views/inputs/visualizations/useScrollAnimation.js diff --git a/entry_types/scrolled/package/spec/editor/views/inputs/visualizations/ContentElementVisualization-spec.js b/entry_types/scrolled/package/spec/editor/views/inputs/visualizations/ContentElementVisualization-spec.js new file mode 100644 index 0000000000..07c2706c4b --- /dev/null +++ b/entry_types/scrolled/package/spec/editor/views/inputs/visualizations/ContentElementVisualization-spec.js @@ -0,0 +1,25 @@ +import React from 'react'; +import {render} from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; + +import {ContentElementVisualization} from 'editor/views/inputs/visualizations/ContentElementVisualization'; + +import styles from 'editor/views/inputs/visualizations/ContentElementVisualization.module.css'; + +describe('ContentElementVisualization', () => { + it('applies classes for position and layout', () => { + const {container} = render(); + + expect(container.firstChild).toHaveClass(styles.sidePosition, styles.centerLayout); + }); + + it('renders children inside the rect representing the element', () => { + const {getByTestId} = render( + + + + ); + + expect(getByTestId('overlay').parentElement).toHaveClass(styles.block); + }); +}); diff --git a/entry_types/scrolled/package/spec/editor/views/inputs/visualizations/useScrollAnimation-spec.js b/entry_types/scrolled/package/spec/editor/views/inputs/visualizations/useScrollAnimation-spec.js new file mode 100644 index 0000000000..3ab96bcaa0 --- /dev/null +++ b/entry_types/scrolled/package/spec/editor/views/inputs/visualizations/useScrollAnimation-spec.js @@ -0,0 +1,81 @@ +import React, {useRef} from 'react'; +import {act, render} from '@testing-library/react'; + +import {useScrollAnimation} from 'editor/views/inputs/visualizations/useScrollAnimation'; + +describe('useScrollAnimation', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + function renderScroller({scrollTop, onScroll}) { + function Scroller() { + const ref = useRef(); + + useScrollAnimation(ref, {scrollTop, onScroll}); + + return
; + } + + const {getByTestId} = render(); + return getByTestId('scroller'); + } + + it('scrolls before the first interval elapses', () => { + const onScroll = jest.fn(); + const scroller = renderScroller({scrollTop: () => 100, onScroll}); + + expect(scroller.scrollTop).toEqual(100); + expect(onScroll).toHaveBeenCalledWith(scroller); + }); + + it('scrolls back and forth', () => { + const scroller = renderScroller({scrollTop: (scroller, progress) => 1000 * progress}); + + act(() => jest.advanceTimersByTime(1500)); + const scrollTopAtTurningPoint = scroller.scrollTop; + + act(() => jest.advanceTimersByTime(1500)); + const scrollTopAtEnd = scroller.scrollTop; + + act(() => jest.advanceTimersByTime(1500)); + + expect(scrollTopAtTurningPoint).toBeGreaterThan(0); + expect(scrollTopAtEnd).toEqual(1000); + expect(scroller.scrollTop).toBeLessThan(scrollTopAtEnd); + }); + + it('passes the scroller to the callback', () => { + const scrollTop = jest.fn().mockReturnValue(0); + const scroller = renderScroller({scrollTop}); + + act(() => jest.advanceTimersByTime(10)); + + expect(scrollTop).toHaveBeenCalledWith(scroller, expect.any(Number)); + }); + + it('invokes onScroll with the scroller', () => { + const onScroll = jest.fn(); + const scroller = renderScroller({scrollTop: () => 100, onScroll}); + + act(() => jest.advanceTimersByTime(10)); + + expect(onScroll).toHaveBeenCalledWith(scroller); + }); + + it('stops scrolling on unmount', () => { + const onScroll = jest.fn(); + function Scroller() { + const ref = useRef(); + useScrollAnimation(ref, {scrollTop: () => 100, onScroll}); + return
; + } + + const {unmount} = render(); + unmount(); + onScroll.mockClear(); + + act(() => jest.advanceTimersByTime(100)); + + expect(onScroll).not.toHaveBeenCalled(); + }); +}); diff --git a/entry_types/scrolled/package/src/editor/views/inputs/PositionSelectInputView.js b/entry_types/scrolled/package/src/editor/views/inputs/PositionSelectInputView.js index 55bdc8c91f..cc8305443f 100644 --- a/entry_types/scrolled/package/src/editor/views/inputs/PositionSelectInputView.js +++ b/entry_types/scrolled/package/src/editor/views/inputs/PositionSelectInputView.js @@ -1,9 +1,11 @@ -import React, {useRef, useEffect} from 'react'; +import React, {useRef} from 'react'; import classNames from 'classnames'; import I18n from 'i18n-js'; import {i18nUtils} from 'pageflow/ui'; import {ListboxInputView} from './ListboxInputView'; +import {ContentElementVisualization} from './visualizations/ContentElementVisualization'; +import {useScrollAnimation} from './visualizations/useScrollAnimation'; import styles from './PositionSelectInputView.module.css'; @@ -19,55 +21,18 @@ export const PositionSelectInputView = ListboxInputView.extend({ } }); -const duration = 3000; - function Preview({item, layout, inlineHelpTranslationKeyPrefix}) { const ref = useRef(); - const dist = item.value === 'sticky' || item.value === 'standAlone' ? 200 : 100; - - useEffect(() => { - let startTime = new Date().getTime(); - - const interval = setInterval(() => { - const currentTime = new Date().getTime(); - let t = (currentTime - startTime) % (2 * duration); - if (t > duration) { - t = duration - (t - duration); - } + const distance = item.value === 'sticky' || item.value === 'standAlone' ? 200 : 100; - ref.current.scrollTop = dist * easeInOut(t / duration); - }, 10); - - return () => clearInterval(interval); - }, [dist]); + useScrollAnimation(ref, {scrollTop: (scroller, progress) => distance * progress}); return (
- ); } - -function TextBlock({words}) { - return ( -
- {Array(words).fill().map((i, index) => -
- )} -
- ); -} - -function easeInOut(t) { - t = t * 2; - if (t < 1) return (t**2)/2; - t = t - 1; - return t - (t**2)/2 + 1/2; -}; diff --git a/entry_types/scrolled/package/src/editor/views/inputs/PositionSelectInputView.module.css b/entry_types/scrolled/package/src/editor/views/inputs/PositionSelectInputView.module.css index f4e8b11c93..d1f32208dd 100644 --- a/entry_types/scrolled/package/src/editor/views/inputs/PositionSelectInputView.module.css +++ b/entry_types/scrolled/package/src/editor/views/inputs/PositionSelectInputView.module.css @@ -1,131 +1,3 @@ -.preview { - aspect-ratio: 16 / 9; - border: solid 1px var(--ui-on-surface-color-lightest); - border-radius: rounded(sm); - margin-bottom: space(1); - background-color: var(--ui-primary-color); - color: var(--ui-on-primary-color-light); - padding: space(4) space(8); - overflow: hidden; - position: relative; - max-width: 260px; - box-sizing: border-box; - margin-left: auto; - margin-right: auto; -} - -.backdropPosition { - background-color: var(--ui-selection-color); -} - -.backdropPosition .section { - padding-top: 40%; -} - -.content { - width: 45%; -} - -.centerLayout .content, -.centerLayout .block, -.centerRaggedLayout .content, -.centerRaggedLayout .block { - width: 60%; - margin-left: auto; - margin-right: auto; -} - -.rightLayout .content, -.rightLayout .block { - margin-left: auto; -} - -.textBlock { - display: block; - width: 100%; - margin-bottom: space(3); - line-height: space(3); -} - -.centerRaggedLayout .textBlock { - text-align: center; -} - -.textBlockWord { - display: inline-block; - border: solid 1px currentColor; - width: space(2); -} - -.textBlockWord:nth-child(5n) { - width: space(3); -} - -.textBlockWord:nth-child(2n) { - width: space(2.5); -} - -.block { - width: 60%; - aspect-ratio: 4 / 3; - background-color: var(--ui-selection-color); - border-radius: rounded(); - margin-bottom: space(2); -} - -.leftPosition .block, -.rightPosition .block { - width: 40%; - margin: space(2) 0 0; -} - -.leftPosition .block { - float: left; - margin-left: -5%; - margin-right: space(3); -} - -.rightPosition .block { - float: right; - margin-right: -5%; - margin-left: space(3); -} - -.sidePosition .block { - width: 100%; -} - -.sidePosition .wrapper { - float: right; - width: 40%; - top: 30%; -} - -.rightLayout.sidePosition .wrapper { - float: left; -} - -.stickyPosition { - composes: sidePosition; -} - -.stickyPosition .wrapper { - position: sticky; -} - -.standAlonePosition .wrapper { - height: 170px; -} - -.standAlonePosition .block { - position: sticky; - top: 10%; -} - -.backdropPosition .block { - display: none; -} - .outer { position: relative; } diff --git a/entry_types/scrolled/package/src/editor/views/inputs/visualizations/ContentElementVisualization.js b/entry_types/scrolled/package/src/editor/views/inputs/visualizations/ContentElementVisualization.js new file mode 100644 index 0000000000..b99788698a --- /dev/null +++ b/entry_types/scrolled/package/src/editor/views/inputs/visualizations/ContentElementVisualization.js @@ -0,0 +1,50 @@ +import React, {forwardRef} from 'react'; +import classNames from 'classnames'; + +import styles from './ContentElementVisualization.module.css'; + +// Illustrates how a content element behaves while the page scrolls, +// by rendering a miniature of the section it sits in. Children are +// rendered inside the rectangle representing the content element. +export const ContentElementVisualization = forwardRef(function ContentElementVisualization( + {position, layout, children}, ref +) { + return ( + + ); +}); + +function TextBlock({words}) { + return ( +
+ {Array(words).fill().map((i, index) => +
+ )} +
+ ); +} diff --git a/entry_types/scrolled/package/src/editor/views/inputs/visualizations/ContentElementVisualization.module.css b/entry_types/scrolled/package/src/editor/views/inputs/visualizations/ContentElementVisualization.module.css new file mode 100644 index 0000000000..47cdcdc627 --- /dev/null +++ b/entry_types/scrolled/package/src/editor/views/inputs/visualizations/ContentElementVisualization.module.css @@ -0,0 +1,127 @@ +.visualization { + aspect-ratio: 16 / 9; + border: solid 1px var(--ui-on-surface-color-lightest); + border-radius: rounded(sm); + margin-bottom: space(1); + background-color: var(--ui-primary-color); + color: var(--ui-on-primary-color-light); + padding: space(4) space(8); + overflow: hidden; + position: relative; + max-width: 260px; + box-sizing: border-box; + margin-left: auto; + margin-right: auto; +} + +.backdropPosition { + background-color: var(--ui-selection-color); +} + +.backdropPosition .section { + padding-top: 40%; +} + +.content { + width: 45%; +} + +.centerLayout .content, +.centerLayout .block, +.centerRaggedLayout .content, +.centerRaggedLayout .block { + width: 60%; + margin-left: auto; + margin-right: auto; +} + +.rightLayout .content, +.rightLayout .block { + margin-left: auto; +} + +.textBlock { + display: block; + width: 100%; + margin-bottom: space(3); + line-height: space(3); +} + +.centerRaggedLayout .textBlock { + text-align: center; +} + +.textBlockWord { + display: inline-block; + border: solid 1px currentColor; + width: space(2); +} + +.textBlockWord:nth-child(5n) { + width: space(3); +} + +.textBlockWord:nth-child(2n) { + width: space(2.5); +} + +.block { + width: 60%; + aspect-ratio: 4 / 3; + background-color: var(--ui-selection-color); + border-radius: rounded(); + margin-bottom: space(2); +} + +.leftPosition .block, +.rightPosition .block { + width: 40%; + margin: space(2) 0 0; +} + +.leftPosition .block { + float: left; + margin-left: -5%; + margin-right: space(3); +} + +.rightPosition .block { + float: right; + margin-right: -5%; + margin-left: space(3); +} + +.sidePosition .block { + width: 100%; +} + +.sidePosition .wrapper { + float: right; + width: 40%; + top: 30%; +} + +.rightLayout.sidePosition .wrapper { + float: left; +} + +.stickyPosition { + composes: sidePosition; +} + +.stickyPosition .wrapper { + position: sticky; +} + +.standAlonePosition .wrapper { + height: 170px; +} + +.standAlonePosition .block { + position: sticky; + top: 10%; +} + +.backdropPosition .block { + display: none; +} diff --git a/entry_types/scrolled/package/src/editor/views/inputs/visualizations/useScrollAnimation.js b/entry_types/scrolled/package/src/editor/views/inputs/visualizations/useScrollAnimation.js new file mode 100644 index 0000000000..aa06d8ac45 --- /dev/null +++ b/entry_types/scrolled/package/src/editor/views/inputs/visualizations/useScrollAnimation.js @@ -0,0 +1,42 @@ +import {useEffect, useRef} from 'react'; + +// Scrolls the referenced element back and forth to demonstrate how a +// content element behaves while the page scrolls. Since the position +// to scroll to often depends on the size of the rendered preview, +// scrollTop is passed as a function receiving the scroller and the +// eased progress of the animation. +export function useScrollAnimation(ref, {scrollTop, duration = 3000, onScroll}) { + const callbacksRef = useRef(); + callbacksRef.current = {scrollTop, onScroll}; + + useEffect(() => { + const startTime = new Date().getTime(); + + function update() { + const scroller = ref.current; + const elapsed = (new Date().getTime() - startTime) % (2 * duration); + const t = (elapsed > duration ? 2 * duration - elapsed : elapsed) / duration; + + scroller.scrollTop = callbacksRef.current.scrollTop(scroller, easeInOut(t)); + + if (callbacksRef.current.onScroll) { + callbacksRef.current.onScroll(scroller); + } + } + + // Update once to prevent displaying the unscrolled preview until + // the first interval elapses. + update(); + + const interval = setInterval(update, 10); + + return () => clearInterval(interval); + }, [ref, duration]); +} + +function easeInOut(t) { + t = t * 2; + if (t < 1) return (t**2)/2; + t = t - 1; + return t - (t**2)/2 + 1/2; +}; From 3e956aace6a2ae672ebf3c44eb2f3606bf16bee9 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Tue, 18 Aug 2026 15:12:03 +0200 Subject: [PATCH 13/14] Add scroll range select input view Editors cannot tell from range names alone which part of the page drives the animation. Illustrate each range with the same miniature section the position select uses, scrolled along the whole view timeline of the element: from before it enters the viewport until it has left again. Two tweaks to the illustration make ranges easier to tell apart: The rectangle representing the element is narrowed for positions that do not place it next to text, and the section gets room to scroll above and below its content. Without that room, the scroll position the end of the timeline requires is beyond what the visualization can scroll to, which makes progress appear to stop short of the end. Ranges that are measured against the center of the viewport mark it with a dotted line. Play progress is displayed inside the rectangle representing the content element, as a percentage over a bar that fills the rectangle. Progress comes from the same getViewTimelineProgress function the frontend uses, so the demo cannot drift from the real behavior. Since the visualization pins elements like the entry does, feeding it the rects measured inside the visualization is enough. All options illustrate the position the element currently has, since the range is what differs between them. Position and layout can be passed as functions, since options are only rendered once the dropdown opens. --- .../inputs/ScrollRangeSelectInputView-spec.js | 110 ++++++++++++++++++ .../ContentElementVisualization-spec.js | 100 +++++++++++++++- .../scrolled/package/src/editor/index.js | 1 + .../inputs/ScrollRangeSelectInputView.js | 69 +++++++++++ .../ScrollRangeSelectInputView.module.css | 7 ++ .../ContentElementVisualization.js | 70 ++++++++++- .../ContentElementVisualization.module.css | 40 +++++++ .../inputs/visualizations/PlaybackProgress.js | 24 ++++ .../PlaybackProgress.module.css | 26 +++++ .../scrolled/package/src/frontend/index.js | 1 + 10 files changed, 445 insertions(+), 3 deletions(-) create mode 100644 entry_types/scrolled/package/spec/editor/views/inputs/ScrollRangeSelectInputView-spec.js create mode 100644 entry_types/scrolled/package/src/editor/views/inputs/ScrollRangeSelectInputView.js create mode 100644 entry_types/scrolled/package/src/editor/views/inputs/ScrollRangeSelectInputView.module.css create mode 100644 entry_types/scrolled/package/src/editor/views/inputs/visualizations/PlaybackProgress.js create mode 100644 entry_types/scrolled/package/src/editor/views/inputs/visualizations/PlaybackProgress.module.css diff --git a/entry_types/scrolled/package/spec/editor/views/inputs/ScrollRangeSelectInputView-spec.js b/entry_types/scrolled/package/spec/editor/views/inputs/ScrollRangeSelectInputView-spec.js new file mode 100644 index 0000000000..73310c77e3 --- /dev/null +++ b/entry_types/scrolled/package/spec/editor/views/inputs/ScrollRangeSelectInputView-spec.js @@ -0,0 +1,110 @@ +import Backbone from 'backbone'; +import userEvent from '@testing-library/user-event'; +import {within} from '@testing-library/dom'; +import '@testing-library/jest-dom/extend-expect'; + +import {renderReactBasedBackboneView as render} from 'pageflow-scrolled/testHelpers'; + +import { + ScrollRangeSelectInputView +} from 'editor/views/inputs/ScrollRangeSelectInputView'; + +import {fakeBoundingClientRectsByClassName} from 'support/fakeBoundingClientRects'; + +import visualizationStyles from 'editor/views/inputs/visualizations/ContentElementVisualization.module.css'; + +describe('ScrollRangeSelectInputView', () => { + afterEach(() => jest.restoreAllMocks()); + + function renderInputView({position = 'inline'} = {}) { + const model = new Backbone.Model({position, scrollRange: 'cover'}); + + const inputView = new ScrollRangeSelectInputView({ + model, + propertyName: 'scrollRange', + values: ['cover', 'inFocus'], + texts: ['While visible', 'While in focus'], + position: () => model.get('position') + }); + + return {model, ...render(inputView)}; + } + + function previewOf(option) { + return option.querySelector(`.${visualizationStyles.visualization}`); + } + + function viewportCenterOf(option) { + return option.querySelector(`.${visualizationStyles.viewportCenter}`); + } + + it('illustrates the currently selected position in each option', async () => { + const user = userEvent.setup(); + const {getByRole, getAllByRole} = renderInputView({position: 'standAlone'}); + + await user.click(getByRole('button', {name: 'While visible'})); + + getAllByRole('option').forEach(option => + expect(previewOf(option)).toHaveClass(visualizationStyles.standAlonePosition) + ); + }); + + it('leaves room to scroll and narrows the element in each option', async () => { + const user = userEvent.setup(); + const {getByRole, getAllByRole} = renderInputView(); + + await user.click(getByRole('button', {name: 'While visible'})); + + getAllByRole('option').forEach(option => + expect(previewOf(option)).toHaveClass(visualizationStyles.narrowBlock, + visualizationStyles.scrollRoom) + ); + }); + + it('marks the viewport center in the option that is measured against it', async () => { + const user = userEvent.setup(); + const {getByRole} = renderInputView({position: 'inline'}); + + await user.click(getByRole('button', {name: 'While visible'})); + + expect(viewportCenterOf(getByRole('option', {name: 'While in focus'}))).not.toBeNull(); + expect(viewportCenterOf(getByRole('option', {name: 'While visible'}))).toBeNull(); + }); + + it('does not mark the viewport center for positions that pin the element', async () => { + const user = userEvent.setup(); + const {getByRole} = renderInputView({position: 'standAlone'}); + + await user.click(getByRole('button', {name: 'While visible'})); + + expect(viewportCenterOf(getByRole('option', {name: 'While in focus'}))).toBeNull(); + }); + + it('displays progress of the range of each option inside the element', async () => { + fakeBoundingClientRectsByClassName({ + [visualizationStyles.visualization]: {top: 0, height: 100}, + [visualizationStyles.block]: {top: 60, height: 20} + }); + const user = userEvent.setup(); + const {getByRole} = renderInputView(); + + await user.click(getByRole('button', {name: 'While visible'})); + + expect(within(getByRole('option', {name: 'While visible'})).getByText('33%')) + .not.toBeNull(); + expect(within(getByRole('option', {name: 'While in focus'})).getByText('0%')) + .not.toBeNull(); + }); + + it('illustrates the position the element has when opening the dropdown', async () => { + const user = userEvent.setup(); + const {model, getByRole, getAllByRole} = renderInputView({position: 'inline'}); + + model.set('position', 'standAlone'); + await user.click(getByRole('button', {name: 'While visible'})); + + getAllByRole('option').forEach(option => + expect(previewOf(option)).toHaveClass(visualizationStyles.standAlonePosition) + ); + }); +}); diff --git a/entry_types/scrolled/package/spec/editor/views/inputs/visualizations/ContentElementVisualization-spec.js b/entry_types/scrolled/package/spec/editor/views/inputs/visualizations/ContentElementVisualization-spec.js index 07c2706c4b..d2598831a0 100644 --- a/entry_types/scrolled/package/spec/editor/views/inputs/visualizations/ContentElementVisualization-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/inputs/visualizations/ContentElementVisualization-spec.js @@ -2,7 +2,13 @@ import React from 'react'; import {render} from '@testing-library/react'; import '@testing-library/jest-dom/extend-expect'; -import {ContentElementVisualization} from 'editor/views/inputs/visualizations/ContentElementVisualization'; +import { + ContentElementVisualization, + measureScrollTimeline, + measureViewTimelineProgress +} from 'editor/views/inputs/visualizations/ContentElementVisualization'; + +import {fakeBoundingClientRectsByClassName} from 'support/fakeBoundingClientRects'; import styles from 'editor/views/inputs/visualizations/ContentElementVisualization.module.css'; @@ -13,6 +19,30 @@ describe('ContentElementVisualization', () => { expect(container.firstChild).toHaveClass(styles.sidePosition, styles.centerLayout); }); + it('optionally narrows the rect representing the element', () => { + const {container} = render(); + + expect(container.firstChild).toHaveClass(styles.narrowBlock); + }); + + it('optionally adds room to scroll around the section content', () => { + const {container} = render(); + + expect(container.firstChild).toHaveClass(styles.scrollRoom); + }); + + it('optionally marks the center of the viewport', () => { + const {container} = render(); + + expect(container.querySelector(`.${styles.viewportCenter}`)).not.toBeNull(); + }); + + it('does not mark the center of the viewport by default', () => { + const {container} = render(); + + expect(container.querySelector(`.${styles.viewportCenter}`)).toBeNull(); + }); + it('renders children inside the rect representing the element', () => { const {getByTestId} = render( @@ -23,3 +53,71 @@ describe('ContentElementVisualization', () => { expect(getByTestId('overlay').parentElement).toHaveClass(styles.block); }); }); + +describe('measureViewTimelineProgress', () => { + afterEach(() => jest.restoreAllMocks()); + + function renderPreview(position) { + const {container} = render(); + return container.firstChild; + } + + it('measures the element along the visualization acting as viewport', () => { + fakeBoundingClientRectsByClassName({ + [styles.visualization]: {top: 200, height: 100}, + [styles.block]: {top: 240, height: 20} + }); + + const progress = measureViewTimelineProgress({ + scroller: renderPreview('inline'), + position: 'inline', + range: 'cover' + }); + + expect(progress).toEqual(0.5); + }); + + it('measures sticky elements along the group that scrolls past them', () => { + fakeBoundingClientRectsByClassName( + { + [styles.visualization]: {top: 0, height: 100}, + [styles.wrapper]: {top: 40, height: 20} + }, + {otherElements: {top: 0, height: 200}} + ); + + const progress = measureViewTimelineProgress({ + scroller: renderPreview('sticky'), + position: 'sticky', + range: 'inFocus' + }); + + expect(progress).toEqual(40 / 180); + }); +}); + +describe('measureScrollTimeline', () => { + afterEach(() => jest.restoreAllMocks()); + + function scrollTimeline(rects) { + fakeBoundingClientRectsByClassName(rects); + + const {container} = render(); + + return measureScrollTimeline({scroller: container.firstChild, position: 'inline'}); + } + + it('spans from element about to enter until it has left the viewport', () => { + expect(scrollTimeline({ + [styles.visualization]: {top: 0, height: 100}, + [styles.block]: {top: 150, height: 20} + })).toEqual({from: 50, to: 170}); + }); + + it('starts at the top if there is not enough room above', () => { + expect(scrollTimeline({ + [styles.visualization]: {top: 0, height: 100}, + [styles.block]: {top: 40, height: 20} + })).toEqual({from: 0, to: 60}); + }); +}); diff --git a/entry_types/scrolled/package/src/editor/index.js b/entry_types/scrolled/package/src/editor/index.js index 8287fb4c7a..b21144be5f 100644 --- a/entry_types/scrolled/package/src/editor/index.js +++ b/entry_types/scrolled/package/src/editor/index.js @@ -21,6 +21,7 @@ export {ColorSelectInputView} from './views/inputs/ColorSelectInputView'; export {NoOptionsHintView} from './views/NoOptionsHintView'; export {EditMotifAreaDialogView} from './views/EditMotifAreaDialogView'; export {ImageModifierListInputView} from './views/inputs/ImageModifierListInputView'; +export {ScrollRangeSelectInputView} from './views/inputs/ScrollRangeSelectInputView'; export {InlineFileRightsMenuItem} from './models/InlineFileRightsMenuItem'; export {defineEntryDefaultsInputsFromSeed} from './views/EditDefaultsView'; diff --git a/entry_types/scrolled/package/src/editor/views/inputs/ScrollRangeSelectInputView.js b/entry_types/scrolled/package/src/editor/views/inputs/ScrollRangeSelectInputView.js new file mode 100644 index 0000000000..4160bce14a --- /dev/null +++ b/entry_types/scrolled/package/src/editor/views/inputs/ScrollRangeSelectInputView.js @@ -0,0 +1,69 @@ +import React, {useRef} from 'react'; +import _ from 'underscore'; + +import {ListboxInputView} from './ListboxInputView'; +import { + ContentElementVisualization, + measureScrollTimeline, + measureViewTimelineProgress, + pinsElement +} from './visualizations/ContentElementVisualization'; +import {PlaybackProgress} from './visualizations/PlaybackProgress'; +import {useScrollAnimation} from './visualizations/useScrollAnimation'; + +import styles from './ScrollRangeSelectInputView.module.css'; + +export const ScrollRangeSelectInputView = ListboxInputView.extend({ + // Options are rendered when the dropdown is opened, so passing + // position and layout as functions ensures the illustration matches + // what the element looks like by then. + renderItem(item) { + return ( + + ); + } +}); + +function Preview({item, position, layout}) { + const scrollerRef = useRef(); + const progressRef = useRef(); + + useScrollAnimation(scrollerRef, { + scrollTop: (scroller, animationProgress) => { + const {from, to} = measureScrollTimeline({scroller, position}); + + return from + (to - from) * animationProgress; + }, + + onScroll: scroller => progressRef.current.setProgress( + measureViewTimelineProgress({scroller, position, range: item.value}) + ) + }); + + return ( +
+ + + + +
+ {item.text} +
+
+ ); +} + +// Only ranges that are measured relative to the center of the viewport +// benefit from marking it. The inFocus range measures the pinned phase +// of elements that stay in place instead. +function measuresViewportCenter(range, position) { + return range === 'center' || (range === 'inFocus' && !pinsElement(position)); +} diff --git a/entry_types/scrolled/package/src/editor/views/inputs/ScrollRangeSelectInputView.module.css b/entry_types/scrolled/package/src/editor/views/inputs/ScrollRangeSelectInputView.module.css new file mode 100644 index 0000000000..04fe7733d9 --- /dev/null +++ b/entry_types/scrolled/package/src/editor/views/inputs/ScrollRangeSelectInputView.module.css @@ -0,0 +1,7 @@ +.outer { + position: relative; +} + +.description { + text-align: left; +} diff --git a/entry_types/scrolled/package/src/editor/views/inputs/visualizations/ContentElementVisualization.js b/entry_types/scrolled/package/src/editor/views/inputs/visualizations/ContentElementVisualization.js index b99788698a..0f26fee941 100644 --- a/entry_types/scrolled/package/src/editor/views/inputs/visualizations/ContentElementVisualization.js +++ b/entry_types/scrolled/package/src/editor/views/inputs/visualizations/ContentElementVisualization.js @@ -1,5 +1,6 @@ import React, {forwardRef} from 'react'; import classNames from 'classnames'; +import {getViewTimelineProgress} from 'pageflow-scrolled/frontend'; import styles from './ContentElementVisualization.module.css'; @@ -7,14 +8,18 @@ import styles from './ContentElementVisualization.module.css'; // by rendering a miniature of the section it sits in. Children are // rendered inside the rectangle representing the content element. export const ContentElementVisualization = forwardRef(function ContentElementVisualization( - {position, layout, children}, ref + {position, layout, narrowBlock, scrollRoom, viewportCenter, children}, ref ) { return (