/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at http://mozilla.org/MPL/2.0/. */
#include "ViewTimeline.h"
#include "mozilla/Keyframe.h"
#include "mozilla/ScrollContainerFrame.h"
#include "mozilla/ServoCSSParser.h"
#include "mozilla/ServoStyleSet.h"
#include "mozilla/dom/Animation.h"
#include "mozilla/dom/CSSUnitValue.h"
#include "mozilla/dom/Document.h"
#include "mozilla/dom/DocumentInlines.h"
#include "mozilla/dom/ElementInlines.h"
#include "mozilla/dom/ViewTimelineBinding.h"
#include "nsComputedDOMStyle.h"
#include "nsLayoutUtils.h"
#include "nsPresContext.h"
namespace mozilla::dom {
NS_IMPL_CYCLE_COLLECTION_INHERITED(ViewTimeline, ScrollTimeline, mSubject)
NS_IMPL_ISUPPORTS_CYCLE_COLLECTION_INHERITED_0(ViewTimeline, ScrollTimeline)
/* static */
already_AddRefed<ViewTimeline> ViewTimeline::MakeNamed(
Document* aDocument, Element* aSubject,
const PseudoStyleRequest& aPseudoRequest, StyleScrollAxis aAxis,
const StyleViewTimelineInset& aInset) {
MOZ_ASSERT(NS_IsMainThread());
// 1. Create an anonymous scroller, as if `scroll(nearest)`.
auto scroller = ScrollerInfo::Anonymous(
StyleScroller::Nearest,
NonOwningAnimationTarget{aSubject, aPseudoRequest});
// 2. Create timeline.
return MakeAndAddRef<ViewTimeline>(aDocument, scroller, aAxis, aSubject,
aPseudoRequest.mType, aInset);
}
/* static */
already_AddRefed<ViewTimeline> ViewTimeline::MakeAnonymous(
Document* aDocument,
const NonOwningAnimationTarget& aTarget,
StyleScrollAxis aAxis,
const StyleViewTimelineInset& aInset) {
// view() finds the nearest scroll container from the animation target.
auto scroller = ScrollerInfo::Anonymous(StyleScroller::Nearest, aTarget);
return MakeAndAddRef<ViewTimeline>(aDocument, scroller, aAxis,
aTarget.mElement,
aTarget.mPseudoRequest.mType, aInset);
}
JSObject* ViewTimeline::WrapObject(JSContext* aCx,
JS::Handle<JSObject*> aGivenProto) {
return ViewTimeline_Binding::Wrap(aCx, this, aGivenProto);
}
static MOZ_CAN_RUN_SCRIPT Maybe<StyleViewTimelineInset>
ParseAndComputeInsetString(
const nsACString& aInsetString, Element* aSubject,
const Document* aDocument) {
if (!aSubject) {
// Use default.
return Some(StyleViewTimelineInset());
}
// We flush and get the computed style to compute the insets. The flush is not
// spec'ed but other browsers agree with this now so we follow them.
// https://github.com/w3c/csswg-drafts/issues/13852
//
// Note: ViewTimeline.subject doesn't allow pseudo-element per spec.
// Note: |style| could be null. We handle the null case in
// Servo_ParseAndComputeViewTimelineInset().
RefPtr<
const ComputedStyle> style = nsComputedDOMStyle::GetComputedStyle(
aSubject, PseudoStyleRequest::NotPseudo());
const StylePerDocumentStyleData* rawData =
aDocument->EnsureStyleSet().RawData();
StyleViewTimelineInset inset;
if (!ServoCSSParser::ParseAndComputeViewTimelineInset(
aInsetString, aSubject, style, rawData, inset)) {
return Nothing();
}
return Some(std::move(inset));
}
/* static */
already_AddRefed<ViewTimeline> ViewTimeline::Constructor(
const GlobalObject& aGlobal,
const ViewTimelineOptions& aOptions,
ErrorResult& aRv) {
RefPtr<Document> doc =
AnimationUtils::GetCurrentRealmDocument(aGlobal.Context());
if (!doc) {
aRv.
Throw(NS_ERROR_FAILURE);
return nullptr;
}
// The spec doesn't provide the default value for element, so we use null
// subject to align the behavior with other browsers.
RefPtr<Element> subject =
aOptions.mSubject.WasPassed() ? &aOptions.mSubject.Value() : nullptr;
StyleScrollAxis axis;
switch (aOptions.mAxis) {
case dom::ScrollAxis::Block:
axis = StyleScrollAxis::Block;
break;
case dom::ScrollAxis::
Inline:
axis = StyleScrollAxis::
Inline;
break;
case dom::ScrollAxis::X:
axis = StyleScrollAxis::X;
break;
case dom::ScrollAxis::Y:
axis = StyleScrollAxis::Y;
break;
}
StyleViewTimelineInset inset;
if (aOptions.mInset.IsUTF8String()) {
// If a DOMString value is provided as an inset, parse it as a
// <'view-timeline-inset'> value;
Maybe<StyleViewTimelineInset> value = ParseAndComputeInsetString(
aOptions.mInset.GetAsUTF8String(), subject, doc);
if (!value) {
// We throw TypeError for the invalid inset, including DOMString, just
// like the invalid sequence case per spec.
aRv.ThrowTypeError(
"Invalid inset string");
return nullptr;
}
inset = std::move(*value);
}
else {
if (!StaticPrefs::layout_css_typed_om_enabled()) {
// CSSKeywordValue and CSSNumericValue are disabled.
aRv.
Throw(NS_ERROR_DOM_NOT_SUPPORTED_ERR);
return nullptr;
}
// if a sequence is provided, the first value represents the start inset and
// the second value represents the end inset. If the sequence has only one
// value, it is duplicated. If it has zero values or more than two values,
// or if it contains a CSSKeywordValue whose value is not "auto", throw a
// TypeError.
// FIXME: Bug 2016880. Handle the sequence of CSSNumericValue and
// CSSKeywordValue.
aRv.ThrowTypeError(
"Unsupported");
return nullptr;
}
// Set the source of timeline to the subject’s nearest ancestor scroll
// container element.
// Note: if subject is null, we use null source as well.
ScrollerInfo scroller = ScrollerInfo::Anonymous(
subject ? ScrollerInfo::Type::Nearest : ScrollerInfo::Type::Provided,
subject, PseudoStyleRequest::NotPseudo());
RefPtr<ViewTimeline> result = MakeAndAddRef<ViewTimeline>(
doc, scroller, axis, subject, PseudoStyleType::NotPseudo, inset);
if (subject) {
// Maybe our nearested scroller already exists, try to compute the current
// time.
result->UpdateCachedCurrentTime();
}
return result.forget();
}
already_AddRefed<CSSNumericValue> ViewTimeline::GetStartOffset(
ErrorResult& aRv)
const {
auto data = ComputeTimelineData();
if (!data) {
return nullptr;
}
if (!StaticPrefs::layout_css_typed_om_enabled()) {
aRv.
Throw(NS_ERROR_DOM_NOT_SUPPORTED_ERR);
return nullptr;
}
return MakeAndAddRef<CSSUnitValue>(
GetParentObject(), nsPresContext::AppUnitsToDoubleCSSPixels(data->mStart),
"px"_ns);
}
already_AddRefed<CSSNumericValue> ViewTimeline::GetEndOffset(
ErrorResult& aRv)
const {
auto data = ComputeTimelineData();
if (!data) {
return nullptr;
}
if (!StaticPrefs::layout_css_typed_om_enabled()) {
aRv.
Throw(NS_ERROR_DOM_NOT_SUPPORTED_ERR);
return nullptr;
}
return MakeAndAddRef<CSSUnitValue>(
GetParentObject(), nsPresContext::AppUnitsToDoubleCSSPixels(data->mEnd),
"px"_ns);
}
void ViewTimeline::ReplacePropertiesWith(
Element* aSubjectElement,
const PseudoStyleRequest& aPseudoRequest,
nsAtom* aName, StyleScrollAxis aAxis,
const StyleViewTimelineInset& aInset) {
mSubject = aSubjectElement;
mSubjectPseudoType = aPseudoRequest.mType;
mAxis = aAxis;
// FIXME: Bug 1817073. We assume it is a non-animatable value for now.
mInset = aInset;
for (
auto* anim = mAnimationOrder.getFirst(); anim;
anim = static_cast<LinkedListElement<Animation>*>(anim)->getNext()) {
MOZ_ASSERT(anim->GetTimeline() == this);
MOZ_ASSERT(anim->GetTimelineName() == aName);
// Set this so we just PostUpdate() for this animation.
// FIXME(dshin, bug 1737927): Mutation observer may need to be notified.
anim->SetTimeline(this, aName, Animation::FromJS::No);
}
}
static std::pair<nscoord, nscoord> ComputeInsets(
const ScrollContainerFrame* aScrollContainerFrame,
const layers::ScrollDirection aOrientation,
const StyleScrollAxis aAxis,
const StyleViewTimelineInset& aInset) {
// If view-timeline-inset is auto, it indicates to use the value of
// scroll-padding. We use logical dimension to map that start/end offset to
// the corresponding scroll-padding-{inline|block}-{start|end} values.
const WritingMode wm =
aScrollContainerFrame->GetScrolledFrame()->GetWritingMode();
const auto& scrollPadding =
LogicalMargin(wm, aScrollContainerFrame->GetScrollPadding());
const bool isBlockAxis = aAxis == StyleScrollAxis::Block ||
(aAxis == StyleScrollAxis::X && wm.IsVertical()) ||
(aAxis == StyleScrollAxis::Y && !wm.IsVertical());
// The percentages of view-timelne-inset is relative to the corresponding
// dimension of the relevant scrollport.
// https://drafts.csswg.org/scroll-animations-1/#view-timeline-inset
const nsRect scrollPort = aScrollContainerFrame->GetScrollPortRect();
const nscoord percentageBasis =
aOrientation == layers::ScrollDirection::eHorizontal ? scrollPort.width
: scrollPort.height;
nscoord startInset =
aInset.start.IsAuto()
? (isBlockAxis ? scrollPadding.BStart(wm) : scrollPadding.IStart(wm))
: aInset.start.AsLengthPercentage().Resolve(percentageBasis);
nscoord endInset =
aInset.end.IsAuto()
? (isBlockAxis ? scrollPadding.BEnd(wm) : scrollPadding.IEnd(wm))
: aInset.end.AsLengthPercentage().Resolve(percentageBasis);
return {startInset, endInset};
}
bool ViewTimeline::UpdateCachedCurrentTime() {
const auto prevCachedCurrentTime = std::move(mCachedCurrentTime);
mCachedCurrentTime.reset();
const auto state = GetState();
// If no layout box, this timeline is inactive.
if (
const auto* e = state.mSource.mElement; !e || !e->GetPrimaryFrame()) {
return prevCachedCurrentTime.isSome();
}
// if this is not a scroller container, this timeline is inactive.
const ScrollContainerFrame* scrollContainerFrame =
state.GetScrollContainerFrame();
if (!scrollContainerFrame) {
return prevCachedCurrentTime.isSome();
}
// Don't try to update against a frame that hasn't been laid out yet.
if (scrollContainerFrame->HasAnyStateBits(NS_FRAME_FIRST_REFLOW)) {
return prevCachedCurrentTime.isSome();
}
// Note: We may fail to get the pseudo element (or its primary frame) if it is
// not generated yet or just get destroyed, while we are sampling this view
// timeline.
// FIXME: Bug 1954230. It's probably a case we need to discard this timeline.
// For now, this is just a hot fix.
MOZ_ASSERT(mSubject,
"We should have a subject to create this view timeline");
const Element* subjectElement =
mSubject->GetPseudoElement(PseudoStyleRequest(mSubjectPseudoType));
const nsIFrame* subject =
subjectElement ? subjectElement->GetPrimaryFrame() : nullptr;
if (!subject) {
// No principal box of the subject, so we cannot compute the offset. This
// may happen when we clear all animation collections during unbinding from
// the tree.
return prevCachedCurrentTime.isSome();
}
// The current scroll position and scroll range.
const nsPoint& scrollPosition = scrollContainerFrame->GetScrollPosition();
const nsRect& scrollRange = scrollContainerFrame->GetScrollRange();
// In order to get the distance between the subject and the scrollport
// properly, we use the position based on the domain of the scrolled frame,
// instead of the scroll container frame.
const nsIFrame* scrolledFrame = scrollContainerFrame->GetScrolledFrame();
MOZ_ASSERT(scrolledFrame);
const nsRect subjectRect(subject->GetOffsetTo(scrolledFrame),
subject->GetSize());
// Use scrollport size (i.e. padding box size - scrollbar size), which is used
// for calculating the view progress visibility range.
// https://drafts.csswg.org/scroll-animations/#view-progress-visibility-range
const nsRect scrollPort = scrollContainerFrame->GetScrollPortRect();
// |sideInsets.mEnd| is used to adjust the start offset, and
// |sideInsets.mStart| is used to adjust the end offset. This is because
// |sideInsets.mStart| refers to logical start side [1] of the source box
// (i.e. the box of the scrollport), where as |startOffset| refers to the
// start of the timeline, and similarly for end side/offset. [1]
// https://drafts.csswg.org/css-writing-modes-4/#css-start
const auto orientation = state.Axis();
const auto sideInsets =
ComputeInsets(scrollContainerFrame, orientation, mAxis, mInset);
// Adjuct the positions and sizes based on the physical axis.
const WritingMode wm = scrolledFrame->GetWritingMode();
switch (orientation) {
case layers::ScrollDirection::eVertical: {
// Mirror of the R-L case below for bottom-to-top scrolling (vertical
// writing-mode + direction:rtl), where the inline axis is vertical and
// reversed, so scrollPosition.y is zero or negative.
const bool isBottomToTop = wm.IsVertical() && wm.IsInlineReversed();
mCachedCurrentTime.emplace(CurrentTimeData{
ScrollTimeline::CurrentTimeData{scrollPosition.y, scrollRange.height},
scrollPort.height,
isBottomToTop ? scrolledFrame->GetSize().height - subjectRect.YMost()
: subjectRect.y,
subjectRect.height, sideInsets.first, sideInsets.second});
break;
}
case layers::ScrollDirection::eHorizontal:
mCachedCurrentTime.emplace(CurrentTimeData{
ScrollTimeline::CurrentTimeData{scrollPosition.x, scrollRange.width},
scrollPort.width,
// |mSubjectPosition| should be the position of the start border edge
// of the subject, so for R-L case, we have to use XMost() as the
// start border edge of the subject, and compute its position by using
// the x-most side of the scrolled frame as the origin on the
// horizontal axis.
wm.IsPhysicalRTL()
? scrolledFrame->GetSize().width - subjectRect.XMost()
: subjectRect.x,
subjectRect.width, sideInsets.first, sideInsets.second});
break;
}
if (!prevCachedCurrentTime ||
prevCachedCurrentTime->IsChanged(*mCachedCurrentTime)) {
TimelineDataDidChange();
}
return mCachedCurrentTime != prevCachedCurrentTime;
}
// FIXME: Bug 2018678. Need to be adjusted for sticky positioning element.
// https://drafts.csswg.org/scroll-animations-1/#view-timelines-ranges
std::pair<nscoord, nscoord> ViewTimeline::IntervalForTimelineRangeName(
const StyleTimelineRangeName aName,
const ScrollTimeline::ComputedTimelineData& aData)
const {
MOZ_ASSERT(mCachedCurrentTime,
"We should have a cached current time");
// The following variable names are based on the vertical scrolling direction
// and the subject becomes visible from the bottom of the scroll port.
// The scroll offset when we align the start border edge of the subject with
// the end edge of the scroll port.
const nscoord alignedSubjectStartViewEnd = aData.mStart;
// The scroll offset when we align the end border edge of the subject with
// the start edge of the scroll port.
const nscoord alignedSubjectEndViewStart = aData.mEnd;
// The scroll offset when we align the start border edge of the subject with
// the start edge of the scroll port.
const nscoord alignedSubjectStartViewStart =
alignedSubjectEndViewStart - mCachedCurrentTime->mSubjectSize;
// The scroll offset when we align the end border edge of the subject with the
// end edge of the scroll port.
const nscoord alignedSubjectEndViewEnd =
alignedSubjectStartViewEnd + mCachedCurrentTime->mSubjectSize;
// Precompute the range of `contain` to avoid the code duplication. See below
// for more details.
const nscoord containStart =
std::min(alignedSubjectStartViewStart, alignedSubjectEndViewEnd);
const nscoord containEnd =
std::max(alignedSubjectStartViewStart, alignedSubjectEndViewEnd);
// FIXME: Bug 2030453. Check the case for RTL for horizontal axis. Perhaps we
// have to swap these two values.
switch (aName) {
case StyleTimelineRangeName::None:
case StyleTimelineRangeName::Normal:
// The default behavior is equalivant to `cover` for view timeline.
case StyleTimelineRangeName::Cover:
// Represents the full range of the view progress timeline:
// * 0% progress represents the latest position at which the start border
// edge of the element’s principal box coincides with the end edge of
// its view progress visibility range.
// * 100% progress represents the earliest position at which the end
// border edge of the element’s principal box coincides with the start
// edge of its view progress visibility range.
return {alignedSubjectStartViewEnd, alignedSubjectEndViewStart};
case StyleTimelineRangeName::Contain:
// Represents the range during which the principal box is either fully
// contained by, or fully covers, its view progress visibility range
// within the scrollport.
// 0% progress represents the earliest position at which either:
// 1. the start border edge of the element’s principal box coincides
// with the start edge of its view progress visibility range.
// 2. the end border edge of the element’s principal box coincides with
// the end edge of its view progress visibility range.
// 100% progress represents the latest position at which either:
// 1. the start border edge of the element’s principal box coincides
// with the start edge of its view progress visibility range.
// 2. the end border edge of the element’s principal box coincides with
// the end edge of its view progress visibility range.
//
// Note that we swap the values if the subject size is larger than the
// scrollport size. That's why there are 2 options for 0% and 2 options
// for 100% in the spec.
//
// For more visual explanation, see:
// https://github.com/w3c/csswg-drafts/issues/7973#issuecomment-1427150014
return {containStart, containEnd};
case StyleTimelineRangeName::Entry:
// Represents the range during which the principal box is entering the
// view progress visibility range.
// * 0% is equivalent to 0% of the cover range.
// * 100% is equivalent to 0% of the contain range.
return {alignedSubjectStartViewEnd, containStart};
case StyleTimelineRangeName::
Exit:
// Represents the range during which the principal box is exiting the view
// progress visibility range.
// * 0% is equivalent to 100% of the contain range.
// * 100% is equivalent to 100% of the cover range.
return {containEnd, alignedSubjectEndViewStart};
case StyleTimelineRangeName::EntryCrossing:
// Represents the range during which the principal box crosses the end
// border edge.
// * 0% is equivalent to 0% of the cover range.
//
// Note that the duration of the entry-crossing range is equal to the
// subject size, so this is equivalent to
// `{alignedSubjectStartViewEnd,
// alignedSubjectStartViewEnd + mCachedCurrentTime->mSubjectSize}`.
return {alignedSubjectStartViewEnd, alignedSubjectEndViewEnd};
case StyleTimelineRangeName::ExitCrossing:
// Represents the range during which the principal box crosses the start
// border edge.
// * 100% is equivalent to 100% of the cover range.
//
// Note that the duration of the exit-crossing range is equal to the
// subject size, so this is equivalent to
// `{alignedSubjectEndViewStart - mCachedCurrentTime->mSubjectSize,
// alignedSubjectEndViewStart}`.
return {alignedSubjectStartViewStart, alignedSubjectEndViewStart};
case StyleTimelineRangeName::Scroll:
// Represents the full range of the scroll container on which the view
// progress timeline is defined.
//
// So this is equivalent to scroll timeline's full range.
return {
0, mCachedCurrentTime->mScrollData.mMaxScrollOffset};
}
MOZ_ASSERT_UNREACHABLE(
"All cases should be handled.");
// Use cover as the default value. However, we shouldn't be here.
return {alignedSubjectStartViewEnd, alignedSubjectEndViewStart};
}
// Calculate the offset (as a percentage) for a pair of range name and offset,
// based on the full timeline range (i.e. `cover` for view-timeline).
template <typename F>
double ViewTimeline::ComputeOffsetToTimelineRange(
const StyleTimelineRangeName& aName,
const ScrollTimeline::ComputedTimelineData& aData,
F&& aFuncToResolveValue)
const {
const auto [nameStart, nameEnd] = IntervalForTimelineRangeName(aName, aData);
const auto timelineRange = aData.mEnd - aData.mStart;
const auto nameRange = nameEnd - nameStart;
const auto positionInNameRange = nameStart + aFuncToResolveValue(nameRange);
const auto positionInTimeline = positionInNameRange - aData.mStart;
return static_cast<
double>(positionInTimeline) /
static_cast<
double>(timelineRange);
}
Maybe<
double> ViewTimeline::MapKeyframeOffsetToOffset(
const StyleTimelineRangeName aName,
const double aPercentage)
const {
const auto& data = ComputeTimelineData();
if (!data) {
return Nothing();
}
return Some(ComputeOffsetToTimelineRange(
aName, *data,
[&](
const nscoord aBasis) {
return aPercentage * aBasis; }));
}
std::pair<
double,
double> ViewTimeline::IntervalForAttachmentRange(
const AnimationRange& aStyleRange)
const {
const auto& data = ComputeTimelineData();
if (!data) {
// Return the default, [0%, 100%].
return {
0,
1.
0};
}
// Returns the percentage (in double) for this StyleAnimationValue based on
// the full timeline range (i.e. `cover` for view-timeline).
auto computeNamedRangeEdgeAsPercentage =
[&](
const StyleGenericAnimationRangeValue<StyleLengthPercentag
e>&
aValue) {
return ComputeOffsetToTimelineRange(
aValue.name, *data,
[&](const nscoord aBasis) { return aValue.lp.Resolve(aBasis); });
};
return {computeNamedRangeEdgeAsPercentage(aStyleRange.mStart),
computeNamedRangeEdgeAsPercentage(aStyleRange.mEnd)};
}
Maybe<ScrollTimeline::ComputedTimelineData> ViewTimeline::ComputeTimelineData()
const {
if (!mCachedCurrentTime) {
return Nothing();
}
const CurrentTimeData& data = mCachedCurrentTime.ref();
// We use "cover" timeline range as the default full range for view
// timeline.
// https://drafts.csswg.org/scroll-animations-1/#view-timeline-progress
// Note: `mSubjectPosition - mScrollPortSize` means the distance between the
// start border edge of the subject and the end edge of the scrollport.
const nscoord startOffset =
data.mSubjectPosition - data.mScrollPortSize + data.mInsetEnd;
// Note: `mSubjectPosition + mSubjectSize` means the position of the end
// border edge of the subject. When it touches the start edge of the
// scrollport, it is 100%.
const nscoord endOffset =
data.mSubjectPosition + data.mSubjectSize - data.mInsetStart;
return Some(ComputedTimelineData{
data.mScrollData.mPosition,
startOffset,
endOffset,
});
}
} // namespace mozilla::dom