diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index 90745d17fc..ab86ffdf4d 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -775,11 +775,21 @@ de: id: label: Animation playbackMode: - inline_help_html: Bestimmt, wie die Animation abgespielt wird, sobald sie sichtbar wird: + inline_help_html: Bestimmt, wie die Animation abgespielt wird, sobald sie sichtbar wird: label: Wiedergabe-Modus values: loop: Endlosschleife playOnce: Einmal abspielen + scroll: Scrollposition + scrollRange: + inline_help_html: Bestimmt, wann das Scrollen der Seite die Animation antreibt: + 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 ba71870733..8d4bca3d63 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -760,11 +760,21 @@ en: id: label: Animation playbackMode: - inline_help_html: Determines how the animation is played once it becomes visible: + inline_help_html: Determines how the animation is played once it becomes visible: label: Playback Mode values: loop: Loop playOnce: Play once + scroll: Scroll position + scrollRange: + inline_help_html: Determines when scrolling the page drives the animation: + 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 6f1393a7b1..15c1cf3852 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 @@ -199,6 +199,110 @@ 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. + +* `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. + +* `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). + +* `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 +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 +}); +``` + +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. 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. 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: + +```javascript +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/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/contentElements/lottieAnimation/LottieAnimation-spec.js b/entry_types/scrolled/package/spec/contentElements/lottieAnimation/LottieAnimation-spec.js index 94583d903a..0224d682db 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,99 @@ 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('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'); + + simulateScrollProgress(0.5); + + expect(players[0].setFrame).not.toHaveBeenCalled(); + }); + }); + it('destroys player on unmount', () => { const {unmount} = renderLottieAnimation(); 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..5b93d84238 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,7 +1,11 @@ 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'; +import { + renderContentElementConfigurationEditor, + scrollRangeNames, + useEditorGlobals +} from 'support'; import 'contentElements/lottieAnimation/editor'; import {LottieFile} from 'contentElements/lottieAnimation/editor/models/LottieFile'; @@ -39,10 +43,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({ @@ -51,6 +56,19 @@ describe('lottieAnimation/editor', () => { }); } + useFakeTranslations({ + 'pageflow_scrolled.editor.content_elements.lottieAnimation.attributes.scrollRange.values.cover': + 'While visible', + 'pageflow_scrolled.editor.content_elements.lottieAnimation.attributes.scrollRange.values.contain': + 'While completely visible', + 'pageflow_scrolled.editor.content_elements.lottieAnimation.attributes.scrollRange.values.entry': + 'While entering', + '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 playback mode', () => { const configurationEditor = renderConfigurationEditor({configuration: {}}); @@ -58,7 +76,61 @@ describe('lottieAnimation/editor', () => { inView: configurationEditor }); - expect(input.values()).toEqual(['loop', 'playOnce']); + expect(input.values()).toEqual(['loop', 'playOnce', 'scroll']); + }); + + it('displays select to choose scroll range in scroll playback mode', async () => { + const configurationEditor = renderConfigurationEditor({ + configuration: {playbackMode: 'scroll'} + }); + + expect(await scrollRangeNames('scrollRange', {inView: configurationEditor})).toEqual([ + 'While visible', + 'While completely visible', + 'While crossing the viewport center', + 'While entering' + ]); + }); + + it('names in focus range after pinned phase for sticky position', async () => { + const configurationEditor = renderConfigurationEditor({ + configuration: {playbackMode: 'scroll', position: 'sticky'} + }); + + expect(await scrollRangeNames('scrollRange', {inView: configurationEditor})).toEqual([ + 'While visible', + 'While completely visible', + 'While locked in place', + 'While entering' + ]); + }); + + it('names in focus range after pinned phase for standAlone position', async () => { + const configurationEditor = renderConfigurationEditor({ + configuration: {playbackMode: 'scroll', position: 'standAlone'} + }); + + expect(await scrollRangeNames('scrollRange', {inView: configurationEditor})) + .toContain('While locked in place'); + }); + + it('names in focus range after viewport center if layout inlines sticky', async () => { + const configurationEditor = renderConfigurationEditor({ + layout: 'center', + configuration: {playbackMode: 'scroll', position: 'sticky'} + }); + + expect(await scrollRangeNames('scrollRange', {inView: configurationEditor})) + .toContain('While crossing the viewport center'); + }); + + 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', () => { 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 new file mode 100644 index 0000000000..d2598831a0 --- /dev/null +++ b/entry_types/scrolled/package/spec/editor/views/inputs/visualizations/ContentElementVisualization-spec.js @@ -0,0 +1,123 @@ +import React from 'react'; +import {render} from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; + +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'; + +describe('ContentElementVisualization', () => { + it('applies classes for position and layout', () => { + const {container} = render(); + + 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( + + + + ); + + 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/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/spec/frontend/useContentElementViewTimelineProgress-spec.js b/entry_types/scrolled/package/spec/frontend/useContentElementViewTimelineProgress-spec.js new file mode 100644 index 0000000000..e0e8a899c7 --- /dev/null +++ b/entry_types/scrolled/package/spec/frontend/useContentElementViewTimelineProgress-spec.js @@ -0,0 +1,264 @@ +import React from 'react'; +import {act} from '@testing-library/react'; + +import {frontend, Entry, useContentElementViewTimelineProgress} from 'pageflow-scrolled/frontend'; + +import {renderInEntry} from 'support'; +import { + fakeBoundingClientRectsByClassName, + fakeBoundingClientRectsByTestId +} from 'support/fakeBoundingClientRects'; + +import scrollSpaceStyles from 'frontend/ContentElementScrollSpace.module.css'; +import twoColumnStyles from 'frontend/layouts/TwoColumn.module.css'; + +describe('useContentElementViewTimelineProgress', () => { + beforeEach(() => { + window.innerHeight = 1000; + }); + + afterEach(() => jest.restoreAllMocks()); + + function renderTestContentElement({onProgress, range, viewTimeline = true, position} = {}) { + frontend.contentElementTypes.register('test', { + viewTimeline, + + component: function Test() { + useContentElementViewTimelineProgress({range, onProgress}); + return
; + } + }); + + return renderInEntry(, { + seed: {contentElements: [{typeName: 'test', configuration: {position}}]} + }); + } + + 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); + }); + + 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); + }); + }); + + 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 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 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 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); + 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(() => {}); + + 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..fbda4d0734 --- /dev/null +++ b/entry_types/scrolled/package/spec/frontend/viewTimelineRanges-spec.js @@ -0,0 +1,237 @@ +import {getViewTimelineProgress} from 'frontend/viewTimelineRanges'; + +describe('getViewTimelineProgress', () => { + function progress({range = 'cover', top, height = 500, viewportHeight = 1000}) { + return getViewTimelineProgress({ + range, + subjectRect: {top, height}, + elementRect: {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('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); + }); + + 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); + }); + + 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 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); + }); + + 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); + }); + }); + + 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 + })).toEqual(0.5); + }); + }); + + it('throws descriptive error for unknown range', () => { + expect(() => progress({range: 'crossing', top: 0})) + .toThrow(/Unknown view timeline range 'crossing'.*inFocus/); + }); +}); 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/spec/support/index.js b/entry_types/scrolled/package/spec/support/index.js index 563b3386de..d0582f0dcb 100644 --- a/entry_types/scrolled/package/spec/support/index.js +++ b/entry_types/scrolled/package/spec/support/index.js @@ -3,6 +3,7 @@ export * from 'pageflow-scrolled/testHelpers'; export * from './factories'; export * from './fakeWindows'; export * from './renderContentElementConfigurationEditor'; +export * from './scrollRangeSelectInput'; export * from './scrollPositionLifecycle'; export * from './tick'; export * from './useFakeXhr'; diff --git a/entry_types/scrolled/package/spec/support/scrollRangeSelectInput.js b/entry_types/scrolled/package/spec/support/scrollRangeSelectInput.js new file mode 100644 index 0000000000..28c2e3d752 --- /dev/null +++ b/entry_types/scrolled/package/spec/support/scrollRangeSelectInput.js @@ -0,0 +1,20 @@ +import userEvent from '@testing-library/user-event'; +import {within} from '@testing-library/dom'; +import {Input} from 'pageflow/testHelpers'; + +import styles from 'editor/views/inputs/ScrollRangeSelectInputView.module.css'; + +// Options of the scroll range select are only rendered while the +// dropdown is open. Read the name of each range from the description, +// since options also contain the play progress of their illustration. +export async function scrollRangeNames(propertyName, {inView}) { + const input = Input.findByPropertyName(propertyName, {inView, visible: true}); + const queries = within(input.$el[0]); + const user = userEvent.setup(); + + await user.click(queries.getByRole('button')); + + return queries.getAllByRole('option').map( + option => option.querySelector(`.${styles.description}`).textContent + ); +} diff --git a/entry_types/scrolled/package/src/contentElements/lottieAnimation/LottieAnimation.js b/entry_types/scrolled/package/src/contentElements/lottieAnimation/LottieAnimation.js index 4fae924b02..2aec57c784 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', scrollRange = 'cover'} = configuration; + return ( @@ -39,8 +42,10 @@ export function LottieAnimation({configuration}) { {lottieFile && shouldLoad && { + progressRef.current = progress; + + if (isLoadedRef.current) { + const dotLottie = dotLottieRef.current; + dotLottie.setFrame(progress * (dotLottie.totalFrames - 1)); + } + }, []); + + useContentElementViewTimelineProgress({ + range: scrollRange, + onProgress: seekOnScroll ? seek : null + }); + useEffect(() => { const dotLottie = new DotLottie({ canvas: canvasRef.current, @@ -79,7 +102,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 +110,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 +123,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/editor/index.js b/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/index.js index 3b78b3692d..3eb8189e13 100644 --- a/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/index.js +++ b/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/index.js @@ -1,4 +1,9 @@ -import {editor, ImageModifierListInputView, InlineFileRightsMenuItem} from 'pageflow-scrolled/editor'; +import { + editor, + ImageModifierListInputView, + InlineFileRightsMenuItem, + ScrollRangeSelectInputView +} from 'pageflow-scrolled/editor'; import {processImageModifiers} from 'pageflow-scrolled/frontend'; import {FileInputView} from 'pageflow/editor'; import {SelectInputView, SeparatorView} from 'pageflow/ui'; @@ -22,7 +27,14 @@ editor.fileTypes.register('lottie_files', { matchUpload: upload => /\.lottie$/i.test(upload.name) }); -const playbackModes = ['loop', 'playOnce']; +const playbackModes = ['loop', 'playOnce', 'scroll']; +const scrollRanges = ['cover', 'contain', 'inFocus', 'entry']; +const pinnedPositions = ['sticky', 'standAlone']; + +const scrollRangeValuesKey = + 'pageflow_scrolled.editor.content_elements.lottieAnimation.attributes.scrollRange.values'; +const pinnedScrollRangeKeys = ['cover', 'contain', 'inFocusWhenPinned', 'entry'] + .map(name => `${scrollRangeValuesKey}.${name}`); editor.contentElementTypes.register('lottieAnimation', { pictogram, @@ -39,7 +51,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 +73,25 @@ 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', ScrollRangeSelectInputView, { + values: scrollRanges, + ...scrollRangeIllustration(contentElement), + visibleBinding: ['playbackMode', 'position'], + visible: ([playbackMode]) => + playbackMode === 'scroll' && !staysInPlace(contentElement) + }); + this.input('scrollRange', ScrollRangeSelectInputView, { + values: scrollRanges, + translationKeys: pinnedScrollRangeKeys, + ...scrollRangeIllustration(contentElement), + visibleBinding: ['playbackMode', 'position'], + visible: ([playbackMode]) => + playbackMode === 'scroll' && staysInPlace(contentElement) + }); this.view(SeparatorView); @@ -73,3 +104,17 @@ 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()); +} + +// Illustrate the ranges with the element as it looks in its section. +function scrollRangeIllustration(contentElement) { + return { + position: () => contentElement.getResolvedPosition(), + sectionLayout: () => contentElement.section.configuration.get('layout') + }; +} 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 }); 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/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/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/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 new file mode 100644 index 0000000000..0f26fee941 --- /dev/null +++ b/entry_types/scrolled/package/src/editor/views/inputs/visualizations/ContentElementVisualization.js @@ -0,0 +1,116 @@ +import React, {forwardRef} from 'react'; +import classNames from 'classnames'; +import {getViewTimelineProgress} from 'pageflow-scrolled/frontend'; + +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, narrowBlock, scrollRoom, viewportCenter, children}, ref +) { + return ( +