);
}
diff --git a/entry_types/scrolled/package/src/frontend/index.js b/entry_types/scrolled/package/src/frontend/index.js
index e837c4dc79..7e216b0b65 100644
--- a/entry_types/scrolled/package/src/frontend/index.js
+++ b/entry_types/scrolled/package/src/frontend/index.js
@@ -99,6 +99,11 @@ export {
useContentElementLifecycle,
ContentElementLifecycleContext
} from './useContentElementLifecycle';
+export {
+ useContentElementViewTimelineProgress,
+ ContentElementViewTimelineContext
+} from './useContentElementViewTimelineProgress';
+export {getViewTimelineProgress} from './viewTimelineRanges';
export {useCurrentChapter} from './useCurrentChapter';
export {useIsStaticPreview} from './useScrollPositionLifecycle';
export {useMediaMuted, useOnUnmuteMedia} from './useMediaMuted';
diff --git a/entry_types/scrolled/package/src/frontend/layouts/TwoColumn.js b/entry_types/scrolled/package/src/frontend/layouts/TwoColumn.js
index 0e7080350c..d3337b56be 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 {ViewTimelinePinProvider} 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();
+
+ 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
new file mode 100644
index 0000000000..9d9e66f837
--- /dev/null
+++ b/entry_types/scrolled/package/src/frontend/useContentElementViewTimelineProgress.js
@@ -0,0 +1,215 @@
+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 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);
+
+ if (viewTimeline) {
+ return (
+
+ {children}
+
+ );
+ }
+ else {
+ return children;
+ }
+}
+
+function ViewTimelineProvider({children}) {
+ const getPinnedElements = useContext(ViewTimelinePinContext);
+ const ownElementRef = 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 getElements = useCallback(
+ () => getPinnedElements ?
+ getPinnedElements() :
+ {subject: ownElementRef.current, element: ownElementRef.current},
+ [getPinnedElements]
+ );
+
+ const viewTimeline = useMemo(() => ({
+ subscribe(range, callback) {
+ const subscription = {range, callback};
+
+ subscriptionsRef.current.add(subscription);
+ setHasSubscriptions(true);
+ update(getElements(), [subscription]);
+
+ return () => {
+ subscriptionsRef.current.delete(subscription);
+ setHasSubscriptions(subscriptionsRef.current.size > 0);
+ };
+ }
+ }), [getElements]);
+
+ useEffect(() => {
+ if (!hasSubscriptions) {
+ return;
+ }
+
+ const subscriptions = subscriptionsRef.current;
+
+ let animationFrame;
+
+ function handle() {
+ if (animationFrame) {
+ return;
+ }
+
+ animationFrame = requestAnimationFrame(() => {
+ animationFrame = null;
+ update(getElements(), subscriptions);
+ });
+ }
+
+ window.addEventListener('scroll', handle);
+ window.addEventListener('resize', handle);
+
+ return () => {
+ cancelAnimationFrame(animationFrame);
+
+ window.removeEventListener('scroll', handle);
+ window.removeEventListener('resize', handle);
+ };
+ }, [getElements, hasSubscriptions]);
+
+ const content = (
+
+ {children}
+
+ );
+
+ if (getPinnedElements) {
+ return content;
+ }
+
+ return (
+
+ {content}
+
+ );
+}
+
+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,
+ subjectRect,
+ elementRect,
+ 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.
+ *
+ * * `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.
+ *
+ * * `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.
+ *
+ * * `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
+ * 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..b9d2c4dabd
--- /dev/null
+++ b/entry_types/scrolled/package/src/frontend/viewTimelineRanges.js
@@ -0,0 +1,98 @@
+// Offsets of the subject's top edge relative to the viewport's top
+// 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,
+
+ firstContained: ({viewportHeight, elementHeight}) => viewportHeight - elementHeight,
+
+ reachesCenter: ({viewportHeight}) => viewportHeight / 2,
+
+ leavesCenter: ({subjectHeight, viewportHeight}) => viewportHeight / 2 - subjectHeight,
+
+ lastContained: ({subjectHeight, elementHeight}) => elementHeight - 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 = {
+ // Mirror the named ranges of CSS scroll driven animations.
+ cover: ['firstVisible', 'lastVisible'],
+ contain: ['firstContained', 'lastContained'],
+ entry: ['firstVisible', 'firstContained'],
+ exit: ['lastContained', 'lastVisible'],
+
+ // Same part of the page during which content elements become active
+ // and autoplayed videos play.
+ center: ['reachesCenter', 'leavesCenter'],
+
+ // Only elements that components like TwoColumn or
+ // ContentElementScrollSpace pin in the viewport have a pinned phase.
+ 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 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: ${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 = hasPinnedPhase ? subjectRect : elementRect;
+
+ const [start, end] = orderEdges(milestoneNames.map(name => milestones[name]({
+ subjectHeight: subject.height,
+ elementTop: elementRect.top,
+ elementHeight: elementRect.height,
+ viewportHeight
+ })));
+
+ if (start === end) {
+ return subject.top <= start ? 1 : 0;
+ }
+
+ 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) {
+ 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..fb8e85dcf9 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,15 @@ 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. 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`}.
* @param {boolean|Object} [options.inlineEditing] -
@@ -47,6 +54,8 @@ import {renderInEntryWithScrollPositionLifecycle} from './scrollPositionLifecycl
* inlineEditing: {isSelected: true}
* });
* simulateScrollPosition('near viewport');
+ * simulateScrollProgress(0.5);
+ * simulateScrollProgress(0.5, {range: 'pinned'});
* triggerEditorCommand({type: 'HIGHLIGHT'});
* simulateStorylineMode('background');
*/
@@ -58,6 +67,20 @@ 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) {
+ function handleProgress(progress, options) {
+ if (!options.range || options.range === range) {
+ callback(progress);
+ }
+ }
+
+ viewTimelineEmitter.on('progress', handleProgress);
+ return () => viewTimelineEmitter.off('progress', handleProgress);
+ }
+ };
const inlineEditingConfig = resolveInlineEditing(inlineEditing);
@@ -86,7 +109,9 @@ export function renderInContentElement(ui, {inlineEditing,
return (
- {tree}
+
+ {tree}
+
);
@@ -116,6 +141,11 @@ export function renderInContentElement(ui, {inlineEditing,
act(() => {
storylineEmitter.trigger('storylineMode', mode)
});
+ },
+ simulateScrollProgress(progress, {range} = {}) {
+ act(() => {
+ viewTimelineEmitter.trigger('progress', progress, {range})
+ });
}
};
}
diff --git a/package/src/testHelpers/dominos/ui/index.js b/package/src/testHelpers/dominos/ui/index.js
index 546d5b1de4..ee3834ec29 100644
--- a/package/src/testHelpers/dominos/ui/index.js
+++ b/package/src/testHelpers/dominos/ui/index.js
@@ -2,5 +2,6 @@ export * from './ConfigurationEditor'
export * from './ConfigurationEditorTab'
export * from './Table'
export * from './Tabs';
+export {Base as Input} from './inputs/Base'
export * from './inputs/RadioButtonGroupInput'
export * from './inputs/SelectInput'
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');