diff options
Diffstat (limited to 'dom/animation')
175 files changed, 26146 insertions, 0 deletions
diff --git a/dom/animation/AnimValuesStyleRule.cpp b/dom/animation/AnimValuesStyleRule.cpp new file mode 100644 index 000000000..dbdffd5b5 --- /dev/null +++ b/dom/animation/AnimValuesStyleRule.cpp @@ -0,0 +1,110 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* 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 "AnimValuesStyleRule.h" +#include "nsRuleData.h" +#include "nsStyleContext.h" + +namespace mozilla { + +NS_IMPL_ISUPPORTS(AnimValuesStyleRule, nsIStyleRule) + +void +AnimValuesStyleRule::MapRuleInfoInto(nsRuleData* aRuleData) +{ + nsStyleContext *contextParent = aRuleData->mStyleContext->GetParent(); + if (contextParent && contextParent->HasPseudoElementData()) { + // Don't apply transitions or animations to things inside of + // pseudo-elements. + // FIXME (Bug 522599): Add tests for this. + + // Prevent structs from being cached on the rule node since we're inside + // a pseudo-element, as we could determine cacheability differently + // when walking the rule tree for a style context that is not inside + // a pseudo-element. Note that nsRuleNode::GetStyle##name_ and GetStyleData + // will never look at cached structs when we're animating things inside + // a pseduo-element, so that we don't incorrectly return a struct that + // is only appropriate for non-pseudo-elements. + aRuleData->mConditions.SetUncacheable(); + return; + } + + for (auto iter = mAnimationValues.ConstIter(); !iter.Done(); iter.Next()) { + nsCSSPropertyID property = static_cast<nsCSSPropertyID>(iter.Key()); + if (aRuleData->mSIDs & nsCachedStyleData::GetBitForSID( + nsCSSProps::kSIDTable[property])) { + nsCSSValue *prop = aRuleData->ValueFor(property); + if (prop->GetUnit() == eCSSUnit_Null) { + DebugOnly<bool> ok = + StyleAnimationValue::UncomputeValue(property, iter.Data(), + *prop); + MOZ_ASSERT(ok, "could not store computed value"); + } + } + } +} + +bool +AnimValuesStyleRule::MightMapInheritedStyleData() +{ + return mStyleBits & NS_STYLE_INHERITED_STRUCT_MASK; +} + +bool +AnimValuesStyleRule::GetDiscretelyAnimatedCSSValue(nsCSSPropertyID aProperty, + nsCSSValue* aValue) +{ + MOZ_ASSERT(false, "GetDiscretelyAnimatedCSSValue is not implemented yet"); + return false; +} + +void +AnimValuesStyleRule::AddValue(nsCSSPropertyID aProperty, + const StyleAnimationValue &aValue) +{ + MOZ_ASSERT(aProperty != eCSSProperty_UNKNOWN, + "Unexpected css property"); + mAnimationValues.Put(aProperty, aValue); + mStyleBits |= + nsCachedStyleData::GetBitForSID(nsCSSProps::kSIDTable[aProperty]); +} + +void +AnimValuesStyleRule::AddValue(nsCSSPropertyID aProperty, + StyleAnimationValue&& aValue) +{ + MOZ_ASSERT(aProperty != eCSSProperty_UNKNOWN, + "Unexpected css property"); + mAnimationValues.Put(aProperty, Move(aValue)); + mStyleBits |= + nsCachedStyleData::GetBitForSID(nsCSSProps::kSIDTable[aProperty]); +} + +#ifdef DEBUG +void +AnimValuesStyleRule::List(FILE* out, int32_t aIndent) const +{ + nsAutoCString str; + for (int32_t index = aIndent; --index >= 0; ) { + str.AppendLiteral(" "); + } + str.AppendLiteral("[anim values] { "); + for (auto iter = mAnimationValues.ConstIter(); !iter.Done(); iter.Next()) { + nsCSSPropertyID property = static_cast<nsCSSPropertyID>(iter.Key()); + str.Append(nsCSSProps::GetStringValue(property)); + str.AppendLiteral(": "); + nsAutoString value; + Unused << + StyleAnimationValue::UncomputeValue(property, iter.Data(), value); + AppendUTF16toUTF8(value, str); + str.AppendLiteral("; "); + } + str.AppendLiteral("}\n"); + fprintf_stderr(out, "%s", str.get()); +} +#endif + +} // namespace mozilla diff --git a/dom/animation/AnimValuesStyleRule.h b/dom/animation/AnimValuesStyleRule.h new file mode 100644 index 000000000..3562014b9 --- /dev/null +++ b/dom/animation/AnimValuesStyleRule.h @@ -0,0 +1,58 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* 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/. */ + +#ifndef mozilla_AnimValuesStyleRule_h +#define mozilla_AnimValuesStyleRule_h + +#include "mozilla/StyleAnimationValue.h" +#include "nsCSSPropertyID.h" +#include "nsCSSPropertyIDSet.h" +#include "nsDataHashtable.h" +#include "nsHashKeys.h" // For nsUint32HashKey +#include "nsIStyleRule.h" +#include "nsISupportsImpl.h" // For NS_DECL_ISUPPORTS +#include "nsRuleNode.h" // For nsCachedStyleData +#include "nsTArray.h" // For nsTArray + +namespace mozilla { + +/** + * A style rule that maps property-StyleAnimationValue pairs. + */ +class AnimValuesStyleRule final : public nsIStyleRule +{ +public: + AnimValuesStyleRule() + : mStyleBits(0) {} + + // nsISupports implementation + NS_DECL_ISUPPORTS + + // nsIStyleRule implementation + void MapRuleInfoInto(nsRuleData* aRuleData) override; + bool MightMapInheritedStyleData() override; + bool GetDiscretelyAnimatedCSSValue(nsCSSPropertyID aProperty, + nsCSSValue* aValue) override; +#ifdef DEBUG + void List(FILE* out = stdout, int32_t aIndent = 0) const override; +#endif + + // For the following functions, it there is already a value for |aProperty| it + // will be replaced with |aValue|. + void AddValue(nsCSSPropertyID aProperty, const StyleAnimationValue &aValue); + void AddValue(nsCSSPropertyID aProperty, StyleAnimationValue&& aValue); + +private: + ~AnimValuesStyleRule() {} + + nsDataHashtable<nsUint32HashKey, StyleAnimationValue> mAnimationValues; + + uint32_t mStyleBits; +}; + +} // namespace mozilla + +#endif // mozilla_AnimValuesStyleRule_h diff --git a/dom/animation/Animation.cpp b/dom/animation/Animation.cpp new file mode 100644 index 000000000..6dd583ed1 --- /dev/null +++ b/dom/animation/Animation.cpp @@ -0,0 +1,1426 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* 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 "Animation.h" +#include "AnimationUtils.h" +#include "mozilla/dom/AnimationBinding.h" +#include "mozilla/dom/AnimationPlaybackEvent.h" +#include "mozilla/dom/DocumentTimeline.h" +#include "mozilla/AnimationTarget.h" +#include "mozilla/AutoRestore.h" +#include "mozilla/AsyncEventDispatcher.h" // For AsyncEventDispatcher +#include "mozilla/Maybe.h" // For Maybe +#include "nsAnimationManager.h" // For CSSAnimation +#include "nsDOMMutationObserver.h" // For nsAutoAnimationMutationBatch +#include "nsIDocument.h" // For nsIDocument +#include "nsIPresShell.h" // For nsIPresShell +#include "nsThreadUtils.h" // For nsRunnableMethod and nsRevocableEventPtr +#include "nsTransitionManager.h" // For CSSTransition +#include "PendingAnimationTracker.h" // For PendingAnimationTracker + +namespace mozilla { +namespace dom { + +// Static members +uint64_t Animation::sNextAnimationIndex = 0; + +NS_IMPL_CYCLE_COLLECTION_INHERITED(Animation, DOMEventTargetHelper, + mTimeline, + mEffect, + mReady, + mFinished) + +NS_IMPL_ADDREF_INHERITED(Animation, DOMEventTargetHelper) +NS_IMPL_RELEASE_INHERITED(Animation, DOMEventTargetHelper) + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION_INHERITED(Animation) +NS_INTERFACE_MAP_END_INHERITING(DOMEventTargetHelper) + +JSObject* +Animation::WrapObject(JSContext* aCx, JS::Handle<JSObject*> aGivenProto) +{ + return dom::AnimationBinding::Wrap(aCx, this, aGivenProto); +} + +// --------------------------------------------------------------------------- +// +// Utility methods +// +// --------------------------------------------------------------------------- + +namespace { + // A wrapper around nsAutoAnimationMutationBatch that looks up the + // appropriate document from the supplied animation. + class MOZ_RAII AutoMutationBatchForAnimation { + public: + explicit AutoMutationBatchForAnimation(const Animation& aAnimation + MOZ_GUARD_OBJECT_NOTIFIER_PARAM) { + MOZ_GUARD_OBJECT_NOTIFIER_INIT; + Maybe<NonOwningAnimationTarget> target = + nsNodeUtils::GetTargetForAnimation(&aAnimation); + if (!target) { + return; + } + + // For mutation observers, we use the OwnerDoc. + nsIDocument* doc = target->mElement->OwnerDoc(); + if (!doc) { + return; + } + + mAutoBatch.emplace(doc); + } + + private: + MOZ_DECL_USE_GUARD_OBJECT_NOTIFIER + Maybe<nsAutoAnimationMutationBatch> mAutoBatch; + }; +} + +// --------------------------------------------------------------------------- +// +// Animation interface: +// +// --------------------------------------------------------------------------- +/* static */ already_AddRefed<Animation> +Animation::Constructor(const GlobalObject& aGlobal, + AnimationEffectReadOnly* aEffect, + const Optional<AnimationTimeline*>& aTimeline, + ErrorResult& aRv) +{ + nsCOMPtr<nsIGlobalObject> global = do_QueryInterface(aGlobal.GetAsSupports()); + RefPtr<Animation> animation = new Animation(global); + + AnimationTimeline* timeline; + if (aTimeline.WasPassed()) { + timeline = aTimeline.Value(); + } else { + nsIDocument* document = + AnimationUtils::GetCurrentRealmDocument(aGlobal.Context()); + if (!document) { + aRv.Throw(NS_ERROR_FAILURE); + return nullptr; + } + timeline = document->Timeline(); + } + + animation->SetTimelineNoUpdate(timeline); + animation->SetEffectNoUpdate(aEffect); + + return animation.forget(); +} + +void +Animation::SetId(const nsAString& aId) +{ + if (mId == aId) { + return; + } + mId = aId; + nsNodeUtils::AnimationChanged(this); +} + +void +Animation::SetEffect(AnimationEffectReadOnly* aEffect) +{ + SetEffectNoUpdate(aEffect); + PostUpdate(); +} + +// https://w3c.github.io/web-animations/#setting-the-target-effect +void +Animation::SetEffectNoUpdate(AnimationEffectReadOnly* aEffect) +{ + RefPtr<Animation> kungFuDeathGrip(this); + + if (mEffect == aEffect) { + return; + } + + AutoMutationBatchForAnimation mb(*this); + bool wasRelevant = mIsRelevant; + + if (mEffect) { + if (!aEffect) { + // If the new effect is null, call ResetPendingTasks before clearing + // mEffect since ResetPendingTasks needs it to get the appropriate + // PendingAnimationTracker. + ResetPendingTasks(); + } + + // We need to notify observers now because once we set mEffect to null + // we won't be able to find the target element to notify. + if (mIsRelevant) { + nsNodeUtils::AnimationRemoved(this); + } + + // Break links with the old effect and then drop it. + RefPtr<AnimationEffectReadOnly> oldEffect = mEffect; + mEffect = nullptr; + oldEffect->SetAnimation(nullptr); + + // The following will not do any notification because mEffect is null. + UpdateRelevance(); + } + + if (aEffect) { + // Break links from the new effect to its previous animation, if any. + RefPtr<AnimationEffectReadOnly> newEffect = aEffect; + Animation* prevAnim = aEffect->GetAnimation(); + if (prevAnim) { + prevAnim->SetEffect(nullptr); + } + + // Create links with the new effect. SetAnimation(this) will also update + // mIsRelevant of this animation, and then notify mutation observer if + // needed by calling Animation::UpdateRelevance(), so we don't need to + // call it again. + mEffect = newEffect; + mEffect->SetAnimation(this); + + // Notify possible add or change. + // If the target is different, the change notification will be ignored by + // AutoMutationBatchForAnimation. + if (wasRelevant && mIsRelevant) { + nsNodeUtils::AnimationChanged(this); + } + + // Reschedule pending pause or pending play tasks. + // If we have a pending animation, it will either be registered + // in the pending animation tracker and have a null pending ready time, + // or, after it has been painted, it will be removed from the tracker + // and assigned a pending ready time. + // After updating the effect we'll typically need to repaint so if we've + // already been assigned a pending ready time, we should clear it and put + // the animation back in the tracker. + if (!mPendingReadyTime.IsNull()) { + mPendingReadyTime.SetNull(); + + nsIDocument* doc = GetRenderedDocument(); + if (doc) { + PendingAnimationTracker* tracker = + doc->GetOrCreatePendingAnimationTracker(); + if (mPendingState == PendingState::PlayPending) { + tracker->AddPlayPending(*this); + } else { + tracker->AddPausePending(*this); + } + } + } + } + + UpdateTiming(SeekFlag::NoSeek, SyncNotifyFlag::Async); +} + +void +Animation::SetTimeline(AnimationTimeline* aTimeline) +{ + SetTimelineNoUpdate(aTimeline); + PostUpdate(); +} + +// https://w3c.github.io/web-animations/#setting-the-timeline +void +Animation::SetTimelineNoUpdate(AnimationTimeline* aTimeline) +{ + if (mTimeline == aTimeline) { + return; + } + + RefPtr<AnimationTimeline> oldTimeline = mTimeline; + if (oldTimeline) { + oldTimeline->RemoveAnimation(this); + } + + mTimeline = aTimeline; + if (!mStartTime.IsNull()) { + mHoldTime.SetNull(); + } + + UpdateTiming(SeekFlag::NoSeek, SyncNotifyFlag::Async); +} + +// https://w3c.github.io/web-animations/#set-the-animation-start-time +void +Animation::SetStartTime(const Nullable<TimeDuration>& aNewStartTime) +{ + if (aNewStartTime == mStartTime) { + return; + } + + AutoMutationBatchForAnimation mb(*this); + + Nullable<TimeDuration> timelineTime; + if (mTimeline) { + // The spec says to check if the timeline is active (has a resolved time) + // before using it here, but we don't need to since it's harmless to set + // the already null time to null. + timelineTime = mTimeline->GetCurrentTime(); + } + if (timelineTime.IsNull() && !aNewStartTime.IsNull()) { + mHoldTime.SetNull(); + } + + Nullable<TimeDuration> previousCurrentTime = GetCurrentTime(); + mStartTime = aNewStartTime; + if (!aNewStartTime.IsNull()) { + if (mPlaybackRate != 0.0) { + mHoldTime.SetNull(); + } + } else { + mHoldTime = previousCurrentTime; + } + + CancelPendingTasks(); + if (mReady) { + // We may have already resolved mReady, but in that case calling + // MaybeResolve is a no-op, so that's okay. + mReady->MaybeResolve(this); + } + + UpdateTiming(SeekFlag::DidSeek, SyncNotifyFlag::Async); + if (IsRelevant()) { + nsNodeUtils::AnimationChanged(this); + } + PostUpdate(); +} + +// https://w3c.github.io/web-animations/#current-time +Nullable<TimeDuration> +Animation::GetCurrentTime() const +{ + Nullable<TimeDuration> result; + if (!mHoldTime.IsNull()) { + result = mHoldTime; + return result; + } + + if (mTimeline && !mStartTime.IsNull()) { + Nullable<TimeDuration> timelineTime = mTimeline->GetCurrentTime(); + if (!timelineTime.IsNull()) { + result.SetValue((timelineTime.Value() - mStartTime.Value()) + .MultDouble(mPlaybackRate)); + } + } + return result; +} + +// https://w3c.github.io/web-animations/#set-the-current-time +void +Animation::SetCurrentTime(const TimeDuration& aSeekTime) +{ + // Return early if the current time has not changed. However, if we + // are pause-pending, then setting the current time to any value + // including the current value has the effect of aborting the + // pause so we should not return early in that case. + if (mPendingState != PendingState::PausePending && + Nullable<TimeDuration>(aSeekTime) == GetCurrentTime()) { + return; + } + + AutoMutationBatchForAnimation mb(*this); + + SilentlySetCurrentTime(aSeekTime); + + if (mPendingState == PendingState::PausePending) { + // Finish the pause operation + mHoldTime.SetValue(aSeekTime); + mStartTime.SetNull(); + + if (mReady) { + mReady->MaybeResolve(this); + } + CancelPendingTasks(); + } + + UpdateTiming(SeekFlag::DidSeek, SyncNotifyFlag::Async); + if (IsRelevant()) { + nsNodeUtils::AnimationChanged(this); + } + PostUpdate(); +} + +// https://w3c.github.io/web-animations/#set-the-animation-playback-rate +void +Animation::SetPlaybackRate(double aPlaybackRate) +{ + if (aPlaybackRate == mPlaybackRate) { + return; + } + + AutoMutationBatchForAnimation mb(*this); + + Nullable<TimeDuration> previousTime = GetCurrentTime(); + mPlaybackRate = aPlaybackRate; + if (!previousTime.IsNull()) { + SetCurrentTime(previousTime.Value()); + } + + // In the case where GetCurrentTime() returns the same result before and + // after updating mPlaybackRate, SetCurrentTime will return early since, + // as far as it can tell, nothing has changed. + // As a result, we need to perform the following updates here: + // - update timing (since, if the sign of the playback rate has changed, our + // finished state may have changed), + // - dispatch a change notification for the changed playback rate, and + // - update the playback rate on animations on layers. + UpdateTiming(SeekFlag::DidSeek, SyncNotifyFlag::Async); + if (IsRelevant()) { + nsNodeUtils::AnimationChanged(this); + } + PostUpdate(); +} + +// https://w3c.github.io/web-animations/#play-state +AnimationPlayState +Animation::PlayState() const +{ + if (mPendingState != PendingState::NotPending) { + return AnimationPlayState::Pending; + } + + Nullable<TimeDuration> currentTime = GetCurrentTime(); + if (currentTime.IsNull()) { + return AnimationPlayState::Idle; + } + + if (mStartTime.IsNull()) { + return AnimationPlayState::Paused; + } + + if ((mPlaybackRate > 0.0 && currentTime.Value() >= EffectEnd()) || + (mPlaybackRate < 0.0 && currentTime.Value() <= TimeDuration())) { + return AnimationPlayState::Finished; + } + + return AnimationPlayState::Running; +} + +Promise* +Animation::GetReady(ErrorResult& aRv) +{ + nsCOMPtr<nsIGlobalObject> global = GetOwnerGlobal(); + if (!mReady && global) { + mReady = Promise::Create(global, aRv); // Lazily create on demand + } + if (!mReady) { + aRv.Throw(NS_ERROR_FAILURE); + } else if (PlayState() != AnimationPlayState::Pending) { + mReady->MaybeResolve(this); + } + return mReady; +} + +Promise* +Animation::GetFinished(ErrorResult& aRv) +{ + nsCOMPtr<nsIGlobalObject> global = GetOwnerGlobal(); + if (!mFinished && global) { + mFinished = Promise::Create(global, aRv); // Lazily create on demand + } + if (!mFinished) { + aRv.Throw(NS_ERROR_FAILURE); + } else if (mFinishedIsResolved) { + MaybeResolveFinishedPromise(); + } + return mFinished; +} + +void +Animation::Cancel() +{ + CancelNoUpdate(); + PostUpdate(); +} + +// https://w3c.github.io/web-animations/#finish-an-animation +void +Animation::Finish(ErrorResult& aRv) +{ + if (mPlaybackRate == 0 || + (mPlaybackRate > 0 && EffectEnd() == TimeDuration::Forever())) { + aRv.Throw(NS_ERROR_DOM_INVALID_STATE_ERR); + return; + } + + AutoMutationBatchForAnimation mb(*this); + + // Seek to the end + TimeDuration limit = + mPlaybackRate > 0 ? TimeDuration(EffectEnd()) : TimeDuration(0); + bool didChange = GetCurrentTime() != Nullable<TimeDuration>(limit); + SilentlySetCurrentTime(limit); + + // If we are paused or play-pending we need to fill in the start time in + // order to transition to the finished state. + // + // We only do this, however, if we have an active timeline. If we have an + // inactive timeline we can't transition into the finished state just like + // we can't transition to the running state (this finished state is really + // a substate of the running state). + if (mStartTime.IsNull() && + mTimeline && + !mTimeline->GetCurrentTime().IsNull()) { + mStartTime.SetValue(mTimeline->GetCurrentTime().Value() - + limit.MultDouble(1.0 / mPlaybackRate)); + didChange = true; + } + + // If we just resolved the start time for a pause or play-pending + // animation, we need to clear the task. We don't do this as a branch of + // the above however since we can have a play-pending animation with a + // resolved start time if we aborted a pause operation. + if (!mStartTime.IsNull() && + (mPendingState == PendingState::PlayPending || + mPendingState == PendingState::PausePending)) { + if (mPendingState == PendingState::PausePending) { + mHoldTime.SetNull(); + } + CancelPendingTasks(); + didChange = true; + if (mReady) { + mReady->MaybeResolve(this); + } + } + UpdateTiming(SeekFlag::DidSeek, SyncNotifyFlag::Sync); + if (didChange && IsRelevant()) { + nsNodeUtils::AnimationChanged(this); + } + PostUpdate(); +} + +void +Animation::Play(ErrorResult& aRv, LimitBehavior aLimitBehavior) +{ + PlayNoUpdate(aRv, aLimitBehavior); + PostUpdate(); +} + +void +Animation::Pause(ErrorResult& aRv) +{ + PauseNoUpdate(aRv); + PostUpdate(); +} + +// https://w3c.github.io/web-animations/#reverse-an-animation +void +Animation::Reverse(ErrorResult& aRv) +{ + if (!mTimeline || mTimeline->GetCurrentTime().IsNull()) { + aRv.Throw(NS_ERROR_DOM_INVALID_STATE_ERR); + return; + } + + if (mPlaybackRate == 0.0) { + return; + } + + AutoMutationBatchForAnimation mb(*this); + + SilentlySetPlaybackRate(-mPlaybackRate); + Play(aRv, LimitBehavior::AutoRewind); + + if (IsRelevant()) { + nsNodeUtils::AnimationChanged(this); + } +} + +// --------------------------------------------------------------------------- +// +// JS wrappers for Animation interface: +// +// --------------------------------------------------------------------------- + +Nullable<double> +Animation::GetStartTimeAsDouble() const +{ + return AnimationUtils::TimeDurationToDouble(mStartTime); +} + +void +Animation::SetStartTimeAsDouble(const Nullable<double>& aStartTime) +{ + return SetStartTime(AnimationUtils::DoubleToTimeDuration(aStartTime)); +} + +Nullable<double> +Animation::GetCurrentTimeAsDouble() const +{ + return AnimationUtils::TimeDurationToDouble(GetCurrentTime()); +} + +void +Animation::SetCurrentTimeAsDouble(const Nullable<double>& aCurrentTime, + ErrorResult& aRv) +{ + if (aCurrentTime.IsNull()) { + if (!GetCurrentTime().IsNull()) { + aRv.Throw(NS_ERROR_DOM_TYPE_ERR); + } + return; + } + + return SetCurrentTime(TimeDuration::FromMilliseconds(aCurrentTime.Value())); +} + +// --------------------------------------------------------------------------- + +void +Animation::Tick() +{ + // Finish pending if we have a pending ready time, but only if we also + // have an active timeline. + if (mPendingState != PendingState::NotPending && + !mPendingReadyTime.IsNull() && + mTimeline && + !mTimeline->GetCurrentTime().IsNull()) { + // Even though mPendingReadyTime is initialized using TimeStamp::Now() + // during the *previous* tick of the refresh driver, it can still be + // ahead of the *current* timeline time when we are using the + // vsync timer so we need to clamp it to the timeline time. + mPendingReadyTime.SetValue(std::min(mTimeline->GetCurrentTime().Value(), + mPendingReadyTime.Value())); + FinishPendingAt(mPendingReadyTime.Value()); + mPendingReadyTime.SetNull(); + } + + if (IsPossiblyOrphanedPendingAnimation()) { + MOZ_ASSERT(mTimeline && !mTimeline->GetCurrentTime().IsNull(), + "Orphaned pending animations should have an active timeline"); + FinishPendingAt(mTimeline->GetCurrentTime().Value()); + } + + UpdateTiming(SeekFlag::NoSeek, SyncNotifyFlag::Async); + + if (!mEffect) { + return; + } + + // Update layers if we are newly finished. + KeyframeEffectReadOnly* keyframeEffect = mEffect->AsKeyframeEffect(); + if (keyframeEffect && + !keyframeEffect->Properties().IsEmpty() && + !mFinishedAtLastComposeStyle && + PlayState() == AnimationPlayState::Finished) { + PostUpdate(); + } +} + +void +Animation::TriggerOnNextTick(const Nullable<TimeDuration>& aReadyTime) +{ + // Normally we expect the play state to be pending but it's possible that, + // due to the handling of possibly orphaned animations in Tick(), this + // animation got started whilst still being in another document's pending + // animation map. + if (PlayState() != AnimationPlayState::Pending) { + return; + } + + // If aReadyTime.IsNull() we'll detect this in Tick() where we check for + // orphaned animations and trigger this animation anyway + mPendingReadyTime = aReadyTime; +} + +void +Animation::TriggerNow() +{ + // Normally we expect the play state to be pending but when an animation + // is cancelled and its rendered document can't be reached, we can end up + // with the animation still in a pending player tracker even after it is + // no longer pending. + if (PlayState() != AnimationPlayState::Pending) { + return; + } + + // If we don't have an active timeline we can't trigger the animation. + // However, this is a test-only method that we don't expect to be used in + // conjunction with animations without an active timeline so generate + // a warning if we do find ourselves in that situation. + if (!mTimeline || mTimeline->GetCurrentTime().IsNull()) { + NS_WARNING("Failed to trigger an animation with an active timeline"); + return; + } + + FinishPendingAt(mTimeline->GetCurrentTime().Value()); +} + +Nullable<TimeDuration> +Animation::GetCurrentOrPendingStartTime() const +{ + Nullable<TimeDuration> result; + + if (!mStartTime.IsNull()) { + result = mStartTime; + return result; + } + + if (mPendingReadyTime.IsNull() || mHoldTime.IsNull()) { + return result; + } + + // Calculate the equivalent start time from the pending ready time. + result = StartTimeFromReadyTime(mPendingReadyTime.Value()); + + return result; +} + +TimeDuration +Animation::StartTimeFromReadyTime(const TimeDuration& aReadyTime) const +{ + MOZ_ASSERT(!mHoldTime.IsNull(), "Hold time should be set in order to" + " convert a ready time to a start time"); + if (mPlaybackRate == 0) { + return aReadyTime; + } + return aReadyTime - mHoldTime.Value().MultDouble(1 / mPlaybackRate); +} + +TimeStamp +Animation::AnimationTimeToTimeStamp(const StickyTimeDuration& aTime) const +{ + // Initializes to null. Return the same object every time to benefit from + // return-value-optimization. + TimeStamp result; + + // We *don't* check for mTimeline->TracksWallclockTime() here because that + // method only tells us if the timeline times can be converted to + // TimeStamps that can be compared to TimeStamp::Now() or not, *not* + // whether the timelines can be converted to TimeStamp values at all. + // + // Furthermore, we want to be able to use this method when the refresh driver + // is under test control (in which case TracksWallclockTime() will return + // false). + // + // Once we introduce timelines that are not time-based we will need to + // differentiate between them here and determine how to sort their events. + if (!mTimeline) { + return result; + } + + // Check the time is convertible to a timestamp + if (aTime == TimeDuration::Forever() || + mPlaybackRate == 0.0 || + mStartTime.IsNull()) { + return result; + } + + // Invert the standard relation: + // animation time = (timeline time - start time) * playback rate + TimeDuration timelineTime = + TimeDuration(aTime).MultDouble(1.0 / mPlaybackRate) + mStartTime.Value(); + + result = mTimeline->ToTimeStamp(timelineTime); + return result; +} + +TimeStamp +Animation::ElapsedTimeToTimeStamp( + const StickyTimeDuration& aElapsedTime) const +{ + return AnimationTimeToTimeStamp(aElapsedTime + + mEffect->SpecifiedTiming().mDelay); +} + + +// https://w3c.github.io/web-animations/#silently-set-the-current-time +void +Animation::SilentlySetCurrentTime(const TimeDuration& aSeekTime) +{ + if (!mHoldTime.IsNull() || + mStartTime.IsNull() || + !mTimeline || + mTimeline->GetCurrentTime().IsNull() || + mPlaybackRate == 0.0) { + mHoldTime.SetValue(aSeekTime); + if (!mTimeline || mTimeline->GetCurrentTime().IsNull()) { + mStartTime.SetNull(); + } + } else { + mStartTime.SetValue(mTimeline->GetCurrentTime().Value() - + (aSeekTime.MultDouble(1 / mPlaybackRate))); + } + + mPreviousCurrentTime.SetNull(); +} + +void +Animation::SilentlySetPlaybackRate(double aPlaybackRate) +{ + Nullable<TimeDuration> previousTime = GetCurrentTime(); + mPlaybackRate = aPlaybackRate; + if (!previousTime.IsNull()) { + SilentlySetCurrentTime(previousTime.Value()); + } +} + +// https://w3c.github.io/web-animations/#cancel-an-animation +void +Animation::CancelNoUpdate() +{ + ResetPendingTasks(); + + if (mFinished) { + mFinished->MaybeReject(NS_ERROR_DOM_ABORT_ERR); + } + ResetFinishedPromise(); + + DispatchPlaybackEvent(NS_LITERAL_STRING("cancel")); + + mHoldTime.SetNull(); + mStartTime.SetNull(); + + UpdateTiming(SeekFlag::NoSeek, SyncNotifyFlag::Async); + + if (mTimeline) { + mTimeline->RemoveAnimation(this); + } +} + +void +Animation::UpdateRelevance() +{ + bool wasRelevant = mIsRelevant; + mIsRelevant = HasCurrentEffect() || IsInEffect(); + + // Notify animation observers. + if (wasRelevant && !mIsRelevant) { + nsNodeUtils::AnimationRemoved(this); + } else if (!wasRelevant && mIsRelevant) { + nsNodeUtils::AnimationAdded(this); + } +} + +bool +Animation::HasLowerCompositeOrderThan(const Animation& aOther) const +{ + // 0. Object-equality case + if (&aOther == this) { + return false; + } + + // 1. CSS Transitions sort lowest + { + auto asCSSTransitionForSorting = + [] (const Animation& anim) -> const CSSTransition* + { + const CSSTransition* transition = anim.AsCSSTransition(); + return transition && transition->IsTiedToMarkup() ? + transition : + nullptr; + }; + auto thisTransition = asCSSTransitionForSorting(*this); + auto otherTransition = asCSSTransitionForSorting(aOther); + if (thisTransition && otherTransition) { + return thisTransition->HasLowerCompositeOrderThan(*otherTransition); + } + if (thisTransition || otherTransition) { + return thisTransition; + } + } + + // 2. CSS Animations sort next + { + auto asCSSAnimationForSorting = + [] (const Animation& anim) -> const CSSAnimation* + { + const CSSAnimation* animation = anim.AsCSSAnimation(); + return animation && animation->IsTiedToMarkup() ? animation : nullptr; + }; + auto thisAnimation = asCSSAnimationForSorting(*this); + auto otherAnimation = asCSSAnimationForSorting(aOther); + if (thisAnimation && otherAnimation) { + return thisAnimation->HasLowerCompositeOrderThan(*otherAnimation); + } + if (thisAnimation || otherAnimation) { + return thisAnimation; + } + } + + // Subclasses of Animation repurpose mAnimationIndex to implement their + // own brand of composite ordering. However, by this point we should have + // handled any such custom composite ordering so we should now have unique + // animation indices. + MOZ_ASSERT(mAnimationIndex != aOther.mAnimationIndex, + "Animation indices should be unique"); + + // 3. Finally, generic animations sort by their position in the global + // animation array. + return mAnimationIndex < aOther.mAnimationIndex; +} + +void +Animation::ComposeStyle(RefPtr<AnimValuesStyleRule>& aStyleRule, + const nsCSSPropertyIDSet& aPropertiesToSkip) +{ + if (!mEffect) { + return; + } + + if (!IsInEffect()) { + return; + } + + // In order to prevent flicker, there are a few cases where we want to use + // a different time for rendering that would otherwise be returned by + // GetCurrentTime. These are: + // + // (a) For animations that are pausing but which are still running on the + // compositor. In this case we send a layer transaction that removes the + // animation but which also contains the animation values calculated on + // the main thread. To prevent flicker when this occurs we want to ensure + // the timeline time used to calculate the main thread animation values + // does not lag far behind the time used on the compositor. Ideally we + // would like to use the "animation ready time" calculated at the end of + // the layer transaction as the timeline time but it will be too late to + // update the style rule at that point so instead we just use the current + // wallclock time. + // + // (b) For animations that are pausing that we have already taken off the + // compositor. In this case we record a pending ready time but we don't + // apply it until the next tick. However, while waiting for the next tick, + // we should still use the pending ready time as the timeline time. If we + // use the regular timeline time the animation may appear jump backwards + // if the main thread's timeline time lags behind the compositor. + // + // (c) For animations that are play-pending due to an aborted pause operation + // (i.e. a pause operation that was interrupted before we entered the + // paused state). When we cancel a pending pause we might momentarily take + // the animation off the compositor, only to re-add it moments later. In + // that case the compositor might have been ahead of the main thread so we + // should use the current wallclock time to ensure the animation doesn't + // temporarily jump backwards. + // + // To address each of these cases we temporarily tweak the hold time + // immediately before updating the style rule and then restore it immediately + // afterwards. This is purely to prevent visual flicker. Other behavior + // such as dispatching events continues to rely on the regular timeline time. + AnimationPlayState playState = PlayState(); + { + AutoRestore<Nullable<TimeDuration>> restoreHoldTime(mHoldTime); + + if (playState == AnimationPlayState::Pending && + mHoldTime.IsNull() && + !mStartTime.IsNull()) { + Nullable<TimeDuration> timeToUse = mPendingReadyTime; + if (timeToUse.IsNull() && + mTimeline && + mTimeline->TracksWallclockTime()) { + timeToUse = mTimeline->ToTimelineTime(TimeStamp::Now()); + } + if (!timeToUse.IsNull()) { + mHoldTime.SetValue((timeToUse.Value() - mStartTime.Value()) + .MultDouble(mPlaybackRate)); + } + } + + KeyframeEffectReadOnly* keyframeEffect = mEffect->AsKeyframeEffect(); + if (keyframeEffect) { + keyframeEffect->ComposeStyle(aStyleRule, aPropertiesToSkip); + } + } + + MOZ_ASSERT(playState == PlayState(), + "Play state should not change during the course of compositing"); + mFinishedAtLastComposeStyle = (playState == AnimationPlayState::Finished); +} + +void +Animation::NotifyEffectTimingUpdated() +{ + MOZ_ASSERT(mEffect, + "We should only update timing effect when we have a target " + "effect"); + UpdateTiming(Animation::SeekFlag::NoSeek, + Animation::SyncNotifyFlag::Async); +} + +// https://w3c.github.io/web-animations/#play-an-animation +void +Animation::PlayNoUpdate(ErrorResult& aRv, LimitBehavior aLimitBehavior) +{ + AutoMutationBatchForAnimation mb(*this); + + bool abortedPause = mPendingState == PendingState::PausePending; + + Nullable<TimeDuration> currentTime = GetCurrentTime(); + if (mPlaybackRate > 0.0 && + (currentTime.IsNull() || + (aLimitBehavior == LimitBehavior::AutoRewind && + (currentTime.Value() < TimeDuration() || + currentTime.Value() >= EffectEnd())))) { + mHoldTime.SetValue(TimeDuration(0)); + } else if (mPlaybackRate < 0.0 && + (currentTime.IsNull() || + (aLimitBehavior == LimitBehavior::AutoRewind && + (currentTime.Value() <= TimeDuration() || + currentTime.Value() > EffectEnd())))) { + if (EffectEnd() == TimeDuration::Forever()) { + aRv.Throw(NS_ERROR_DOM_INVALID_STATE_ERR); + return; + } + mHoldTime.SetValue(TimeDuration(EffectEnd())); + } else if (mPlaybackRate == 0.0 && currentTime.IsNull()) { + mHoldTime.SetValue(TimeDuration(0)); + } + + bool reuseReadyPromise = false; + if (mPendingState != PendingState::NotPending) { + CancelPendingTasks(); + reuseReadyPromise = true; + } + + // If the hold time is null then we're either already playing normally (and + // we can ignore this call) or we aborted a pending pause operation (in which + // case, for consistency, we need to go through the motions of doing an + // asynchronous start even though we already have a resolved start time). + if (mHoldTime.IsNull() && !abortedPause) { + return; + } + + // Clear the start time until we resolve a new one. We do this except + // for the case where we are aborting a pause and don't have a hold time. + // + // If we're aborting a pause and *do* have a hold time (e.g. because + // the animation is finished or we just applied the auto-rewind behavior + // above) we should respect it by clearing the start time. If we *don't* + // have a hold time we should keep the current start time so that the + // the animation continues moving uninterrupted by the aborted pause. + // + // (If we're not aborting a pause, mHoldTime must be resolved by now + // or else we would have returned above.) + if (!mHoldTime.IsNull()) { + mStartTime.SetNull(); + } + + if (!reuseReadyPromise) { + // Clear ready promise. We'll create a new one lazily. + mReady = nullptr; + } + + mPendingState = PendingState::PlayPending; + + nsIDocument* doc = GetRenderedDocument(); + if (doc) { + PendingAnimationTracker* tracker = + doc->GetOrCreatePendingAnimationTracker(); + tracker->AddPlayPending(*this); + } else { + TriggerOnNextTick(Nullable<TimeDuration>()); + } + + UpdateTiming(SeekFlag::NoSeek, SyncNotifyFlag::Async); + if (IsRelevant()) { + nsNodeUtils::AnimationChanged(this); + } +} + +// https://w3c.github.io/web-animations/#pause-an-animation +void +Animation::PauseNoUpdate(ErrorResult& aRv) +{ + if (IsPausedOrPausing()) { + return; + } + + AutoMutationBatchForAnimation mb(*this); + + // If we are transitioning from idle, fill in the current time + if (GetCurrentTime().IsNull()) { + if (mPlaybackRate >= 0.0) { + mHoldTime.SetValue(TimeDuration(0)); + } else { + if (EffectEnd() == TimeDuration::Forever()) { + aRv.Throw(NS_ERROR_DOM_INVALID_STATE_ERR); + return; + } + mHoldTime.SetValue(TimeDuration(EffectEnd())); + } + } + + bool reuseReadyPromise = false; + if (mPendingState == PendingState::PlayPending) { + CancelPendingTasks(); + reuseReadyPromise = true; + } + + if (!reuseReadyPromise) { + // Clear ready promise. We'll create a new one lazily. + mReady = nullptr; + } + + mPendingState = PendingState::PausePending; + + nsIDocument* doc = GetRenderedDocument(); + if (doc) { + PendingAnimationTracker* tracker = + doc->GetOrCreatePendingAnimationTracker(); + tracker->AddPausePending(*this); + } else { + TriggerOnNextTick(Nullable<TimeDuration>()); + } + + UpdateTiming(SeekFlag::NoSeek, SyncNotifyFlag::Async); + if (IsRelevant()) { + nsNodeUtils::AnimationChanged(this); + } +} + +void +Animation::ResumeAt(const TimeDuration& aReadyTime) +{ + // This method is only expected to be called for an animation that is + // waiting to play. We can easily adapt it to handle other states + // but it's currently not necessary. + MOZ_ASSERT(mPendingState == PendingState::PlayPending, + "Expected to resume a play-pending animation"); + MOZ_ASSERT(mHoldTime.IsNull() != mStartTime.IsNull(), + "An animation in the play-pending state should have either a" + " resolved hold time or resolved start time (but not both)"); + + // If we aborted a pending pause operation we will already have a start time + // we should use. In all other cases, we resolve it from the ready time. + if (mStartTime.IsNull()) { + mStartTime = StartTimeFromReadyTime(aReadyTime); + if (mPlaybackRate != 0) { + mHoldTime.SetNull(); + } + } + mPendingState = PendingState::NotPending; + + UpdateTiming(SeekFlag::NoSeek, SyncNotifyFlag::Async); + + if (mReady) { + mReady->MaybeResolve(this); + } +} + +void +Animation::PauseAt(const TimeDuration& aReadyTime) +{ + MOZ_ASSERT(mPendingState == PendingState::PausePending, + "Expected to pause a pause-pending animation"); + + if (!mStartTime.IsNull() && mHoldTime.IsNull()) { + mHoldTime.SetValue((aReadyTime - mStartTime.Value()) + .MultDouble(mPlaybackRate)); + } + mStartTime.SetNull(); + mPendingState = PendingState::NotPending; + + UpdateTiming(SeekFlag::NoSeek, SyncNotifyFlag::Async); + + if (mReady) { + mReady->MaybeResolve(this); + } +} + +void +Animation::UpdateTiming(SeekFlag aSeekFlag, SyncNotifyFlag aSyncNotifyFlag) +{ + // We call UpdateFinishedState before UpdateEffect because the former + // can change the current time, which is used by the latter. + UpdateFinishedState(aSeekFlag, aSyncNotifyFlag); + UpdateEffect(); + + if (mTimeline) { + mTimeline->NotifyAnimationUpdated(*this); + } +} + +// https://w3c.github.io/web-animations/#update-an-animations-finished-state +void +Animation::UpdateFinishedState(SeekFlag aSeekFlag, + SyncNotifyFlag aSyncNotifyFlag) +{ + Nullable<TimeDuration> currentTime = GetCurrentTime(); + TimeDuration effectEnd = TimeDuration(EffectEnd()); + + if (!mStartTime.IsNull() && + mPendingState == PendingState::NotPending) { + if (mPlaybackRate > 0.0 && + !currentTime.IsNull() && + currentTime.Value() >= effectEnd) { + if (aSeekFlag == SeekFlag::DidSeek) { + mHoldTime = currentTime; + } else if (!mPreviousCurrentTime.IsNull()) { + mHoldTime.SetValue(std::max(mPreviousCurrentTime.Value(), effectEnd)); + } else { + mHoldTime.SetValue(effectEnd); + } + } else if (mPlaybackRate < 0.0 && + !currentTime.IsNull() && + currentTime.Value() <= TimeDuration()) { + if (aSeekFlag == SeekFlag::DidSeek) { + mHoldTime = currentTime; + } else if (!mPreviousCurrentTime.IsNull()) { + mHoldTime.SetValue(std::min(mPreviousCurrentTime.Value(), + TimeDuration(0))); + } else { + mHoldTime.SetValue(0); + } + } else if (mPlaybackRate != 0.0 && + !currentTime.IsNull() && + mTimeline && + !mTimeline->GetCurrentTime().IsNull()) { + if (aSeekFlag == SeekFlag::DidSeek && !mHoldTime.IsNull()) { + mStartTime.SetValue(mTimeline->GetCurrentTime().Value() - + (mHoldTime.Value().MultDouble(1 / mPlaybackRate))); + } + mHoldTime.SetNull(); + } + } + + bool currentFinishedState = PlayState() == AnimationPlayState::Finished; + if (currentFinishedState && !mFinishedIsResolved) { + DoFinishNotification(aSyncNotifyFlag); + } else if (!currentFinishedState && mFinishedIsResolved) { + ResetFinishedPromise(); + } + // We must recalculate the current time to take account of any mHoldTime + // changes the code above made. + mPreviousCurrentTime = GetCurrentTime(); +} + +void +Animation::UpdateEffect() +{ + if (mEffect) { + UpdateRelevance(); + + KeyframeEffectReadOnly* keyframeEffect = mEffect->AsKeyframeEffect(); + if (keyframeEffect) { + keyframeEffect->NotifyAnimationTimingUpdated(); + } + } +} + +void +Animation::FlushStyle() const +{ + nsIDocument* doc = GetRenderedDocument(); + if (doc) { + doc->FlushPendingNotifications(Flush_Style); + } +} + +void +Animation::PostUpdate() +{ + if (!mEffect) { + return; + } + + KeyframeEffectReadOnly* keyframeEffect = mEffect->AsKeyframeEffect(); + if (!keyframeEffect) { + return; + } + + Maybe<NonOwningAnimationTarget> target = keyframeEffect->GetTarget(); + if (!target) { + return; + } + + nsPresContext* presContext = keyframeEffect->GetPresContext(); + if (!presContext) { + return; + } + + presContext->EffectCompositor() + ->RequestRestyle(target->mElement, + target->mPseudoType, + EffectCompositor::RestyleType::Layer, + CascadeLevel()); +} + +void +Animation::CancelPendingTasks() +{ + if (mPendingState == PendingState::NotPending) { + return; + } + + nsIDocument* doc = GetRenderedDocument(); + if (doc) { + PendingAnimationTracker* tracker = doc->GetPendingAnimationTracker(); + if (tracker) { + if (mPendingState == PendingState::PlayPending) { + tracker->RemovePlayPending(*this); + } else { + tracker->RemovePausePending(*this); + } + } + } + + mPendingState = PendingState::NotPending; + mPendingReadyTime.SetNull(); +} + +// https://w3c.github.io/web-animations/#reset-an-animations-pending-tasks +void +Animation::ResetPendingTasks() +{ + if (mPendingState == PendingState::NotPending) { + return; + } + + CancelPendingTasks(); + if (mReady) { + mReady->MaybeReject(NS_ERROR_DOM_ABORT_ERR); + } +} + +bool +Animation::IsPossiblyOrphanedPendingAnimation() const +{ + // Check if we are pending but might never start because we are not being + // tracked. + // + // This covers the following cases: + // + // * We started playing but our effect's target element was orphaned + // or bound to a different document. + // (note that for the case of our effect changing we should handle + // that in SetEffect) + // * We started playing but our timeline became inactive. + // In this case the pending animation tracker will drop us from its hashmap + // when we have been painted. + // * When we started playing we couldn't find a PendingAnimationTracker to + // register with (perhaps the effect had no document) so we simply + // set mPendingState in PlayNoUpdate and relied on this method to catch us + // on the next tick. + + // If we're not pending we're ok. + if (mPendingState == PendingState::NotPending) { + return false; + } + + // If we have a pending ready time then we will be started on the next + // tick. + if (!mPendingReadyTime.IsNull()) { + return false; + } + + // If we don't have an active timeline then we shouldn't start until + // we do. + if (!mTimeline || mTimeline->GetCurrentTime().IsNull()) { + return false; + } + + // If we have no rendered document, or we're not in our rendered document's + // PendingAnimationTracker then there's a good chance no one is tracking us. + // + // If we're wrong and another document is tracking us then, at worst, we'll + // simply start/pause the animation one tick too soon. That's better than + // never starting/pausing the animation and is unlikely. + nsIDocument* doc = GetRenderedDocument(); + if (!doc) { + return true; + } + + PendingAnimationTracker* tracker = doc->GetPendingAnimationTracker(); + return !tracker || + (!tracker->IsWaitingToPlay(*this) && + !tracker->IsWaitingToPause(*this)); +} + +StickyTimeDuration +Animation::EffectEnd() const +{ + if (!mEffect) { + return StickyTimeDuration(0); + } + + return mEffect->SpecifiedTiming().EndTime(); +} + +nsIDocument* +Animation::GetRenderedDocument() const +{ + if (!mEffect || !mEffect->AsKeyframeEffect()) { + return nullptr; + } + + return mEffect->AsKeyframeEffect()->GetRenderedDocument(); +} + +void +Animation::DoFinishNotification(SyncNotifyFlag aSyncNotifyFlag) +{ + CycleCollectedJSContext* context = CycleCollectedJSContext::Get(); + + if (aSyncNotifyFlag == SyncNotifyFlag::Sync) { + DoFinishNotificationImmediately(); + } else if (!mFinishNotificationTask.IsPending()) { + RefPtr<nsRunnableMethod<Animation>> runnable = + NewRunnableMethod(this, &Animation::DoFinishNotificationImmediately); + context->DispatchToMicroTask(do_AddRef(runnable)); + mFinishNotificationTask = runnable.forget(); + } +} + +void +Animation::ResetFinishedPromise() +{ + mFinishedIsResolved = false; + mFinished = nullptr; +} + +void +Animation::MaybeResolveFinishedPromise() +{ + if (mFinished) { + mFinished->MaybeResolve(this); + } + mFinishedIsResolved = true; +} + +void +Animation::DoFinishNotificationImmediately() +{ + mFinishNotificationTask.Revoke(); + + if (PlayState() != AnimationPlayState::Finished) { + return; + } + + MaybeResolveFinishedPromise(); + + DispatchPlaybackEvent(NS_LITERAL_STRING("finish")); +} + +void +Animation::DispatchPlaybackEvent(const nsAString& aName) +{ + AnimationPlaybackEventInit init; + + if (aName.EqualsLiteral("finish")) { + init.mCurrentTime = GetCurrentTimeAsDouble(); + } + if (mTimeline) { + init.mTimelineTime = mTimeline->GetCurrentTimeAsDouble(); + } + + RefPtr<AnimationPlaybackEvent> event = + AnimationPlaybackEvent::Constructor(this, aName, init); + event->SetTrusted(true); + + RefPtr<AsyncEventDispatcher> asyncDispatcher = + new AsyncEventDispatcher(this, event); + asyncDispatcher->PostDOMEvent(); +} + +bool +Animation::IsRunningOnCompositor() const +{ + return mEffect && + mEffect->AsKeyframeEffect() && + mEffect->AsKeyframeEffect()->IsRunningOnCompositor(); +} + +} // namespace dom +} // namespace mozilla diff --git a/dom/animation/Animation.h b/dom/animation/Animation.h new file mode 100644 index 000000000..c59d7d6ce --- /dev/null +++ b/dom/animation/Animation.h @@ -0,0 +1,453 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* 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/. */ + +#ifndef mozilla_dom_Animation_h +#define mozilla_dom_Animation_h + +#include "nsWrapperCache.h" +#include "nsCycleCollectionParticipant.h" +#include "mozilla/Attributes.h" +#include "mozilla/DOMEventTargetHelper.h" +#include "mozilla/EffectCompositor.h" // For EffectCompositor::CascadeLevel +#include "mozilla/LinkedList.h" +#include "mozilla/TimeStamp.h" // for TimeStamp, TimeDuration +#include "mozilla/dom/AnimationBinding.h" // for AnimationPlayState +#include "mozilla/dom/AnimationEffectReadOnly.h" +#include "mozilla/dom/AnimationTimeline.h" +#include "mozilla/dom/Promise.h" +#include "nsCSSPropertyID.h" +#include "nsIGlobalObject.h" + +// X11 has a #define for CurrentTime. +#ifdef CurrentTime +#undef CurrentTime +#endif + +// GetCurrentTime is defined in winbase.h as zero argument macro forwarding to +// GetTickCount(). +#ifdef GetCurrentTime +#undef GetCurrentTime +#endif + +struct JSContext; +class nsCSSPropertyIDSet; +class nsIDocument; +class nsPresContext; + +namespace mozilla { + +class AnimValuesStyleRule; + +namespace dom { + +class CSSAnimation; +class CSSTransition; + +class Animation + : public DOMEventTargetHelper + , public LinkedListElement<Animation> +{ +protected: + virtual ~Animation() {} + +public: + explicit Animation(nsIGlobalObject* aGlobal) + : DOMEventTargetHelper(aGlobal) + , mPlaybackRate(1.0) + , mPendingState(PendingState::NotPending) + , mAnimationIndex(sNextAnimationIndex++) + , mFinishedAtLastComposeStyle(false) + , mIsRelevant(false) + , mFinishedIsResolved(false) + { + } + + NS_DECL_ISUPPORTS_INHERITED + NS_DECL_CYCLE_COLLECTION_CLASS_INHERITED(Animation, + DOMEventTargetHelper) + + nsIGlobalObject* GetParentObject() const { return GetOwnerGlobal(); } + virtual JSObject* WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) override; + + virtual CSSAnimation* AsCSSAnimation() { return nullptr; } + virtual const CSSAnimation* AsCSSAnimation() const { return nullptr; } + virtual CSSTransition* AsCSSTransition() { return nullptr; } + virtual const CSSTransition* AsCSSTransition() const { return nullptr; } + + /** + * Flag to pass to Play to indicate whether or not it should automatically + * rewind the current time to the start point if the animation is finished. + * For regular calls to play() from script we should do this, but when a CSS + * animation's animation-play-state changes we shouldn't rewind the animation. + */ + enum class LimitBehavior { + AutoRewind, + Continue + }; + + // Animation interface methods + static already_AddRefed<Animation> + Constructor(const GlobalObject& aGlobal, + AnimationEffectReadOnly* aEffect, + const Optional<AnimationTimeline*>& aTimeline, + ErrorResult& aRv); + void GetId(nsAString& aResult) const { aResult = mId; } + void SetId(const nsAString& aId); + AnimationEffectReadOnly* GetEffect() const { return mEffect; } + void SetEffect(AnimationEffectReadOnly* aEffect); + AnimationTimeline* GetTimeline() const { return mTimeline; } + void SetTimeline(AnimationTimeline* aTimeline); + Nullable<TimeDuration> GetStartTime() const { return mStartTime; } + void SetStartTime(const Nullable<TimeDuration>& aNewStartTime); + Nullable<TimeDuration> GetCurrentTime() const; + void SetCurrentTime(const TimeDuration& aNewCurrentTime); + double PlaybackRate() const { return mPlaybackRate; } + void SetPlaybackRate(double aPlaybackRate); + AnimationPlayState PlayState() const; + virtual Promise* GetReady(ErrorResult& aRv); + virtual Promise* GetFinished(ErrorResult& aRv); + void Cancel(); + virtual void Finish(ErrorResult& aRv); + virtual void Play(ErrorResult& aRv, LimitBehavior aLimitBehavior); + virtual void Pause(ErrorResult& aRv); + virtual void Reverse(ErrorResult& aRv); + bool IsRunningOnCompositor() const; + IMPL_EVENT_HANDLER(finish); + IMPL_EVENT_HANDLER(cancel); + + // Wrapper functions for Animation DOM methods when called + // from script. + // + // We often use the same methods internally and from script but when called + // from script we (or one of our subclasses) perform extra steps such as + // flushing style or converting the return type. + Nullable<double> GetStartTimeAsDouble() const; + void SetStartTimeAsDouble(const Nullable<double>& aStartTime); + Nullable<double> GetCurrentTimeAsDouble() const; + void SetCurrentTimeAsDouble(const Nullable<double>& aCurrentTime, + ErrorResult& aRv); + virtual AnimationPlayState PlayStateFromJS() const { return PlayState(); } + virtual void PlayFromJS(ErrorResult& aRv) + { + Play(aRv, LimitBehavior::AutoRewind); + } + /** + * PauseFromJS is currently only here for symmetry with PlayFromJS but + * in future we will likely have to flush style in + * CSSAnimation::PauseFromJS so we leave it for now. + */ + void PauseFromJS(ErrorResult& aRv) { Pause(aRv); } + + // Wrapper functions for Animation DOM methods when called from style. + + virtual void CancelFromStyle() { CancelNoUpdate(); } + void SetTimelineNoUpdate(AnimationTimeline* aTimeline); + void SetEffectNoUpdate(AnimationEffectReadOnly* aEffect); + + virtual void Tick(); + bool NeedsTicks() const + { + AnimationPlayState playState = PlayState(); + return playState == AnimationPlayState::Running || + playState == AnimationPlayState::Pending; + } + + /** + * Set the time to use for starting or pausing a pending animation. + * + * Typically, when an animation is played, it does not start immediately but + * is added to a table of pending animations on the document of its effect. + * In the meantime it sets its hold time to the time from which playback + * should begin. + * + * When the document finishes painting, any pending animations in its table + * are marked as being ready to start by calling StartOnNextTick. + * The moment when the paint completed is also recorded, converted to a + * timeline time, and passed to StartOnTick. This is so that when these + * animations do start, they can be timed from the point when painting + * completed. + * + * After calling TriggerOnNextTick, animations remain in the pending state + * until the next refresh driver tick. At that time they transition out of + * the pending state using the time passed to TriggerOnNextTick as the + * effective time at which they resumed. + * + * This approach means that any setup time required for performing the + * initial paint of an animation such as layerization is not deducted from + * the running time of the animation. Without this we can easily drop the + * first few frames of an animation, or, on slower devices, the whole + * animation. + * + * Furthermore: + * + * - Starting the animation immediately when painting finishes is problematic + * because the start time of the animation will be ahead of its timeline + * (since the timeline time is based on the refresh driver time). + * That's a problem because the animation is playing but its timing + * suggests it starts in the future. We could update the timeline to match + * the start time of the animation but then we'd also have to update the + * timing and style of all animations connected to that timeline or else be + * stuck in an inconsistent state until the next refresh driver tick. + * + * - If we simply use the refresh driver time on its next tick, the lag + * between triggering an animation and its effective start is unacceptably + * long. + * + * For pausing, we apply the same asynchronous approach. This is so that we + * synchronize with animations that are running on the compositor. Otherwise + * if the main thread lags behind the compositor there will be a noticeable + * jump backwards when the main thread takes over. Even though main thread + * animations could be paused immediately, we do it asynchronously for + * consistency and so that animations paused together end up in step. + * + * Note that the caller of this method is responsible for removing the + * animation from any PendingAnimationTracker it may have been added to. + */ + void TriggerOnNextTick(const Nullable<TimeDuration>& aReadyTime); + /** + * Testing only: Start or pause a pending animation using the current + * timeline time. This is used to support existing tests that expect + * animations to begin immediately. Ideally we would rewrite the those tests + * and get rid of this method, but there are a lot of them. + * + * As with TriggerOnNextTick, the caller of this method is responsible for + * removing the animation from any PendingAnimationTracker it may have been + * added to. + */ + void TriggerNow(); + /** + * When StartOnNextTick is called, we store the ready time but we don't apply + * it until the next tick. In the meantime, GetStartTime() will return null. + * + * However, if we build layer animations again before the next tick, we + * should initialize them with the start time that GetStartTime() will return + * on the next tick. + * + * If we were to simply set the start time of layer animations to null, their + * start time would be updated to the current wallclock time when rendering + * finishes, thus making them out of sync with the start time stored here. + * This, in turn, will make the animation jump backwards when we build + * animations on the next tick and apply the start time stored here. + * + * This method returns the start time, if resolved. Otherwise, if we have + * a pending ready time, it returns the corresponding start time. If neither + * of those are available, it returns null. + */ + Nullable<TimeDuration> GetCurrentOrPendingStartTime() const; + + /** + * Calculates the corresponding start time to use for an animation that is + * currently pending with current time |mHoldTime| but should behave + * as if it began or resumed playback at timeline time |aReadyTime|. + */ + TimeDuration StartTimeFromReadyTime(const TimeDuration& aReadyTime) const; + + /** + * Converts a time in the timescale of this Animation's currentTime, to a + * TimeStamp. Returns a null TimeStamp if the conversion cannot be performed + * because of the current state of this Animation (e.g. it has no timeline, a + * zero playbackRate, an unresolved start time etc.) or the value of the time + * passed-in (e.g. an infinite time). + */ + TimeStamp AnimationTimeToTimeStamp(const StickyTimeDuration& aTime) const; + + // Converts an AnimationEvent's elapsedTime value to an equivalent TimeStamp + // that can be used to sort events by when they occurred. + TimeStamp ElapsedTimeToTimeStamp(const StickyTimeDuration& aElapsedTime) const; + + bool IsPausedOrPausing() const + { + return PlayState() == AnimationPlayState::Paused || + mPendingState == PendingState::PausePending; + } + + bool HasCurrentEffect() const + { + return GetEffect() && GetEffect()->IsCurrent(); + } + bool IsInEffect() const + { + return GetEffect() && GetEffect()->IsInEffect(); + } + + /** + * Returns true if this animation's playback state makes it a candidate for + * running on the compositor. + * We send animations to the compositor when their target effect is 'current' + * (a definition that is roughly equivalent to when they are in their before + * or active phase). However, we don't send animations to the compositor when + * they are paused/pausing (including being effectively paused due to + * having a zero playback rate), have a zero-duration active interval, or have + * no target effect at all. + */ + bool IsPlayableOnCompositor() const + { + return HasCurrentEffect() && + mPlaybackRate != 0.0 && + (PlayState() == AnimationPlayState::Running || + mPendingState == PendingState::PlayPending) && + !GetEffect()->IsActiveDurationZero(); + } + bool IsRelevant() const { return mIsRelevant; } + void UpdateRelevance(); + + /** + * Returns true if this Animation has a lower composite order than aOther. + */ + bool HasLowerCompositeOrderThan(const Animation& aOther) const; + + /** + * Returns the level at which the effect(s) associated with this Animation + * are applied to the CSS cascade. + */ + virtual EffectCompositor::CascadeLevel CascadeLevel() const + { + return EffectCompositor::CascadeLevel::Animations; + } + + /** + * Returns true if this animation does not currently need to update + * style on the main thread (e.g. because it is empty, or is + * running on the compositor). + */ + bool CanThrottle() const; + /** + * Updates |aStyleRule| with the animation values of this animation's effect, + * if any. + * Any properties contained in |aPropertiesToSkip| will not be added or + * updated in |aStyleRule|. + */ + void ComposeStyle(RefPtr<AnimValuesStyleRule>& aStyleRule, + const nsCSSPropertyIDSet& aPropertiesToSkip); + + void NotifyEffectTimingUpdated(); + +protected: + void SilentlySetCurrentTime(const TimeDuration& aNewCurrentTime); + void SilentlySetPlaybackRate(double aPlaybackRate); + void CancelNoUpdate(); + void PlayNoUpdate(ErrorResult& aRv, LimitBehavior aLimitBehavior); + void PauseNoUpdate(ErrorResult& aRv); + void ResumeAt(const TimeDuration& aReadyTime); + void PauseAt(const TimeDuration& aReadyTime); + void FinishPendingAt(const TimeDuration& aReadyTime) + { + if (mPendingState == PendingState::PlayPending) { + ResumeAt(aReadyTime); + } else if (mPendingState == PendingState::PausePending) { + PauseAt(aReadyTime); + } else { + NS_NOTREACHED("Can't finish pending if we're not in a pending state"); + } + } + + /** + * Finishing behavior depends on if changes to timing occurred due + * to a seek or regular playback. + */ + enum class SeekFlag { + NoSeek, + DidSeek + }; + + enum class SyncNotifyFlag { + Sync, + Async + }; + + virtual void UpdateTiming(SeekFlag aSeekFlag, + SyncNotifyFlag aSyncNotifyFlag); + void UpdateFinishedState(SeekFlag aSeekFlag, + SyncNotifyFlag aSyncNotifyFlag); + void UpdateEffect(); + void FlushStyle() const; + void PostUpdate(); + void ResetFinishedPromise(); + void MaybeResolveFinishedPromise(); + void DoFinishNotification(SyncNotifyFlag aSyncNotifyFlag); + void DoFinishNotificationImmediately(); + void DispatchPlaybackEvent(const nsAString& aName); + + /** + * Remove this animation from the pending animation tracker and reset + * mPendingState as necessary. The caller is responsible for resolving or + * aborting the mReady promise as necessary. + */ + void CancelPendingTasks(); + + /** + * Performs the same steps as CancelPendingTasks and also rejects and + * recreates the ready promise if the animation was pending. + */ + void ResetPendingTasks(); + + bool IsPossiblyOrphanedPendingAnimation() const; + StickyTimeDuration EffectEnd() const; + + nsIDocument* GetRenderedDocument() const; + + RefPtr<AnimationTimeline> mTimeline; + RefPtr<AnimationEffectReadOnly> mEffect; + // The beginning of the delay period. + Nullable<TimeDuration> mStartTime; // Timeline timescale + Nullable<TimeDuration> mHoldTime; // Animation timescale + Nullable<TimeDuration> mPendingReadyTime; // Timeline timescale + Nullable<TimeDuration> mPreviousCurrentTime; // Animation timescale + double mPlaybackRate; + + // A Promise that is replaced on each call to Play() + // and fulfilled when Play() is successfully completed. + // This object is lazily created by GetReady. + // See http://w3c.github.io/web-animations/#current-ready-promise + RefPtr<Promise> mReady; + + // A Promise that is resolved when we reach the end of the effect, or + // 0 when playing backwards. The Promise is replaced if the animation is + // finished but then a state change makes it not finished. + // This object is lazily created by GetFinished. + // See http://w3c.github.io/web-animations/#current-finished-promise + RefPtr<Promise> mFinished; + + // Indicates if the animation is in the pending state (and what state it is + // waiting to enter when it finished pending). We use this rather than + // checking if this animation is tracked by a PendingAnimationTracker because + // the animation will continue to be pending even after it has been removed + // from the PendingAnimationTracker while it is waiting for the next tick + // (see TriggerOnNextTick for details). + enum class PendingState { NotPending, PlayPending, PausePending }; + PendingState mPendingState; + + static uint64_t sNextAnimationIndex; + + // The relative position of this animation within the global animation list. + // This is kNoIndex while the animation is in the idle state and is updated + // each time the animation transitions out of the idle state. + // + // Note that subclasses such as CSSTransition and CSSAnimation may repurpose + // this member to implement their own brand of sorting. As a result, it is + // possible for two different objects to have the same index. + uint64_t mAnimationIndex; + + bool mFinishedAtLastComposeStyle; + // Indicates that the animation should be exposed in an element's + // getAnimations() list. + bool mIsRelevant; + + nsRevocableEventPtr<nsRunnableMethod<Animation>> mFinishNotificationTask; + // True if mFinished is resolved or would be resolved if mFinished has + // yet to be created. This is not set when mFinished is rejected since + // in that case mFinished is immediately reset to represent a new current + // finished promise. + bool mFinishedIsResolved; + + nsString mId; +}; + +} // namespace dom +} // namespace mozilla + +#endif // mozilla_dom_Animation_h diff --git a/dom/animation/AnimationComparator.h b/dom/animation/AnimationComparator.h new file mode 100644 index 000000000..ff665e82a --- /dev/null +++ b/dom/animation/AnimationComparator.h @@ -0,0 +1,32 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* 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/. */ + +#ifndef mozilla_AnimationComparator_h +#define mozilla_AnimationComparator_h + +namespace mozilla { + +// Although this file is called AnimationComparator, we don't actually +// implement AnimationComparator (to compare const Animation& parameters) +// since it's not actually needed (yet). + +template<typename AnimationPtrType> +class AnimationPtrComparator { +public: + bool Equals(const AnimationPtrType& a, const AnimationPtrType& b) const + { + return a == b; + } + + bool LessThan(const AnimationPtrType& a, const AnimationPtrType& b) const + { + return a->HasLowerCompositeOrderThan(*b); + } +}; + +} // namespace mozilla + +#endif // mozilla_AnimationComparator_h diff --git a/dom/animation/AnimationEffectReadOnly.cpp b/dom/animation/AnimationEffectReadOnly.cpp new file mode 100644 index 000000000..aff28a37b --- /dev/null +++ b/dom/animation/AnimationEffectReadOnly.cpp @@ -0,0 +1,343 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* 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 "mozilla/dom/AnimationEffectReadOnly.h" +#include "mozilla/dom/AnimationEffectReadOnlyBinding.h" +#include "mozilla/AnimationUtils.h" +#include "mozilla/FloatingPoint.h" + +namespace mozilla { +namespace dom { + +NS_IMPL_CYCLE_COLLECTION_CLASS(AnimationEffectReadOnly) +NS_IMPL_CYCLE_COLLECTION_UNLINK_BEGIN(AnimationEffectReadOnly) + if (tmp->mTiming) { + tmp->mTiming->Unlink(); + } + NS_IMPL_CYCLE_COLLECTION_UNLINK(mDocument, mTiming, mAnimation) + NS_IMPL_CYCLE_COLLECTION_UNLINK_PRESERVED_WRAPPER +NS_IMPL_CYCLE_COLLECTION_UNLINK_END + +NS_IMPL_CYCLE_COLLECTION_TRAVERSE_BEGIN(AnimationEffectReadOnly) + NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mDocument, mTiming, mAnimation) + NS_IMPL_CYCLE_COLLECTION_TRAVERSE_SCRIPT_OBJECTS +NS_IMPL_CYCLE_COLLECTION_TRAVERSE_END + +NS_IMPL_CYCLE_COLLECTION_TRACE_WRAPPERCACHE(AnimationEffectReadOnly) + +NS_IMPL_CYCLE_COLLECTING_ADDREF(AnimationEffectReadOnly) +NS_IMPL_CYCLE_COLLECTING_RELEASE(AnimationEffectReadOnly) + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(AnimationEffectReadOnly) + NS_WRAPPERCACHE_INTERFACE_MAP_ENTRY + NS_INTERFACE_MAP_ENTRY(nsISupports) +NS_INTERFACE_MAP_END + +AnimationEffectReadOnly::AnimationEffectReadOnly( + nsIDocument* aDocument, AnimationEffectTimingReadOnly* aTiming) + : mDocument(aDocument) + , mTiming(aTiming) +{ + MOZ_ASSERT(aTiming); +} + +// https://w3c.github.io/web-animations/#current +bool +AnimationEffectReadOnly::IsCurrent() const +{ + if (!mAnimation || mAnimation->PlayState() == AnimationPlayState::Finished) { + return false; + } + + ComputedTiming computedTiming = GetComputedTiming(); + return computedTiming.mPhase == ComputedTiming::AnimationPhase::Before || + computedTiming.mPhase == ComputedTiming::AnimationPhase::Active; +} + +// https://w3c.github.io/web-animations/#in-effect +bool +AnimationEffectReadOnly::IsInEffect() const +{ + ComputedTiming computedTiming = GetComputedTiming(); + return !computedTiming.mProgress.IsNull(); +} + +already_AddRefed<AnimationEffectTimingReadOnly> +AnimationEffectReadOnly::Timing() +{ + RefPtr<AnimationEffectTimingReadOnly> temp(mTiming); + return temp.forget(); +} + +void +AnimationEffectReadOnly::SetSpecifiedTiming(const TimingParams& aTiming) +{ + if (mTiming->AsTimingParams() == aTiming) { + return; + } + mTiming->SetTimingParams(aTiming); + if (mAnimation) { + mAnimation->NotifyEffectTimingUpdated(); + } + // For keyframe effects, NotifyEffectTimingUpdated above will eventually cause + // KeyframeEffectReadOnly::NotifyAnimationTimingUpdated to be called so it can + // update its registration with the target element as necessary. +} + +ComputedTiming +AnimationEffectReadOnly::GetComputedTimingAt( + const Nullable<TimeDuration>& aLocalTime, + const TimingParams& aTiming, + double aPlaybackRate) +{ + const StickyTimeDuration zeroDuration; + + // Always return the same object to benefit from return-value optimization. + ComputedTiming result; + + if (aTiming.mDuration) { + MOZ_ASSERT(aTiming.mDuration.ref() >= zeroDuration, + "Iteration duration should be positive"); + result.mDuration = aTiming.mDuration.ref(); + } + + MOZ_ASSERT(aTiming.mIterations >= 0.0 && !IsNaN(aTiming.mIterations), + "mIterations should be nonnegative & finite, as ensured by " + "ValidateIterations or CSSParser"); + result.mIterations = aTiming.mIterations; + + MOZ_ASSERT(aTiming.mIterationStart >= 0.0, + "mIterationStart should be nonnegative, as ensured by " + "ValidateIterationStart"); + result.mIterationStart = aTiming.mIterationStart; + + result.mActiveDuration = aTiming.ActiveDuration(); + result.mEndTime = aTiming.EndTime(); + result.mFill = aTiming.mFill == dom::FillMode::Auto ? + dom::FillMode::None : + aTiming.mFill; + + // The default constructor for ComputedTiming sets all other members to + // values consistent with an animation that has not been sampled. + if (aLocalTime.IsNull()) { + return result; + } + const TimeDuration& localTime = aLocalTime.Value(); + + // Calculate the time within the active interval. + // https://w3c.github.io/web-animations/#active-time + StickyTimeDuration activeTime; + + StickyTimeDuration beforeActiveBoundary = + std::max(std::min(StickyTimeDuration(aTiming.mDelay), result.mEndTime), + zeroDuration); + + StickyTimeDuration activeAfterBoundary = + std::max(std::min(StickyTimeDuration(aTiming.mDelay + + result.mActiveDuration), + result.mEndTime), + zeroDuration); + + if (localTime > activeAfterBoundary || + (aPlaybackRate >= 0 && localTime == activeAfterBoundary)) { + result.mPhase = ComputedTiming::AnimationPhase::After; + if (!result.FillsForwards()) { + // The animation isn't active or filling at this time. + return result; + } + activeTime = + std::max(std::min(StickyTimeDuration(localTime - aTiming.mDelay), + result.mActiveDuration), + zeroDuration); + } else if (localTime < beforeActiveBoundary || + (aPlaybackRate < 0 && localTime == beforeActiveBoundary)) { + result.mPhase = ComputedTiming::AnimationPhase::Before; + if (!result.FillsBackwards()) { + // The animation isn't active or filling at this time. + return result; + } + activeTime = std::max(StickyTimeDuration(localTime - aTiming.mDelay), + zeroDuration); + } else { + MOZ_ASSERT(result.mActiveDuration != zeroDuration, + "How can we be in the middle of a zero-duration interval?"); + result.mPhase = ComputedTiming::AnimationPhase::Active; + activeTime = localTime - aTiming.mDelay; + } + + // Convert active time to a multiple of iterations. + // https://w3c.github.io/web-animations/#overall-progress + double overallProgress; + if (result.mDuration == zeroDuration) { + overallProgress = result.mPhase == ComputedTiming::AnimationPhase::Before + ? 0.0 + : result.mIterations; + } else { + overallProgress = activeTime / result.mDuration; + } + + // Factor in iteration start offset. + if (IsFinite(overallProgress)) { + overallProgress += result.mIterationStart; + } + + // Determine the 0-based index of the current iteration. + // https://w3c.github.io/web-animations/#current-iteration + result.mCurrentIteration = + IsInfinite(result.mIterations) && + result.mPhase == ComputedTiming::AnimationPhase::After + ? UINT64_MAX // In GetComputedTimingDictionary(), + // we will convert this into Infinity + : static_cast<uint64_t>(overallProgress); + + // Convert the overall progress to a fraction of a single iteration--the + // simply iteration progress. + // https://w3c.github.io/web-animations/#simple-iteration-progress + double progress = IsFinite(overallProgress) + ? fmod(overallProgress, 1.0) + : fmod(result.mIterationStart, 1.0); + + // When we finish exactly at the end of an iteration we need to report + // the end of the final iteration and not the start of the next iteration. + // We *don't* want to do this when we have a zero-iteration animation or + // when the animation has been effectively made into a zero-duration animation + // using a negative end-delay, however. + if (result.mPhase == ComputedTiming::AnimationPhase::After && + progress == 0.0 && + result.mIterations != 0.0 && + (activeTime != zeroDuration || result.mDuration == zeroDuration)) { + // The only way we can be in the after phase with a progress of zero and + // a current iteration of zero, is if we have a zero iteration count or + // were clipped using a negative end delay--both of which we should have + // detected above. + MOZ_ASSERT(result.mCurrentIteration != 0, + "Should not have zero current iteration"); + progress = 1.0; + if (result.mCurrentIteration != UINT64_MAX) { + result.mCurrentIteration--; + } + } + + // Factor in the direction. + bool thisIterationReverse = false; + switch (aTiming.mDirection) { + case PlaybackDirection::Normal: + thisIterationReverse = false; + break; + case PlaybackDirection::Reverse: + thisIterationReverse = true; + break; + case PlaybackDirection::Alternate: + thisIterationReverse = (result.mCurrentIteration & 1) == 1; + break; + case PlaybackDirection::Alternate_reverse: + thisIterationReverse = (result.mCurrentIteration & 1) == 0; + break; + default: + MOZ_ASSERT(true, "Unknown PlaybackDirection type"); + } + if (thisIterationReverse) { + progress = 1.0 - progress; + } + + // Calculate the 'before flag' which we use when applying step timing + // functions. + if ((result.mPhase == ComputedTiming::AnimationPhase::After && + thisIterationReverse) || + (result.mPhase == ComputedTiming::AnimationPhase::Before && + !thisIterationReverse)) { + result.mBeforeFlag = ComputedTimingFunction::BeforeFlag::Set; + } + + // Apply the easing. + if (aTiming.mFunction) { + progress = aTiming.mFunction->GetValue(progress, result.mBeforeFlag); + } + + MOZ_ASSERT(IsFinite(progress), "Progress value should be finite"); + result.mProgress.SetValue(progress); + return result; +} + +ComputedTiming +AnimationEffectReadOnly::GetComputedTiming(const TimingParams* aTiming) const +{ + double playbackRate = mAnimation ? mAnimation->PlaybackRate() : 1; + return GetComputedTimingAt(GetLocalTime(), + aTiming ? *aTiming : SpecifiedTiming(), + playbackRate); +} + +// Helper functions for generating a ComputedTimingProperties dictionary +static void +GetComputedTimingDictionary(const ComputedTiming& aComputedTiming, + const Nullable<TimeDuration>& aLocalTime, + const TimingParams& aTiming, + ComputedTimingProperties& aRetVal) +{ + // AnimationEffectTimingProperties + aRetVal.mDelay = aTiming.mDelay.ToMilliseconds(); + aRetVal.mEndDelay = aTiming.mEndDelay.ToMilliseconds(); + aRetVal.mFill = aComputedTiming.mFill; + aRetVal.mIterations = aComputedTiming.mIterations; + aRetVal.mIterationStart = aComputedTiming.mIterationStart; + aRetVal.mDuration.SetAsUnrestrictedDouble() = + aComputedTiming.mDuration.ToMilliseconds(); + aRetVal.mDirection = aTiming.mDirection; + + // ComputedTimingProperties + aRetVal.mActiveDuration = aComputedTiming.mActiveDuration.ToMilliseconds(); + aRetVal.mEndTime = aComputedTiming.mEndTime.ToMilliseconds(); + aRetVal.mLocalTime = AnimationUtils::TimeDurationToDouble(aLocalTime); + aRetVal.mProgress = aComputedTiming.mProgress; + + if (!aRetVal.mProgress.IsNull()) { + // Convert the returned currentIteration into Infinity if we set + // (uint64_t) aComputedTiming.mCurrentIteration to UINT64_MAX + double iteration = aComputedTiming.mCurrentIteration == UINT64_MAX + ? PositiveInfinity<double>() + : static_cast<double>(aComputedTiming.mCurrentIteration); + aRetVal.mCurrentIteration.SetValue(iteration); + } +} + +void +AnimationEffectReadOnly::GetComputedTimingAsDict( + ComputedTimingProperties& aRetVal) const +{ + double playbackRate = mAnimation ? mAnimation->PlaybackRate() : 1; + const Nullable<TimeDuration> currentTime = GetLocalTime(); + GetComputedTimingDictionary(GetComputedTimingAt(currentTime, + SpecifiedTiming(), + playbackRate), + currentTime, + SpecifiedTiming(), + aRetVal); +} + +AnimationEffectReadOnly::~AnimationEffectReadOnly() +{ + // mTiming is cycle collected, so we have to do null check first even though + // mTiming shouldn't be null during the lifetime of KeyframeEffect. + if (mTiming) { + mTiming->Unlink(); + } +} + +Nullable<TimeDuration> +AnimationEffectReadOnly::GetLocalTime() const +{ + // Since the *animation* start time is currently always zero, the local + // time is equal to the parent time. + Nullable<TimeDuration> result; + if (mAnimation) { + result = mAnimation->GetCurrentTime(); + } + return result; +} + +} // namespace dom +} // namespace mozilla diff --git a/dom/animation/AnimationEffectReadOnly.h b/dom/animation/AnimationEffectReadOnly.h new file mode 100644 index 000000000..fdea49314 --- /dev/null +++ b/dom/animation/AnimationEffectReadOnly.h @@ -0,0 +1,102 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* 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/. */ + +#ifndef mozilla_dom_AnimationEffectReadOnly_h +#define mozilla_dom_AnimationEffectReadOnly_h + +#include "mozilla/ComputedTiming.h" +#include "mozilla/dom/AnimationEffectTimingReadOnly.h" +#include "mozilla/dom/BindingDeclarations.h" +#include "mozilla/dom/Nullable.h" +#include "mozilla/Maybe.h" +#include "mozilla/StickyTimeDuration.h" +#include "mozilla/TimeStamp.h" +#include "mozilla/TimingParams.h" +#include "nsCycleCollectionParticipant.h" +#include "nsWrapperCache.h" + +namespace mozilla { + +struct ElementPropertyTransition; + +namespace dom { + +class Animation; +class AnimationEffectTimingReadOnly; +class KeyframeEffectReadOnly; +struct ComputedTimingProperties; + +class AnimationEffectReadOnly : public nsISupports, + public nsWrapperCache +{ +public: + NS_DECL_CYCLE_COLLECTING_ISUPPORTS + NS_DECL_CYCLE_COLLECTION_SCRIPT_HOLDER_CLASS(AnimationEffectReadOnly) + + AnimationEffectReadOnly(nsIDocument* aDocument, + AnimationEffectTimingReadOnly* aTiming); + + virtual KeyframeEffectReadOnly* AsKeyframeEffect() { return nullptr; } + + virtual ElementPropertyTransition* AsTransition() { return nullptr; } + virtual const ElementPropertyTransition* AsTransition() const + { + return nullptr; + } + + nsISupports* GetParentObject() const { return mDocument; } + + bool IsCurrent() const; + bool IsInEffect() const; + bool IsActiveDurationZero() const + { + return SpecifiedTiming().ActiveDuration() == StickyTimeDuration(); + } + + already_AddRefed<AnimationEffectTimingReadOnly> Timing(); + const TimingParams& SpecifiedTiming() const + { + return mTiming->AsTimingParams(); + } + void SetSpecifiedTiming(const TimingParams& aTiming); + + // This function takes as input the timing parameters of an animation and + // returns the computed timing at the specified local time. + // + // The local time may be null in which case only static parameters such as the + // active duration are calculated. All other members of the returned object + // are given a null/initial value. + // + // This function returns a null mProgress member of the return value + // if the animation should not be run + // (because it is not currently active and is not filling at this time). + static ComputedTiming + GetComputedTimingAt(const Nullable<TimeDuration>& aLocalTime, + const TimingParams& aTiming, + double aPlaybackRate); + // Shortcut that gets the computed timing using the current local time as + // calculated from the timeline time. + ComputedTiming GetComputedTiming(const TimingParams* aTiming = nullptr) const; + void GetComputedTimingAsDict(ComputedTimingProperties& aRetVal) const; + + virtual void SetAnimation(Animation* aAnimation) = 0; + Animation* GetAnimation() const { return mAnimation; }; + +protected: + virtual ~AnimationEffectReadOnly(); + + Nullable<TimeDuration> GetLocalTime() const; + +protected: + RefPtr<nsIDocument> mDocument; + RefPtr<AnimationEffectTimingReadOnly> mTiming; + RefPtr<Animation> mAnimation; +}; + +} // namespace dom +} // namespace mozilla + +#endif // mozilla_dom_AnimationEffectReadOnly_h diff --git a/dom/animation/AnimationEffectTiming.cpp b/dom/animation/AnimationEffectTiming.cpp new file mode 100644 index 000000000..8eb4c6edf --- /dev/null +++ b/dom/animation/AnimationEffectTiming.cpp @@ -0,0 +1,152 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et cindent: */ +/* 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 "mozilla/dom/AnimationEffectTiming.h" + +#include "mozilla/dom/AnimatableBinding.h" +#include "mozilla/dom/AnimationEffectTimingBinding.h" +#include "mozilla/dom/KeyframeEffect.h" +#include "mozilla/TimingParams.h" +#include "nsAString.h" + +namespace mozilla { +namespace dom { + +JSObject* +AnimationEffectTiming::WrapObject(JSContext* aCx, JS::Handle<JSObject*> aGivenProto) +{ + return AnimationEffectTimingBinding::Wrap(aCx, this, aGivenProto); +} + +static inline void +PostSpecifiedTimingUpdated(KeyframeEffect* aEffect) +{ + if (aEffect) { + aEffect->NotifySpecifiedTimingUpdated(); + } +} + +void +AnimationEffectTiming::SetDelay(double aDelay) +{ + TimeDuration delay = TimeDuration::FromMilliseconds(aDelay); + if (mTiming.mDelay == delay) { + return; + } + mTiming.mDelay = delay; + + PostSpecifiedTimingUpdated(mEffect); +} + +void +AnimationEffectTiming::SetEndDelay(double aEndDelay) +{ + TimeDuration endDelay = TimeDuration::FromMilliseconds(aEndDelay); + if (mTiming.mEndDelay == endDelay) { + return; + } + mTiming.mEndDelay = endDelay; + + PostSpecifiedTimingUpdated(mEffect); +} + +void +AnimationEffectTiming::SetFill(const FillMode& aFill) +{ + if (mTiming.mFill == aFill) { + return; + } + mTiming.mFill = aFill; + + PostSpecifiedTimingUpdated(mEffect); +} + +void +AnimationEffectTiming::SetIterationStart(double aIterationStart, + ErrorResult& aRv) +{ + if (mTiming.mIterationStart == aIterationStart) { + return; + } + + TimingParams::ValidateIterationStart(aIterationStart, aRv); + if (aRv.Failed()) { + return; + } + + mTiming.mIterationStart = aIterationStart; + + PostSpecifiedTimingUpdated(mEffect); +} + +void +AnimationEffectTiming::SetIterations(double aIterations, ErrorResult& aRv) +{ + if (mTiming.mIterations == aIterations) { + return; + } + + TimingParams::ValidateIterations(aIterations, aRv); + if (aRv.Failed()) { + return; + } + + mTiming.mIterations = aIterations; + + PostSpecifiedTimingUpdated(mEffect); +} + +void +AnimationEffectTiming::SetDuration(const UnrestrictedDoubleOrString& aDuration, + ErrorResult& aRv) +{ + Maybe<StickyTimeDuration> newDuration = + TimingParams::ParseDuration(aDuration, aRv); + if (aRv.Failed()) { + return; + } + + if (mTiming.mDuration == newDuration) { + return; + } + + mTiming.mDuration = newDuration; + + PostSpecifiedTimingUpdated(mEffect); +} + +void +AnimationEffectTiming::SetDirection(const PlaybackDirection& aDirection) +{ + if (mTiming.mDirection == aDirection) { + return; + } + + mTiming.mDirection = aDirection; + + PostSpecifiedTimingUpdated(mEffect); +} + +void +AnimationEffectTiming::SetEasing(const nsAString& aEasing, ErrorResult& aRv) +{ + Maybe<ComputedTimingFunction> newFunction = + TimingParams::ParseEasing(aEasing, mDocument, aRv); + if (aRv.Failed()) { + return; + } + + if (mTiming.mFunction == newFunction) { + return; + } + + mTiming.mFunction = newFunction; + + PostSpecifiedTimingUpdated(mEffect); +} + +} // namespace dom +} // namespace mozilla diff --git a/dom/animation/AnimationEffectTiming.h b/dom/animation/AnimationEffectTiming.h new file mode 100644 index 000000000..06844b320 --- /dev/null +++ b/dom/animation/AnimationEffectTiming.h @@ -0,0 +1,49 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et cindent: */ +/* 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/. */ + +#ifndef mozilla_dom_AnimationEffectTiming_h +#define mozilla_dom_AnimationEffectTiming_h + +#include "mozilla/dom/AnimationEffectTimingReadOnly.h" +#include "mozilla/Attributes.h" // For MOZ_NON_OWNING_REF +#include "nsStringFwd.h" + +namespace mozilla { +namespace dom { + +class KeyframeEffect; + +class AnimationEffectTiming : public AnimationEffectTimingReadOnly +{ +public: + AnimationEffectTiming(nsIDocument* aDocument, + const TimingParams& aTiming, + KeyframeEffect* aEffect) + : AnimationEffectTimingReadOnly(aDocument, aTiming) + , mEffect(aEffect) { } + + JSObject* WrapObject(JSContext* aCx, JS::Handle<JSObject*> aGivenProto) override; + + void Unlink() override { mEffect = nullptr; } + + void SetDelay(double aDelay); + void SetEndDelay(double aEndDelay); + void SetFill(const FillMode& aFill); + void SetIterationStart(double aIterationStart, ErrorResult& aRv); + void SetIterations(double aIterations, ErrorResult& aRv); + void SetDuration(const UnrestrictedDoubleOrString& aDuration, + ErrorResult& aRv); + void SetDirection(const PlaybackDirection& aDirection); + void SetEasing(const nsAString& aEasing, ErrorResult& aRv); + +private: + KeyframeEffect* MOZ_NON_OWNING_REF mEffect; +}; + +} // namespace dom +} // namespace mozilla + +#endif // mozilla_dom_AnimationEffectTiming_h diff --git a/dom/animation/AnimationEffectTimingReadOnly.cpp b/dom/animation/AnimationEffectTimingReadOnly.cpp new file mode 100644 index 000000000..76cd53049 --- /dev/null +++ b/dom/animation/AnimationEffectTimingReadOnly.cpp @@ -0,0 +1,51 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et cindent: */ +/* 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 "mozilla/dom/AnimationEffectTimingReadOnly.h" + +#include "mozilla/AnimationUtils.h" +#include "mozilla/dom/AnimatableBinding.h" +#include "mozilla/dom/AnimationEffectTimingReadOnlyBinding.h" +#include "mozilla/dom/CSSPseudoElement.h" +#include "mozilla/dom/KeyframeEffectBinding.h" + +namespace mozilla { +namespace dom { + +NS_IMPL_CYCLE_COLLECTION_WRAPPERCACHE(AnimationEffectTimingReadOnly, mDocument) + +NS_IMPL_CYCLE_COLLECTION_ROOT_NATIVE(AnimationEffectTimingReadOnly, AddRef) +NS_IMPL_CYCLE_COLLECTION_UNROOT_NATIVE(AnimationEffectTimingReadOnly, Release) + +JSObject* +AnimationEffectTimingReadOnly::WrapObject(JSContext* aCx, JS::Handle<JSObject*> aGivenProto) +{ + return AnimationEffectTimingReadOnlyBinding::Wrap(aCx, this, aGivenProto); +} + +void +AnimationEffectTimingReadOnly::GetDuration( + OwningUnrestrictedDoubleOrString& aRetVal) const +{ + if (mTiming.mDuration) { + aRetVal.SetAsUnrestrictedDouble() = mTiming.mDuration->ToMilliseconds(); + } else { + aRetVal.SetAsString().AssignLiteral("auto"); + } +} + +void +AnimationEffectTimingReadOnly::GetEasing(nsString& aRetVal) const +{ + if (mTiming.mFunction) { + mTiming.mFunction->AppendToString(aRetVal); + } else { + aRetVal.AssignLiteral("linear"); + } +} + +} // namespace dom +} // namespace mozilla diff --git a/dom/animation/AnimationEffectTimingReadOnly.h b/dom/animation/AnimationEffectTimingReadOnly.h new file mode 100644 index 000000000..1f1d50619 --- /dev/null +++ b/dom/animation/AnimationEffectTimingReadOnly.h @@ -0,0 +1,63 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et cindent: */ +/* 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/. */ + +#ifndef mozilla_dom_AnimationEffectTimingReadOnly_h +#define mozilla_dom_AnimationEffectTimingReadOnly_h + +#include "js/TypeDecls.h" +#include "mozilla/Attributes.h" +#include "mozilla/ErrorResult.h" +#include "mozilla/TimingParams.h" +#include "mozilla/dom/BindingDeclarations.h" +#include "mozilla/dom/UnionTypes.h" +#include "nsCycleCollectionParticipant.h" +#include "nsWrapperCache.h" + +namespace mozilla { +namespace dom { + +class AnimationEffectTimingReadOnly : public nsWrapperCache +{ +public: + AnimationEffectTimingReadOnly() = default; + AnimationEffectTimingReadOnly(nsIDocument* aDocument, + const TimingParams& aTiming) + : mDocument(aDocument) + , mTiming(aTiming) { } + + NS_INLINE_DECL_CYCLE_COLLECTING_NATIVE_REFCOUNTING(AnimationEffectTimingReadOnly) + NS_DECL_CYCLE_COLLECTION_SCRIPT_HOLDER_NATIVE_CLASS(AnimationEffectTimingReadOnly) + +protected: + virtual ~AnimationEffectTimingReadOnly() = default; + +public: + nsISupports* GetParentObject() const { return mDocument; } + JSObject* WrapObject(JSContext* aCx, JS::Handle<JSObject*> aGivenProto) override; + + double Delay() const { return mTiming.mDelay.ToMilliseconds(); } + double EndDelay() const { return mTiming.mEndDelay.ToMilliseconds(); } + FillMode Fill() const { return mTiming.mFill; } + double IterationStart() const { return mTiming.mIterationStart; } + double Iterations() const { return mTiming.mIterations; } + void GetDuration(OwningUnrestrictedDoubleOrString& aRetVal) const; + PlaybackDirection Direction() const { return mTiming.mDirection; } + void GetEasing(nsString& aRetVal) const; + + const TimingParams& AsTimingParams() const { return mTiming; } + void SetTimingParams(const TimingParams& aTiming) { mTiming = aTiming; } + + virtual void Unlink() { } + +protected: + RefPtr<nsIDocument> mDocument; + TimingParams mTiming; +}; + +} // namespace dom +} // namespace mozilla + +#endif // mozilla_dom_AnimationEffectTimingReadOnly_h diff --git a/dom/animation/AnimationPerformanceWarning.cpp b/dom/animation/AnimationPerformanceWarning.cpp new file mode 100644 index 000000000..80ece3198 --- /dev/null +++ b/dom/animation/AnimationPerformanceWarning.cpp @@ -0,0 +1,79 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* 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 "AnimationPerformanceWarning.h" + +#include "nsContentUtils.h" + +namespace mozilla { + +template<uint32_t N> nsresult +AnimationPerformanceWarning::ToLocalizedStringWithIntParams( + const char* aKey, nsXPIDLString& aLocalizedString) const +{ + nsAutoString strings[N]; + const char16_t* charParams[N]; + + for (size_t i = 0, n = mParams->Length(); i < n; i++) { + strings[i].AppendInt((*mParams)[i]); + charParams[i] = strings[i].get(); + } + + return nsContentUtils::FormatLocalizedString( + nsContentUtils::eLAYOUT_PROPERTIES, aKey, charParams, aLocalizedString); +} + +bool +AnimationPerformanceWarning::ToLocalizedString( + nsXPIDLString& aLocalizedString) const +{ + const char* key = nullptr; + + switch (mType) { + case Type::ContentTooSmall: + MOZ_ASSERT(mParams && mParams->Length() == 2, + "Parameter's length should be 2 for ContentTooSmall"); + + return NS_SUCCEEDED( + ToLocalizedStringWithIntParams<2>( + "CompositorAnimationWarningContentTooSmall", aLocalizedString)); + case Type::ContentTooLarge: + MOZ_ASSERT(mParams && mParams->Length() == 7, + "Parameter's length should be 7 for ContentTooLarge"); + + return NS_SUCCEEDED( + ToLocalizedStringWithIntParams<7>( + "CompositorAnimationWarningContentTooLarge", aLocalizedString)); + case Type::TransformBackfaceVisibilityHidden: + key = "CompositorAnimationWarningTransformBackfaceVisibilityHidden"; + break; + case Type::TransformPreserve3D: + key = "CompositorAnimationWarningTransformPreserve3D"; + break; + case Type::TransformSVG: + key = "CompositorAnimationWarningTransformSVG"; + break; + case Type::TransformWithGeometricProperties: + key = "CompositorAnimationWarningTransformWithGeometricProperties"; + break; + case Type::TransformFrameInactive: + key = "CompositorAnimationWarningTransformFrameInactive"; + break; + case Type::OpacityFrameInactive: + key = "CompositorAnimationWarningOpacityFrameInactive"; + break; + case Type::HasRenderingObserver: + key = "CompositorAnimationWarningHasRenderingObserver"; + break; + } + + nsresult rv = + nsContentUtils::GetLocalizedString(nsContentUtils::eLAYOUT_PROPERTIES, + key, aLocalizedString); + return NS_SUCCEEDED(rv); +} + +} // namespace mozilla diff --git a/dom/animation/AnimationPerformanceWarning.h b/dom/animation/AnimationPerformanceWarning.h new file mode 100644 index 000000000..025857e0c --- /dev/null +++ b/dom/animation/AnimationPerformanceWarning.h @@ -0,0 +1,79 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* 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/. */ + +#ifndef mozilla_dom_AnimationPerformanceWarning_h +#define mozilla_dom_AnimationPerformanceWarning_h + +#include <initializer_list> + +class nsXPIDLString; + +namespace mozilla { + +// Represents the reason why we can't run the CSS property on the compositor. +struct AnimationPerformanceWarning +{ + enum class Type : uint8_t { + ContentTooSmall, + ContentTooLarge, + TransformBackfaceVisibilityHidden, + TransformPreserve3D, + TransformSVG, + TransformWithGeometricProperties, + TransformFrameInactive, + OpacityFrameInactive, + HasRenderingObserver, + }; + + explicit AnimationPerformanceWarning(Type aType) + : mType(aType) { } + + AnimationPerformanceWarning(Type aType, + std::initializer_list<int32_t> aParams) + : mType(aType) + { + // FIXME: Once std::initializer_list::size() become a constexpr function, + // we should use static_assert here. + MOZ_ASSERT(aParams.size() <= kMaxParamsForLocalization, + "The length of parameters should be less than " + "kMaxParamsForLocalization"); + mParams.emplace(aParams); + } + + // Maximum number of parameters passed to + // nsContentUtils::FormatLocalizedString to localize warning messages. + // + // NOTE: This constexpr can't be forward declared, so if you want to use + // this variable, please include this header file directly. + // This value is the same as the limit of nsStringBundle::FormatString. + // See the implementation of nsStringBundle::FormatString. + static constexpr uint8_t kMaxParamsForLocalization = 10; + + // Indicates why this property could not be animated on the compositor. + Type mType; + + // Optional parameters that may be used for localization. + Maybe<nsTArray<int32_t>> mParams; + + bool ToLocalizedString(nsXPIDLString& aLocalizedString) const; + template<uint32_t N> + nsresult ToLocalizedStringWithIntParams( + const char* aKey, nsXPIDLString& aLocalizedString) const; + + bool operator==(const AnimationPerformanceWarning& aOther) const + { + return mType == aOther.mType && + mParams == aOther.mParams; + } + bool operator!=(const AnimationPerformanceWarning& aOther) const + { + return !(*this == aOther); + } +}; + +} // namespace mozilla + +#endif // mozilla_dom_AnimationPerformanceWarning_h diff --git a/dom/animation/AnimationTarget.h b/dom/animation/AnimationTarget.h new file mode 100644 index 000000000..dbfef2e10 --- /dev/null +++ b/dom/animation/AnimationTarget.h @@ -0,0 +1,78 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* 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/. */ + +#ifndef mozilla_AnimationTarget_h +#define mozilla_AnimationTarget_h + +#include "mozilla/Attributes.h" // For MOZ_NON_OWNING_REF +#include "mozilla/Maybe.h" +#include "mozilla/RefPtr.h" +#include "nsCSSPseudoElements.h" + +namespace mozilla { + +namespace dom { +class Element; +} // namespace dom + +struct OwningAnimationTarget +{ + OwningAnimationTarget(dom::Element* aElement, CSSPseudoElementType aType) + : mElement(aElement), mPseudoType(aType) { } + + explicit OwningAnimationTarget(dom::Element* aElement) + : mElement(aElement) { } + + bool operator==(const OwningAnimationTarget& aOther) const + { + return mElement == aOther.mElement && + mPseudoType == aOther.mPseudoType; + } + + // mElement represents the parent element of a pseudo-element, not the + // generated content element. + RefPtr<dom::Element> mElement; + CSSPseudoElementType mPseudoType = CSSPseudoElementType::NotPseudo; +}; + +struct NonOwningAnimationTarget +{ + NonOwningAnimationTarget(dom::Element* aElement, CSSPseudoElementType aType) + : mElement(aElement), mPseudoType(aType) { } + + explicit NonOwningAnimationTarget(const OwningAnimationTarget& aOther) + : mElement(aOther.mElement), mPseudoType(aOther.mPseudoType) { } + + // mElement represents the parent element of a pseudo-element, not the + // generated content element. + dom::Element* MOZ_NON_OWNING_REF mElement = nullptr; + CSSPseudoElementType mPseudoType = CSSPseudoElementType::NotPseudo; +}; + + +// Helper functions for cycle-collecting Maybe<OwningAnimationTarget> +inline void +ImplCycleCollectionTraverse(nsCycleCollectionTraversalCallback& aCallback, + Maybe<OwningAnimationTarget>& aTarget, + const char* aName, + uint32_t aFlags = 0) +{ + if (aTarget) { + ImplCycleCollectionTraverse(aCallback, aTarget->mElement, aName, aFlags); + } +} + +inline void +ImplCycleCollectionUnlink(Maybe<OwningAnimationTarget>& aTarget) +{ + if (aTarget) { + ImplCycleCollectionUnlink(aTarget->mElement); + } +} + +} // namespace mozilla + +#endif // mozilla_AnimationTarget_h diff --git a/dom/animation/AnimationTimeline.cpp b/dom/animation/AnimationTimeline.cpp new file mode 100644 index 000000000..643106807 --- /dev/null +++ b/dom/animation/AnimationTimeline.cpp @@ -0,0 +1,63 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* 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 "AnimationTimeline.h" +#include "mozilla/AnimationComparator.h" +#include "mozilla/dom/Animation.h" + +namespace mozilla { +namespace dom { + +NS_IMPL_CYCLE_COLLECTION_CLASS(AnimationTimeline) + +NS_IMPL_CYCLE_COLLECTION_UNLINK_BEGIN(AnimationTimeline) + tmp->mAnimationOrder.clear(); + NS_IMPL_CYCLE_COLLECTION_UNLINK(mWindow, mAnimations) + NS_IMPL_CYCLE_COLLECTION_UNLINK_PRESERVED_WRAPPER +NS_IMPL_CYCLE_COLLECTION_UNLINK_END + +NS_IMPL_CYCLE_COLLECTION_TRAVERSE_BEGIN(AnimationTimeline) + NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mWindow, mAnimations) + NS_IMPL_CYCLE_COLLECTION_TRAVERSE_SCRIPT_OBJECTS +NS_IMPL_CYCLE_COLLECTION_TRAVERSE_END + +NS_IMPL_CYCLE_COLLECTION_TRACE_WRAPPERCACHE(AnimationTimeline) + +NS_IMPL_CYCLE_COLLECTING_ADDREF(AnimationTimeline) +NS_IMPL_CYCLE_COLLECTING_RELEASE(AnimationTimeline) + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(AnimationTimeline) + NS_WRAPPERCACHE_INTERFACE_MAP_ENTRY + NS_INTERFACE_MAP_ENTRY(nsISupports) +NS_INTERFACE_MAP_END + +void +AnimationTimeline::NotifyAnimationUpdated(Animation& aAnimation) +{ + if (mAnimations.Contains(&aAnimation)) { + return; + } + + if (aAnimation.GetTimeline() && aAnimation.GetTimeline() != this) { + aAnimation.GetTimeline()->RemoveAnimation(&aAnimation); + } + + mAnimations.PutEntry(&aAnimation); + mAnimationOrder.insertBack(&aAnimation); +} + +void +AnimationTimeline::RemoveAnimation(Animation* aAnimation) +{ + MOZ_ASSERT(!aAnimation->GetTimeline() || aAnimation->GetTimeline() == this); + if (aAnimation->isInList()) { + aAnimation->remove(); + } + mAnimations.RemoveEntry(aAnimation); +} + +} // namespace dom +} // namespace mozilla diff --git a/dom/animation/AnimationTimeline.h b/dom/animation/AnimationTimeline.h new file mode 100644 index 000000000..d36cc4027 --- /dev/null +++ b/dom/animation/AnimationTimeline.h @@ -0,0 +1,125 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* 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/. */ + +#ifndef mozilla_dom_AnimationTimeline_h +#define mozilla_dom_AnimationTimeline_h + +#include "nsISupports.h" +#include "nsWrapperCache.h" +#include "nsCycleCollectionParticipant.h" +#include "js/TypeDecls.h" +#include "mozilla/AnimationUtils.h" +#include "mozilla/Attributes.h" +#include "nsHashKeys.h" +#include "nsIGlobalObject.h" +#include "nsTHashtable.h" + +// GetCurrentTime is defined in winbase.h as zero argument macro forwarding to +// GetTickCount(). +#ifdef GetCurrentTime +#undef GetCurrentTime +#endif + +namespace mozilla { +namespace dom { + +class Animation; + +class AnimationTimeline + : public nsISupports + , public nsWrapperCache +{ +public: + explicit AnimationTimeline(nsIGlobalObject* aWindow) + : mWindow(aWindow) + { + MOZ_ASSERT(mWindow); + } + +protected: + virtual ~AnimationTimeline() + { + mAnimationOrder.clear(); + } + +public: + NS_DECL_CYCLE_COLLECTING_ISUPPORTS + NS_DECL_CYCLE_COLLECTION_SCRIPT_HOLDER_CLASS(AnimationTimeline) + + nsIGlobalObject* GetParentObject() const { return mWindow; } + + // AnimationTimeline methods + virtual Nullable<TimeDuration> GetCurrentTime() const = 0; + + // Wrapper functions for AnimationTimeline DOM methods when called from + // script. + Nullable<double> GetCurrentTimeAsDouble() const { + return AnimationUtils::TimeDurationToDouble(GetCurrentTime()); + } + + /** + * Returns true if the times returned by GetCurrentTime() are convertible + * to and from wallclock-based TimeStamp (e.g. from TimeStamp::Now()) values + * using ToTimelineTime() and ToTimeStamp(). + * + * Typically this is true, but it will be false in the case when this + * timeline has no refresh driver or is tied to a refresh driver under test + * control. + */ + virtual bool TracksWallclockTime() const = 0; + + /** + * Converts a TimeStamp to the equivalent value in timeline time. + * Note that when TracksWallclockTime() is false, there is no correspondence + * between timeline time and wallclock time. In such a case, passing a + * timestamp from TimeStamp::Now() to this method will not return a + * meaningful result. + */ + virtual Nullable<TimeDuration> ToTimelineTime(const TimeStamp& + aTimeStamp) const = 0; + + virtual TimeStamp ToTimeStamp(const TimeDuration& aTimelineTime) const = 0; + + /** + * Inform this timeline that |aAnimation| which is or was observing the + * timeline, has been updated. This serves as both the means to associate + * AND disassociate animations with a timeline. The timeline itself will + * determine if it needs to begin, continue or stop tracking this animation. + */ + virtual void NotifyAnimationUpdated(Animation& aAnimation); + + /** + * Returns true if any CSS animations, CSS transitions or Web animations are + * currently associated with this timeline. As soon as an animation is + * applied to an element it is associated with the timeline even if it has a + * delayed start, so this includes animations that may not be active for some + * time. + */ + bool HasAnimations() const { + return !mAnimations.IsEmpty(); + } + + virtual void RemoveAnimation(Animation* aAnimation); + +protected: + nsCOMPtr<nsIGlobalObject> mWindow; + + // Animations observing this timeline + // + // We store them in (a) a hashset for quick lookup, and (b) an array + // to maintain a fixed sampling order. + // + // The hashset keeps a strong reference to each animation since + // dealing with addref/release with LinkedList is difficult. + typedef nsTHashtable<nsRefPtrHashKey<dom::Animation>> AnimationSet; + AnimationSet mAnimations; + LinkedList<dom::Animation> mAnimationOrder; +}; + +} // namespace dom +} // namespace mozilla + +#endif // mozilla_dom_AnimationTimeline_h diff --git a/dom/animation/AnimationUtils.cpp b/dom/animation/AnimationUtils.cpp new file mode 100644 index 000000000..476652f77 --- /dev/null +++ b/dom/animation/AnimationUtils.cpp @@ -0,0 +1,81 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* 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 "AnimationUtils.h" + +#include "nsContentUtils.h" // For nsContentUtils::IsCallerChrome +#include "nsDebug.h" +#include "nsIAtom.h" +#include "nsIContent.h" +#include "nsIDocument.h" +#include "nsGlobalWindow.h" +#include "nsString.h" +#include "xpcpublic.h" // For xpc::NativeGlobal +#include "mozilla/Preferences.h" + +namespace mozilla { + +/* static */ void +AnimationUtils::LogAsyncAnimationFailure(nsCString& aMessage, + const nsIContent* aContent) +{ + if (aContent) { + aMessage.AppendLiteral(" ["); + aMessage.Append(nsAtomCString(aContent->NodeInfo()->NameAtom())); + + nsIAtom* id = aContent->GetID(); + if (id) { + aMessage.AppendLiteral(" with id '"); + aMessage.Append(nsAtomCString(aContent->GetID())); + aMessage.Append('\''); + } + aMessage.Append(']'); + } + aMessage.Append('\n'); + printf_stderr("%s", aMessage.get()); +} + +/* static */ nsIDocument* +AnimationUtils::GetCurrentRealmDocument(JSContext* aCx) +{ + nsGlobalWindow* win = xpc::CurrentWindowOrNull(aCx); + if (!win) { + return nullptr; + } + return win->GetDoc(); +} + +/* static */ bool +AnimationUtils::IsOffscreenThrottlingEnabled() +{ + static bool sOffscreenThrottlingEnabled; + static bool sPrefCached = false; + + if (!sPrefCached) { + sPrefCached = true; + Preferences::AddBoolVarCache(&sOffscreenThrottlingEnabled, + "dom.animations.offscreen-throttling"); + } + + return sOffscreenThrottlingEnabled; +} + +/* static */ bool +AnimationUtils::IsCoreAPIEnabledForCaller() +{ + static bool sCoreAPIEnabled; + static bool sPrefCached = false; + + if (!sPrefCached) { + sPrefCached = true; + Preferences::AddBoolVarCache(&sCoreAPIEnabled, + "dom.animations-api.core.enabled"); + } + + return sCoreAPIEnabled || nsContentUtils::IsCallerChrome(); +} + +} // namespace mozilla diff --git a/dom/animation/AnimationUtils.h b/dom/animation/AnimationUtils.h new file mode 100644 index 000000000..82ae69bc8 --- /dev/null +++ b/dom/animation/AnimationUtils.h @@ -0,0 +1,74 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* 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/. */ + +#ifndef mozilla_dom_AnimationUtils_h +#define mozilla_dom_AnimationUtils_h + +#include "mozilla/TimeStamp.h" +#include "mozilla/dom/Nullable.h" +#include "nsStringFwd.h" + +class nsIContent; +class nsIDocument; +struct JSContext; + +namespace mozilla { + +class ComputedTimingFunction; + +class AnimationUtils +{ +public: + static dom::Nullable<double> + TimeDurationToDouble(const dom::Nullable<TimeDuration>& aTime) + { + dom::Nullable<double> result; + + if (!aTime.IsNull()) { + result.SetValue(aTime.Value().ToMilliseconds()); + } + + return result; + } + + static dom::Nullable<TimeDuration> + DoubleToTimeDuration(const dom::Nullable<double>& aTime) + { + dom::Nullable<TimeDuration> result; + + if (!aTime.IsNull()) { + result.SetValue(TimeDuration::FromMilliseconds(aTime.Value())); + } + + return result; + } + + static void LogAsyncAnimationFailure(nsCString& aMessage, + const nsIContent* aContent = nullptr); + + /** + * Get the document from the JS context to use when parsing CSS properties. + */ + static nsIDocument* + GetCurrentRealmDocument(JSContext* aCx); + + /** + * Checks if offscreen animation throttling is enabled. + */ + static bool + IsOffscreenThrottlingEnabled(); + + /** + * Returns true if the preference to enable the core Web Animations API is + * true or the caller is chrome. + */ + static bool + IsCoreAPIEnabledForCaller(); +}; + +} // namespace mozilla + +#endif diff --git a/dom/animation/CSSPseudoElement.cpp b/dom/animation/CSSPseudoElement.cpp new file mode 100644 index 000000000..a4dede0b3 --- /dev/null +++ b/dom/animation/CSSPseudoElement.cpp @@ -0,0 +1,123 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et cindent: */ +/* 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 "mozilla/dom/CSSPseudoElement.h" +#include "mozilla/dom/CSSPseudoElementBinding.h" +#include "mozilla/dom/Element.h" +#include "mozilla/AnimationComparator.h" + +namespace mozilla { +namespace dom { + +NS_IMPL_CYCLE_COLLECTION_WRAPPERCACHE(CSSPseudoElement, mParentElement) + +NS_IMPL_CYCLE_COLLECTION_ROOT_NATIVE(CSSPseudoElement, AddRef) +NS_IMPL_CYCLE_COLLECTION_UNROOT_NATIVE(CSSPseudoElement, Release) + +CSSPseudoElement::CSSPseudoElement(Element* aElement, + CSSPseudoElementType aType) + : mParentElement(aElement) + , mPseudoType(aType) +{ + MOZ_ASSERT(aElement); + MOZ_ASSERT(aType == CSSPseudoElementType::after || + aType == CSSPseudoElementType::before, + "Unexpected Pseudo Type"); +} + +CSSPseudoElement::~CSSPseudoElement() +{ + // Element might have been unlinked already, so we have to do null check. + if (mParentElement) { + mParentElement->DeleteProperty( + GetCSSPseudoElementPropertyAtom(mPseudoType)); + } +} + +ParentObject +CSSPseudoElement::GetParentObject() const +{ + return mParentElement->GetParentObject(); +} + +JSObject* +CSSPseudoElement::WrapObject(JSContext* aCx, JS::Handle<JSObject*> aGivenProto) +{ + return CSSPseudoElementBinding::Wrap(aCx, this, aGivenProto); +} + +void +CSSPseudoElement::GetAnimations(const AnimationFilter& filter, + nsTArray<RefPtr<Animation>>& aRetVal) +{ + nsIDocument* doc = mParentElement->GetComposedDoc(); + if (doc) { + doc->FlushPendingNotifications(Flush_Style); + } + + Element::GetAnimationsUnsorted(mParentElement, mPseudoType, aRetVal); + aRetVal.Sort(AnimationPtrComparator<RefPtr<Animation>>()); +} + +already_AddRefed<Animation> +CSSPseudoElement::Animate( + JSContext* aContext, + JS::Handle<JSObject*> aKeyframes, + const UnrestrictedDoubleOrKeyframeAnimationOptions& aOptions, + ErrorResult& aError) +{ + Nullable<ElementOrCSSPseudoElement> target; + target.SetValue().SetAsCSSPseudoElement() = this; + return Element::Animate(target, aContext, aKeyframes, aOptions, aError); +} + +/* static */ already_AddRefed<CSSPseudoElement> +CSSPseudoElement::GetCSSPseudoElement(Element* aElement, + CSSPseudoElementType aType) +{ + if (!aElement) { + return nullptr; + } + + nsIAtom* propName = CSSPseudoElement::GetCSSPseudoElementPropertyAtom(aType); + RefPtr<CSSPseudoElement> pseudo = + static_cast<CSSPseudoElement*>(aElement->GetProperty(propName)); + if (pseudo) { + return pseudo.forget(); + } + + // CSSPseudoElement is a purely external interface created on-demand, and + // when all references from script to the pseudo are dropped, we can drop the + // CSSPseudoElement object, so use a non-owning reference from Element to + // CSSPseudoElement. + pseudo = new CSSPseudoElement(aElement, aType); + nsresult rv = aElement->SetProperty(propName, pseudo, nullptr, true); + if (NS_FAILED(rv)) { + NS_WARNING("SetProperty failed"); + return nullptr; + } + return pseudo.forget(); +} + +/* static */ nsIAtom* +CSSPseudoElement::GetCSSPseudoElementPropertyAtom(CSSPseudoElementType aType) +{ + switch (aType) { + case CSSPseudoElementType::before: + return nsGkAtoms::cssPseudoElementBeforeProperty; + + case CSSPseudoElementType::after: + return nsGkAtoms::cssPseudoElementAfterProperty; + + default: + NS_NOTREACHED("Should not try to get CSSPseudoElement " + "other than ::before or ::after"); + return nullptr; + } +} + +} // namespace dom +} // namespace mozilla diff --git a/dom/animation/CSSPseudoElement.h b/dom/animation/CSSPseudoElement.h new file mode 100644 index 000000000..00445cc60 --- /dev/null +++ b/dom/animation/CSSPseudoElement.h @@ -0,0 +1,91 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et cindent: */ +/* 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/. */ + +#ifndef mozilla_dom_CSSPseudoElement_h +#define mozilla_dom_CSSPseudoElement_h + +#include "js/TypeDecls.h" +#include "mozilla/Attributes.h" +#include "mozilla/ErrorResult.h" +#include "mozilla/dom/BindingDeclarations.h" +#include "mozilla/dom/Element.h" +#include "mozilla/RefPtr.h" +#include "nsCSSPseudoElements.h" +#include "nsWrapperCache.h" + +namespace mozilla { +namespace dom { + +class Animation; +class Element; +class UnrestrictedDoubleOrKeyframeAnimationOptions; + +class CSSPseudoElement final : public nsWrapperCache +{ +public: + NS_INLINE_DECL_CYCLE_COLLECTING_NATIVE_REFCOUNTING(CSSPseudoElement) + NS_DECL_CYCLE_COLLECTION_SCRIPT_HOLDER_NATIVE_CLASS(CSSPseudoElement) + +protected: + virtual ~CSSPseudoElement(); + +public: + ParentObject GetParentObject() const; + + virtual JSObject* WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) override; + + CSSPseudoElementType GetType() const { return mPseudoType; } + void GetType(nsString& aRetVal) const + { + MOZ_ASSERT(nsCSSPseudoElements::GetPseudoAtom(mPseudoType), + "All pseudo-types allowed by this class should have a" + " corresponding atom"); + // Our atoms use one colon and we would like to return two colons syntax + // for the returned pseudo type string, so serialize this to the + // non-deprecated two colon syntax. + aRetVal.Assign(char16_t(':')); + aRetVal.Append( + nsDependentAtomString(nsCSSPseudoElements::GetPseudoAtom(mPseudoType))); + } + already_AddRefed<Element> ParentElement() const + { + RefPtr<Element> retVal(mParentElement); + return retVal.forget(); + } + + void GetAnimations(const AnimationFilter& filter, + nsTArray<RefPtr<Animation>>& aRetVal); + already_AddRefed<Animation> + Animate(JSContext* aContext, + JS::Handle<JSObject*> aKeyframes, + const UnrestrictedDoubleOrKeyframeAnimationOptions& aOptions, + ErrorResult& aError); + + // Given an element:pseudoType pair, returns the CSSPseudoElement stored as a + // property on |aElement|. If there is no CSSPseudoElement for the specified + // pseudo-type on element, a new CSSPseudoElement will be created and stored + // on the element. + static already_AddRefed<CSSPseudoElement> + GetCSSPseudoElement(Element* aElement, CSSPseudoElementType aType); + +private: + // Only ::before and ::after are supported. + CSSPseudoElement(Element* aElement, CSSPseudoElementType aType); + + static nsIAtom* GetCSSPseudoElementPropertyAtom(CSSPseudoElementType aType); + + // mParentElement needs to be an owning reference since if script is holding + // on to the pseudo-element, it needs to continue to be able to refer to + // the parent element. + RefPtr<Element> mParentElement; + CSSPseudoElementType mPseudoType; +}; + +} // namespace dom +} // namespace mozilla + +#endif // mozilla_dom_CSSPseudoElement_h diff --git a/dom/animation/ComputedTiming.h b/dom/animation/ComputedTiming.h new file mode 100644 index 000000000..4a98e3933 --- /dev/null +++ b/dom/animation/ComputedTiming.h @@ -0,0 +1,78 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* 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/. */ + +#ifndef mozilla_ComputedTiming_h +#define mozilla_ComputedTiming_h + +#include "mozilla/dom/Nullable.h" +#include "mozilla/StickyTimeDuration.h" +#include "mozilla/ComputedTimingFunction.h" + +// X11 has a #define for None +#ifdef None +#undef None +#endif +#include "mozilla/dom/AnimationEffectReadOnlyBinding.h" // FillMode + +namespace mozilla { + +/** + * Stores the results of calculating the timing properties of an animation + * at a given sample time. + */ +struct ComputedTiming +{ + // The total duration of the animation including all iterations. + // Will equal StickyTimeDuration::Forever() if the animation repeats + // indefinitely. + StickyTimeDuration mActiveDuration; + // The effect end time in local time (i.e. an offset from the effect's + // start time). Will equal StickyTimeDuration::Forever() if the animation + // plays indefinitely. + StickyTimeDuration mEndTime; + // Progress towards the end of the current iteration. If the effect is + // being sampled backwards, this will go from 1.0 to 0.0. + // Will be null if the animation is neither animating nor + // filling at the sampled time. + Nullable<double> mProgress; + // Zero-based iteration index (meaningless if mProgress is null). + uint64_t mCurrentIteration = 0; + // Unlike TimingParams::mIterations, this value is + // guaranteed to be in the range [0, Infinity]. + double mIterations = 1.0; + double mIterationStart = 0.0; + StickyTimeDuration mDuration; + + // This is the computed fill mode so it is never auto + dom::FillMode mFill = dom::FillMode::None; + bool FillsForwards() const { + MOZ_ASSERT(mFill != dom::FillMode::Auto, + "mFill should not be Auto in ComputedTiming."); + return mFill == dom::FillMode::Both || + mFill == dom::FillMode::Forwards; + } + bool FillsBackwards() const { + MOZ_ASSERT(mFill != dom::FillMode::Auto, + "mFill should not be Auto in ComputedTiming."); + return mFill == dom::FillMode::Both || + mFill == dom::FillMode::Backwards; + } + + enum class AnimationPhase { + Null, // Not sampled (null sample time) + Before, // Sampled prior to the start of the active interval + Active, // Sampled within the active interval + After // Sampled after (or at) the end of the active interval + }; + AnimationPhase mPhase = AnimationPhase::Null; + + ComputedTimingFunction::BeforeFlag mBeforeFlag = + ComputedTimingFunction::BeforeFlag::Unset; +}; + +} // namespace mozilla + +#endif // mozilla_ComputedTiming_h diff --git a/dom/animation/ComputedTimingFunction.cpp b/dom/animation/ComputedTimingFunction.cpp new file mode 100644 index 000000000..95b7fa785 --- /dev/null +++ b/dom/animation/ComputedTimingFunction.cpp @@ -0,0 +1,194 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* 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 "ComputedTimingFunction.h" +#include "nsAlgorithm.h" // For clamped() +#include "nsStyleUtil.h" + +namespace mozilla { + +void +ComputedTimingFunction::Init(const nsTimingFunction &aFunction) +{ + mType = aFunction.mType; + if (nsTimingFunction::IsSplineType(mType)) { + mTimingFunction.Init(aFunction.mFunc.mX1, aFunction.mFunc.mY1, + aFunction.mFunc.mX2, aFunction.mFunc.mY2); + } else { + mSteps = aFunction.mSteps; + } +} + +static inline double +StepTiming(uint32_t aSteps, + double aPortion, + ComputedTimingFunction::BeforeFlag aBeforeFlag, + nsTimingFunction::Type aType) +{ + MOZ_ASSERT(0.0 <= aPortion && aPortion <= 1.0, "out of range"); + MOZ_ASSERT(aType == nsTimingFunction::Type::StepStart || + aType == nsTimingFunction::Type::StepEnd, "invalid type"); + + if (aPortion == 1.0) { + return 1.0; + } + + // Calculate current step using step-end behavior + uint32_t step = uint32_t(aPortion * aSteps); // floor + + // step-start is one step ahead + if (aType == nsTimingFunction::Type::StepStart) { + step++; + } + + // If the "before flag" is set and we are at a transition point, + // drop back a step (but only if we are not already at the zero point-- + // we do this clamping here since |step| is an unsigned integer) + if (step != 0 && + aBeforeFlag == ComputedTimingFunction::BeforeFlag::Set && + fmod(aPortion * aSteps, 1) == 0) { + step--; + } + + // Convert to a progress value + return double(step) / double(aSteps); +} + +double +ComputedTimingFunction::GetValue( + double aPortion, + ComputedTimingFunction::BeforeFlag aBeforeFlag) const +{ + if (HasSpline()) { + // Check for a linear curve. + // (GetSplineValue(), below, also checks this but doesn't work when + // aPortion is outside the range [0.0, 1.0]). + if (mTimingFunction.X1() == mTimingFunction.Y1() && + mTimingFunction.X2() == mTimingFunction.Y2()) { + return aPortion; + } + + // Ensure that we return 0 or 1 on both edges. + if (aPortion == 0.0) { + return 0.0; + } + if (aPortion == 1.0) { + return 1.0; + } + + // For negative values, try to extrapolate with tangent (p1 - p0) or, + // if p1 is coincident with p0, with (p2 - p0). + if (aPortion < 0.0) { + if (mTimingFunction.X1() > 0.0) { + return aPortion * mTimingFunction.Y1() / mTimingFunction.X1(); + } else if (mTimingFunction.Y1() == 0 && mTimingFunction.X2() > 0.0) { + return aPortion * mTimingFunction.Y2() / mTimingFunction.X2(); + } + // If we can't calculate a sensible tangent, don't extrapolate at all. + return 0.0; + } + + // For values greater than 1, try to extrapolate with tangent (p2 - p3) or, + // if p2 is coincident with p3, with (p1 - p3). + if (aPortion > 1.0) { + if (mTimingFunction.X2() < 1.0) { + return 1.0 + (aPortion - 1.0) * + (mTimingFunction.Y2() - 1) / (mTimingFunction.X2() - 1); + } else if (mTimingFunction.Y2() == 1 && mTimingFunction.X1() < 1.0) { + return 1.0 + (aPortion - 1.0) * + (mTimingFunction.Y1() - 1) / (mTimingFunction.X1() - 1); + } + // If we can't calculate a sensible tangent, don't extrapolate at all. + return 1.0; + } + + return mTimingFunction.GetSplineValue(aPortion); + } + + // Since we use endpoint-exclusive timing, the output of a steps(start) timing + // function when aPortion = 0.0 is the top of the first step. When aPortion is + // negative, however, we should use the bottom of the first step. We handle + // negative values of aPortion specially here since once we clamp aPortion + // to [0,1] below we will no longer be able to distinguish to the two cases. + if (aPortion < 0.0) { + return 0.0; + } + + // Clamp in case of steps(end) and steps(start) for values greater than 1. + aPortion = clamped(aPortion, 0.0, 1.0); + + return StepTiming(mSteps, aPortion, aBeforeFlag, mType); +} + +int32_t +ComputedTimingFunction::Compare(const ComputedTimingFunction& aRhs) const +{ + if (mType != aRhs.mType) { + return int32_t(mType) - int32_t(aRhs.mType); + } + + if (mType == nsTimingFunction::Type::CubicBezier) { + int32_t order = mTimingFunction.Compare(aRhs.mTimingFunction); + if (order != 0) { + return order; + } + } else if (mType == nsTimingFunction::Type::StepStart || + mType == nsTimingFunction::Type::StepEnd) { + if (mSteps != aRhs.mSteps) { + return int32_t(mSteps) - int32_t(aRhs.mSteps); + } + } + + return 0; +} + +void +ComputedTimingFunction::AppendToString(nsAString& aResult) const +{ + switch (mType) { + case nsTimingFunction::Type::CubicBezier: + nsStyleUtil::AppendCubicBezierTimingFunction(mTimingFunction.X1(), + mTimingFunction.Y1(), + mTimingFunction.X2(), + mTimingFunction.Y2(), + aResult); + break; + case nsTimingFunction::Type::StepStart: + case nsTimingFunction::Type::StepEnd: + nsStyleUtil::AppendStepsTimingFunction(mType, mSteps, aResult); + break; + default: + nsStyleUtil::AppendCubicBezierKeywordTimingFunction(mType, aResult); + break; + } +} + +/* static */ int32_t +ComputedTimingFunction::Compare(const Maybe<ComputedTimingFunction>& aLhs, + const Maybe<ComputedTimingFunction>& aRhs) +{ + // We can't use |operator<| for const Maybe<>& here because + // 'ease' is prior to 'linear' which is represented by Nothing(). + // So we have to convert Nothing() as 'linear' and check it first. + nsTimingFunction::Type lhsType = aLhs.isNothing() ? + nsTimingFunction::Type::Linear : aLhs->GetType(); + nsTimingFunction::Type rhsType = aRhs.isNothing() ? + nsTimingFunction::Type::Linear : aRhs->GetType(); + + if (lhsType != rhsType) { + return int32_t(lhsType) - int32_t(rhsType); + } + + // Both of them are Nothing(). + if (lhsType == nsTimingFunction::Type::Linear) { + return 0; + } + + // Other types. + return aLhs->Compare(aRhs.value()); +} + +} // namespace mozilla diff --git a/dom/animation/ComputedTimingFunction.h b/dom/animation/ComputedTimingFunction.h new file mode 100644 index 000000000..998097265 --- /dev/null +++ b/dom/animation/ComputedTimingFunction.h @@ -0,0 +1,65 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* 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/. */ + +#ifndef mozilla_ComputedTimingFunction_h +#define mozilla_ComputedTimingFunction_h + +#include "nsSMILKeySpline.h" // nsSMILKeySpline +#include "nsStyleStruct.h" // nsTimingFunction + +namespace mozilla { + +class ComputedTimingFunction +{ +public: + // BeforeFlag is used in step timing function. + // https://w3c.github.io/web-animations/#before-flag + enum class BeforeFlag { + Unset, + Set + }; + void Init(const nsTimingFunction &aFunction); + double GetValue(double aPortion, BeforeFlag aBeforeFlag) const; + const nsSMILKeySpline* GetFunction() const + { + NS_ASSERTION(HasSpline(), "Type mismatch"); + return &mTimingFunction; + } + nsTimingFunction::Type GetType() const { return mType; } + bool HasSpline() const { return nsTimingFunction::IsSplineType(mType); } + uint32_t GetSteps() const { return mSteps; } + bool operator==(const ComputedTimingFunction& aOther) const + { + return mType == aOther.mType && + (HasSpline() ? + mTimingFunction == aOther.mTimingFunction : + mSteps == aOther.mSteps); + } + bool operator!=(const ComputedTimingFunction& aOther) const + { + return !(*this == aOther); + } + int32_t Compare(const ComputedTimingFunction& aRhs) const; + void AppendToString(nsAString& aResult) const; + + static double GetPortion(const Maybe<ComputedTimingFunction>& aFunction, + double aPortion, + BeforeFlag aBeforeFlag) + { + return aFunction ? aFunction->GetValue(aPortion, aBeforeFlag) : aPortion; + } + static int32_t Compare(const Maybe<ComputedTimingFunction>& aLhs, + const Maybe<ComputedTimingFunction>& aRhs); + +private: + nsTimingFunction::Type mType; + nsSMILKeySpline mTimingFunction; + uint32_t mSteps; +}; + +} // namespace mozilla + +#endif // mozilla_ComputedTimingFunction_h diff --git a/dom/animation/DocumentTimeline.cpp b/dom/animation/DocumentTimeline.cpp new file mode 100644 index 000000000..78a4877d2 --- /dev/null +++ b/dom/animation/DocumentTimeline.cpp @@ -0,0 +1,283 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* 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 "DocumentTimeline.h" +#include "mozilla/dom/DocumentTimelineBinding.h" +#include "AnimationUtils.h" +#include "nsContentUtils.h" +#include "nsDOMMutationObserver.h" +#include "nsDOMNavigationTiming.h" +#include "nsIPresShell.h" +#include "nsPresContext.h" +#include "nsRefreshDriver.h" + +namespace mozilla { +namespace dom { + +NS_IMPL_CYCLE_COLLECTION_CLASS(DocumentTimeline) +NS_IMPL_CYCLE_COLLECTION_UNLINK_BEGIN_INHERITED(DocumentTimeline, + AnimationTimeline) + tmp->UnregisterFromRefreshDriver(); + if (tmp->isInList()) { + tmp->remove(); + } + NS_IMPL_CYCLE_COLLECTION_UNLINK(mDocument) +NS_IMPL_CYCLE_COLLECTION_UNLINK_END +NS_IMPL_CYCLE_COLLECTION_TRAVERSE_BEGIN_INHERITED(DocumentTimeline, + AnimationTimeline) + NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mDocument) +NS_IMPL_CYCLE_COLLECTION_TRAVERSE_END + +NS_IMPL_CYCLE_COLLECTION_TRACE_BEGIN_INHERITED(DocumentTimeline, + AnimationTimeline) +NS_IMPL_CYCLE_COLLECTION_TRACE_END + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION_INHERITED(DocumentTimeline) +NS_INTERFACE_MAP_END_INHERITING(AnimationTimeline) + +NS_IMPL_ADDREF_INHERITED(DocumentTimeline, AnimationTimeline) +NS_IMPL_RELEASE_INHERITED(DocumentTimeline, AnimationTimeline) + +JSObject* +DocumentTimeline::WrapObject(JSContext* aCx, JS::Handle<JSObject*> aGivenProto) +{ + return DocumentTimelineBinding::Wrap(aCx, this, aGivenProto); +} + +/* static */ already_AddRefed<DocumentTimeline> +DocumentTimeline::Constructor(const GlobalObject& aGlobal, + const DocumentTimelineOptions& aOptions, + ErrorResult& aRv) +{ + nsIDocument* doc = AnimationUtils::GetCurrentRealmDocument(aGlobal.Context()); + if (!doc) { + aRv.Throw(NS_ERROR_FAILURE); + return nullptr; + } + TimeDuration originTime = + TimeDuration::FromMilliseconds(aOptions.mOriginTime); + + if (originTime == TimeDuration::Forever() || + originTime == -TimeDuration::Forever()) { + aRv.ThrowTypeError<dom::MSG_TIME_VALUE_OUT_OF_RANGE>( + NS_LITERAL_STRING("Origin time")); + return nullptr; + } + RefPtr<DocumentTimeline> timeline = new DocumentTimeline(doc, originTime); + + return timeline.forget(); +} + +Nullable<TimeDuration> +DocumentTimeline::GetCurrentTime() const +{ + return ToTimelineTime(GetCurrentTimeStamp()); +} + +TimeStamp +DocumentTimeline::GetCurrentTimeStamp() const +{ + nsRefreshDriver* refreshDriver = GetRefreshDriver(); + TimeStamp refreshTime = refreshDriver + ? refreshDriver->MostRecentRefresh() + : TimeStamp(); + + // Always return the same object to benefit from return-value optimization. + TimeStamp result = !refreshTime.IsNull() + ? refreshTime + : mLastRefreshDriverTime; + + // If we don't have a refresh driver and we've never had one use the + // timeline's zero time. + if (result.IsNull()) { + RefPtr<nsDOMNavigationTiming> timing = mDocument->GetNavigationTiming(); + if (timing) { + result = timing->GetNavigationStartTimeStamp(); + // Also, let this time represent the current refresh time. This way + // we'll save it as the last refresh time and skip looking up + // navigation timing each time. + refreshTime = result; + } + } + + if (!refreshTime.IsNull()) { + mLastRefreshDriverTime = refreshTime; + } + + return result; +} + +Nullable<TimeDuration> +DocumentTimeline::ToTimelineTime(const TimeStamp& aTimeStamp) const +{ + Nullable<TimeDuration> result; // Initializes to null + if (aTimeStamp.IsNull()) { + return result; + } + + RefPtr<nsDOMNavigationTiming> timing = mDocument->GetNavigationTiming(); + if (MOZ_UNLIKELY(!timing)) { + return result; + } + + result.SetValue(aTimeStamp + - timing->GetNavigationStartTimeStamp() + - mOriginTime); + return result; +} + +void +DocumentTimeline::NotifyAnimationUpdated(Animation& aAnimation) +{ + AnimationTimeline::NotifyAnimationUpdated(aAnimation); + + if (!mIsObservingRefreshDriver) { + nsRefreshDriver* refreshDriver = GetRefreshDriver(); + if (refreshDriver) { + MOZ_ASSERT(isInList(), + "We should not register with the refresh driver if we are not" + " in the document's list of timelines"); + refreshDriver->AddRefreshObserver(this, Flush_Style); + mIsObservingRefreshDriver = true; + } + } +} + +void +DocumentTimeline::WillRefresh(mozilla::TimeStamp aTime) +{ + MOZ_ASSERT(mIsObservingRefreshDriver); + MOZ_ASSERT(GetRefreshDriver(), + "Should be able to reach refresh driver from within WillRefresh"); + + bool needsTicks = false; + nsTArray<Animation*> animationsToRemove(mAnimations.Count()); + + nsAutoAnimationMutationBatch mb(mDocument); + + for (Animation* animation = mAnimationOrder.getFirst(); animation; + animation = animation->getNext()) { + // Skip any animations that are longer need associated with this timeline. + if (animation->GetTimeline() != this) { + // If animation has some other timeline, it better not be also in the + // animation list of this timeline object! + MOZ_ASSERT(!animation->GetTimeline()); + animationsToRemove.AppendElement(animation); + continue; + } + + needsTicks |= animation->NeedsTicks(); + // Even if |animation| doesn't need future ticks, we should still + // Tick it this time around since it might just need a one-off tick in + // order to dispatch events. + animation->Tick(); + + if (!animation->IsRelevant() && !animation->NeedsTicks()) { + animationsToRemove.AppendElement(animation); + } + } + + for (Animation* animation : animationsToRemove) { + RemoveAnimation(animation); + } + + if (!needsTicks) { + // We already assert that GetRefreshDriver() is non-null at the beginning + // of this function but we check it again here to be sure that ticking + // animations does not have any side effects that cause us to lose the + // connection with the refresh driver, such as triggering the destruction + // of mDocument's PresShell. + MOZ_ASSERT(GetRefreshDriver(), + "Refresh driver should still be valid at end of WillRefresh"); + UnregisterFromRefreshDriver(); + } +} + +void +DocumentTimeline::NotifyRefreshDriverCreated(nsRefreshDriver* aDriver) +{ + MOZ_ASSERT(!mIsObservingRefreshDriver, + "Timeline should not be observing the refresh driver before" + " it is created"); + + if (!mAnimationOrder.isEmpty()) { + MOZ_ASSERT(isInList(), + "We should not register with the refresh driver if we are not" + " in the document's list of timelines"); + aDriver->AddRefreshObserver(this, Flush_Style); + mIsObservingRefreshDriver = true; + } +} + +void +DocumentTimeline::NotifyRefreshDriverDestroying(nsRefreshDriver* aDriver) +{ + if (!mIsObservingRefreshDriver) { + return; + } + + aDriver->RemoveRefreshObserver(this, Flush_Style); + mIsObservingRefreshDriver = false; +} + +void +DocumentTimeline::RemoveAnimation(Animation* aAnimation) +{ + AnimationTimeline::RemoveAnimation(aAnimation); + + if (mIsObservingRefreshDriver && mAnimations.IsEmpty()) { + UnregisterFromRefreshDriver(); + } +} + +TimeStamp +DocumentTimeline::ToTimeStamp(const TimeDuration& aTimeDuration) const +{ + TimeStamp result; + RefPtr<nsDOMNavigationTiming> timing = mDocument->GetNavigationTiming(); + if (MOZ_UNLIKELY(!timing)) { + return result; + } + + result = + timing->GetNavigationStartTimeStamp() + (aTimeDuration + mOriginTime); + return result; +} + +nsRefreshDriver* +DocumentTimeline::GetRefreshDriver() const +{ + nsIPresShell* presShell = mDocument->GetShell(); + if (MOZ_UNLIKELY(!presShell)) { + return nullptr; + } + + nsPresContext* presContext = presShell->GetPresContext(); + if (MOZ_UNLIKELY(!presContext)) { + return nullptr; + } + + return presContext->RefreshDriver(); +} + +void +DocumentTimeline::UnregisterFromRefreshDriver() +{ + if (!mIsObservingRefreshDriver) { + return; + } + + nsRefreshDriver* refreshDriver = GetRefreshDriver(); + if (!refreshDriver) { + return; + } + + refreshDriver->RemoveRefreshObserver(this, Flush_Style); + mIsObservingRefreshDriver = false; +} + +} // namespace dom +} // namespace mozilla diff --git a/dom/animation/DocumentTimeline.h b/dom/animation/DocumentTimeline.h new file mode 100644 index 000000000..888a1d33d --- /dev/null +++ b/dom/animation/DocumentTimeline.h @@ -0,0 +1,111 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* 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/. */ + +#ifndef mozilla_dom_DocumentTimeline_h +#define mozilla_dom_DocumentTimeline_h + +#include "mozilla/dom/DocumentTimelineBinding.h" +#include "mozilla/LinkedList.h" +#include "mozilla/TimeStamp.h" +#include "AnimationTimeline.h" +#include "nsIDocument.h" +#include "nsDOMNavigationTiming.h" // for DOMHighResTimeStamp +#include "nsRefreshDriver.h" + +struct JSContext; + +// GetCurrentTime is defined in winbase.h as zero argument macro forwarding to +// GetTickCount(). +#ifdef GetCurrentTime +#undef GetCurrentTime +#endif + +namespace mozilla { +namespace dom { + +class DocumentTimeline final + : public AnimationTimeline + , public nsARefreshObserver + , public LinkedListElement<DocumentTimeline> +{ +public: + DocumentTimeline(nsIDocument* aDocument, const TimeDuration& aOriginTime) + : AnimationTimeline(aDocument->GetParentObject()) + , mDocument(aDocument) + , mIsObservingRefreshDriver(false) + , mOriginTime(aOriginTime) + { + if (mDocument) { + mDocument->Timelines().insertBack(this); + } + } + +protected: + virtual ~DocumentTimeline() + { + MOZ_ASSERT(!mIsObservingRefreshDriver, "Timeline should have disassociated" + " from the refresh driver before being destroyed"); + if (isInList()) { + remove(); + } + } + +public: + NS_DECL_ISUPPORTS_INHERITED + NS_DECL_CYCLE_COLLECTION_SCRIPT_HOLDER_CLASS_INHERITED(DocumentTimeline, + AnimationTimeline) + + virtual JSObject* WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) override; + + static already_AddRefed<DocumentTimeline> + Constructor(const GlobalObject& aGlobal, + const DocumentTimelineOptions& aOptions, + ErrorResult& aRv); + + // AnimationTimeline methods + virtual Nullable<TimeDuration> GetCurrentTime() const override; + + bool TracksWallclockTime() const override + { + nsRefreshDriver* refreshDriver = GetRefreshDriver(); + return !refreshDriver || + !refreshDriver->IsTestControllingRefreshesEnabled(); + } + Nullable<TimeDuration> ToTimelineTime(const TimeStamp& aTimeStamp) const + override; + TimeStamp ToTimeStamp(const TimeDuration& aTimelineTime) const override; + + void NotifyAnimationUpdated(Animation& aAnimation) override; + + void RemoveAnimation(Animation* aAnimation) override; + + // nsARefreshObserver methods + void WillRefresh(TimeStamp aTime) override; + + void NotifyRefreshDriverCreated(nsRefreshDriver* aDriver); + void NotifyRefreshDriverDestroying(nsRefreshDriver* aDriver); + +protected: + TimeStamp GetCurrentTimeStamp() const; + nsRefreshDriver* GetRefreshDriver() const; + void UnregisterFromRefreshDriver(); + + nsCOMPtr<nsIDocument> mDocument; + + // The most recently used refresh driver time. This is used in cases where + // we don't have a refresh driver (e.g. because we are in a display:none + // iframe). + mutable TimeStamp mLastRefreshDriverTime; + bool mIsObservingRefreshDriver; + + TimeDuration mOriginTime; +}; + +} // namespace dom +} // namespace mozilla + +#endif // mozilla_dom_DocumentTimeline_h diff --git a/dom/animation/EffectCompositor.cpp b/dom/animation/EffectCompositor.cpp new file mode 100644 index 000000000..c88cabe90 --- /dev/null +++ b/dom/animation/EffectCompositor.cpp @@ -0,0 +1,920 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* 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 "EffectCompositor.h" + +#include "mozilla/dom/Animation.h" +#include "mozilla/dom/Element.h" +#include "mozilla/dom/KeyframeEffectReadOnly.h" +#include "mozilla/AnimationComparator.h" +#include "mozilla/AnimationPerformanceWarning.h" +#include "mozilla/AnimationTarget.h" +#include "mozilla/AnimationUtils.h" +#include "mozilla/EffectSet.h" +#include "mozilla/LayerAnimationInfo.h" +#include "mozilla/RestyleManagerHandle.h" +#include "mozilla/RestyleManagerHandleInlines.h" +#include "nsComputedDOMStyle.h" // nsComputedDOMStyle::GetPresShellForContent +#include "nsCSSPropertyIDSet.h" +#include "nsCSSProps.h" +#include "nsIPresShell.h" +#include "nsLayoutUtils.h" +#include "nsRuleNode.h" // For nsRuleNode::ComputePropertiesOverridingAnimation +#include "nsRuleProcessorData.h" // For ElementRuleProcessorData etc. +#include "nsTArray.h" +#include <bitset> +#include <initializer_list> + +using mozilla::dom::Animation; +using mozilla::dom::Element; +using mozilla::dom::KeyframeEffectReadOnly; + +namespace mozilla { + +NS_IMPL_CYCLE_COLLECTION_CLASS(EffectCompositor) + +NS_IMPL_CYCLE_COLLECTION_UNLINK_BEGIN(EffectCompositor) + for (auto& elementSet : tmp->mElementsToRestyle) { + elementSet.Clear(); + } +NS_IMPL_CYCLE_COLLECTION_UNLINK_END + +NS_IMPL_CYCLE_COLLECTION_TRAVERSE_BEGIN(EffectCompositor) + for (auto& elementSet : tmp->mElementsToRestyle) { + for (auto iter = elementSet.Iter(); !iter.Done(); iter.Next()) { + CycleCollectionNoteChild(cb, iter.Key().mElement, + "EffectCompositor::mElementsToRestyle[]", + cb.Flags()); + } + } +NS_IMPL_CYCLE_COLLECTION_TRAVERSE_END + +NS_IMPL_CYCLE_COLLECTION_ROOT_NATIVE(EffectCompositor, AddRef) +NS_IMPL_CYCLE_COLLECTION_UNROOT_NATIVE(EffectCompositor, Release) + +// Helper function to factor out the common logic from +// GetAnimationsForCompositor and HasAnimationsForCompositor. +// +// Takes an optional array to fill with eligible animations. +// +// Returns true if there are eligible animations, false otherwise. +bool +FindAnimationsForCompositor(const nsIFrame* aFrame, + nsCSSPropertyID aProperty, + nsTArray<RefPtr<dom::Animation>>* aMatches /*out*/) +{ + MOZ_ASSERT(!aMatches || aMatches->IsEmpty(), + "Matches array, if provided, should be empty"); + + EffectSet* effects = EffectSet::GetEffectSet(aFrame); + if (!effects || effects->IsEmpty()) { + return false; + } + + // If the property will be added to the animations level of the cascade but + // there is an !important rule for that property in the cascade then the + // animation will not be applied since the !important rule overrides it. + if (effects->PropertiesWithImportantRules().HasProperty(aProperty) && + effects->PropertiesForAnimationsLevel().HasProperty(aProperty)) { + return false; + } + + if (aFrame->RefusedAsyncAnimation()) { + return false; + } + + // The animation cascade will almost always be up-to-date by this point + // but there are some cases such as when we are restoring the refresh driver + // from test control after seeking where it might not be the case. + // + // Those cases are probably not important but just to be safe, let's make + // sure the cascade is up to date since if it *is* up to date, this is + // basically a no-op. + Maybe<NonOwningAnimationTarget> pseudoElement = + EffectCompositor::GetAnimationElementAndPseudoForFrame(aFrame); + if (pseudoElement) { + EffectCompositor::MaybeUpdateCascadeResults(pseudoElement->mElement, + pseudoElement->mPseudoType, + aFrame->StyleContext()); + } + + if (!nsLayoutUtils::AreAsyncAnimationsEnabled()) { + if (nsLayoutUtils::IsAnimationLoggingEnabled()) { + nsCString message; + message.AppendLiteral("Performance warning: Async animations are " + "disabled"); + AnimationUtils::LogAsyncAnimationFailure(message); + } + return false; + } + + // Disable async animations if we have a rendering observer that + // depends on our content (svg masking, -moz-element etc) so that + // it gets updated correctly. + nsIContent* content = aFrame->GetContent(); + while (content) { + if (content->HasRenderingObservers()) { + EffectCompositor::SetPerformanceWarning( + aFrame, aProperty, + AnimationPerformanceWarning( + AnimationPerformanceWarning::Type::HasRenderingObserver)); + return false; + } + content = content->GetParent(); + } + + bool foundSome = false; + for (KeyframeEffectReadOnly* effect : *effects) { + MOZ_ASSERT(effect && effect->GetAnimation()); + Animation* animation = effect->GetAnimation(); + + if (!animation->IsPlayableOnCompositor()) { + continue; + } + + AnimationPerformanceWarning::Type warningType; + if (aProperty == eCSSProperty_transform && + effect->ShouldBlockAsyncTransformAnimations(aFrame, + warningType)) { + if (aMatches) { + aMatches->Clear(); + } + EffectCompositor::SetPerformanceWarning( + aFrame, aProperty, + AnimationPerformanceWarning(warningType)); + return false; + } + + if (!effect->HasEffectiveAnimationOfProperty(aProperty)) { + continue; + } + + if (aMatches) { + aMatches->AppendElement(animation); + } + foundSome = true; + } + + MOZ_ASSERT(!foundSome || !aMatches || !aMatches->IsEmpty(), + "If return value is true, matches array should be non-empty"); + + if (aMatches && foundSome) { + aMatches->Sort(AnimationPtrComparator<RefPtr<dom::Animation>>()); + } + return foundSome; +} + +void +EffectCompositor::RequestRestyle(dom::Element* aElement, + CSSPseudoElementType aPseudoType, + RestyleType aRestyleType, + CascadeLevel aCascadeLevel) +{ + if (!mPresContext) { + // Pres context will be null after the effect compositor is disconnected. + return; + } + + // Ignore animations on orphaned elements. + if (!aElement->IsInComposedDoc()) { + return; + } + + auto& elementsToRestyle = mElementsToRestyle[aCascadeLevel]; + PseudoElementHashEntry::KeyType key = { aElement, aPseudoType }; + + if (aRestyleType == RestyleType::Throttled) { + if (!elementsToRestyle.Contains(key)) { + elementsToRestyle.Put(key, false); + } + mPresContext->Document()->SetNeedStyleFlush(); + } else { + // Get() returns 0 if the element is not found. It will also return + // false if the element is found but does not have a pending restyle. + bool hasPendingRestyle = elementsToRestyle.Get(key); + if (!hasPendingRestyle) { + PostRestyleForAnimation(aElement, aPseudoType, aCascadeLevel); + } + elementsToRestyle.Put(key, true); + } + + if (aRestyleType == RestyleType::Layer) { + // Prompt layers to re-sync their animations. + MOZ_ASSERT(mPresContext->RestyleManager()->IsGecko(), + "stylo: Servo-backed style system should not be using " + "EffectCompositor"); + mPresContext->RestyleManager()->AsGecko()->IncrementAnimationGeneration(); + EffectSet* effectSet = + EffectSet::GetEffectSet(aElement, aPseudoType); + if (effectSet) { + effectSet->UpdateAnimationGeneration(mPresContext); + } + } +} + +void +EffectCompositor::PostRestyleForAnimation(dom::Element* aElement, + CSSPseudoElementType aPseudoType, + CascadeLevel aCascadeLevel) +{ + if (!mPresContext) { + return; + } + + dom::Element* element = GetElementToRestyle(aElement, aPseudoType); + if (!element) { + return; + } + + nsRestyleHint hint = aCascadeLevel == CascadeLevel::Transitions ? + eRestyle_CSSTransitions : + eRestyle_CSSAnimations; + mPresContext->PresShell()->RestyleForAnimation(element, hint); +} + +void +EffectCompositor::PostRestyleForThrottledAnimations() +{ + for (size_t i = 0; i < kCascadeLevelCount; i++) { + CascadeLevel cascadeLevel = CascadeLevel(i); + auto& elementSet = mElementsToRestyle[cascadeLevel]; + + for (auto iter = elementSet.Iter(); !iter.Done(); iter.Next()) { + bool& postedRestyle = iter.Data(); + if (postedRestyle) { + continue; + } + + PostRestyleForAnimation(iter.Key().mElement, + iter.Key().mPseudoType, + cascadeLevel); + postedRestyle = true; + } + } +} + +void +EffectCompositor::UpdateEffectProperties(nsStyleContext* aStyleContext, + dom::Element* aElement, + CSSPseudoElementType aPseudoType) +{ + EffectSet* effectSet = EffectSet::GetEffectSet(aElement, aPseudoType); + if (!effectSet) { + return; + } + + // Style context change might cause CSS cascade level, + // e.g removing !important, so we should update the cascading result. + effectSet->MarkCascadeNeedsUpdate(); + + for (KeyframeEffectReadOnly* effect : *effectSet) { + effect->UpdateProperties(aStyleContext); + } +} + +void +EffectCompositor::MaybeUpdateAnimationRule(dom::Element* aElement, + CSSPseudoElementType aPseudoType, + CascadeLevel aCascadeLevel, + nsStyleContext* aStyleContext) +{ + // First update cascade results since that may cause some elements to + // be marked as needing a restyle. + MaybeUpdateCascadeResults(aElement, aPseudoType, aStyleContext); + + auto& elementsToRestyle = mElementsToRestyle[aCascadeLevel]; + PseudoElementHashEntry::KeyType key = { aElement, aPseudoType }; + + if (!mPresContext || !elementsToRestyle.Contains(key)) { + return; + } + + ComposeAnimationRule(aElement, aPseudoType, aCascadeLevel, + mPresContext->RefreshDriver()->MostRecentRefresh()); + + elementsToRestyle.Remove(key); +} + +nsIStyleRule* +EffectCompositor::GetAnimationRule(dom::Element* aElement, + CSSPseudoElementType aPseudoType, + CascadeLevel aCascadeLevel, + nsStyleContext* aStyleContext) +{ + // NOTE: We need to be careful about early returns in this method where + // we *don't* update mElementsToRestyle. When we get a call to + // RequestRestyle that results in a call to PostRestyleForAnimation, we + // will set a bool flag in mElementsToRestyle indicating that we've + // called PostRestyleForAnimation so we don't need to call it again + // until that restyle happens. During that restyle, if we arrive here + // and *don't* update mElementsToRestyle we'll continue to skip calling + // PostRestyleForAnimation from RequestRestyle. + + if (!mPresContext || !mPresContext->IsDynamic()) { + // For print or print preview, ignore animations. + return nullptr; + } + + MOZ_ASSERT(mPresContext->RestyleManager()->IsGecko(), + "stylo: Servo-backed style system should not be using " + "EffectCompositor"); + if (mPresContext->RestyleManager()->AsGecko()->SkipAnimationRules()) { + // We don't need to worry about updating mElementsToRestyle in this case + // since this is not the animation restyle we requested when we called + // PostRestyleForAnimation (see comment at start of this method). + return nullptr; + } + + MaybeUpdateAnimationRule(aElement, aPseudoType, aCascadeLevel, aStyleContext); + +#ifdef DEBUG + { + auto& elementsToRestyle = mElementsToRestyle[aCascadeLevel]; + PseudoElementHashEntry::KeyType key = { aElement, aPseudoType }; + MOZ_ASSERT(!elementsToRestyle.Contains(key), + "Element should no longer require a restyle after its " + "animation rule has been updated"); + } +#endif + + EffectSet* effectSet = EffectSet::GetEffectSet(aElement, aPseudoType); + if (!effectSet) { + return nullptr; + } + + return effectSet->AnimationRule(aCascadeLevel); +} + +/* static */ dom::Element* +EffectCompositor::GetElementToRestyle(dom::Element* aElement, + CSSPseudoElementType aPseudoType) +{ + if (aPseudoType == CSSPseudoElementType::NotPseudo) { + return aElement; + } + + nsIFrame* primaryFrame = aElement->GetPrimaryFrame(); + if (!primaryFrame) { + return nullptr; + } + nsIFrame* pseudoFrame; + if (aPseudoType == CSSPseudoElementType::before) { + pseudoFrame = nsLayoutUtils::GetBeforeFrame(primaryFrame); + } else if (aPseudoType == CSSPseudoElementType::after) { + pseudoFrame = nsLayoutUtils::GetAfterFrame(primaryFrame); + } else { + NS_NOTREACHED("Should not try to get the element to restyle for a pseudo " + "other that :before or :after"); + return nullptr; + } + if (!pseudoFrame) { + return nullptr; + } + return pseudoFrame->GetContent()->AsElement(); +} + +bool +EffectCompositor::HasPendingStyleUpdates() const +{ + for (auto& elementSet : mElementsToRestyle) { + if (elementSet.Count()) { + return true; + } + } + + return false; +} + +bool +EffectCompositor::HasThrottledStyleUpdates() const +{ + for (auto& elementSet : mElementsToRestyle) { + for (auto iter = elementSet.ConstIter(); !iter.Done(); iter.Next()) { + if (!iter.Data()) { + return true; + } + } + } + + return false; +} + +void +EffectCompositor::AddStyleUpdatesTo(RestyleTracker& aTracker) +{ + if (!mPresContext) { + return; + } + + for (size_t i = 0; i < kCascadeLevelCount; i++) { + CascadeLevel cascadeLevel = CascadeLevel(i); + auto& elementSet = mElementsToRestyle[cascadeLevel]; + + // Copy the list of elements to restyle to a separate array that we can + // iterate over. This is because we need to call MaybeUpdateCascadeResults + // on each element, but doing that can mutate elementSet. In this case + // it will only mutate the bool value associated with each element in the + // set but even doing that will cause assertions in PLDHashTable to fail + // if we are iterating over the hashtable at the same time. + nsTArray<PseudoElementHashEntry::KeyType> elementsToRestyle( + elementSet.Count()); + for (auto iter = elementSet.Iter(); !iter.Done(); iter.Next()) { + // Skip animations on elements that have been orphaned since they + // requested a restyle. + if (iter.Key().mElement->IsInComposedDoc()) { + elementsToRestyle.AppendElement(iter.Key()); + } + } + + for (auto& pseudoElem : elementsToRestyle) { + MaybeUpdateCascadeResults(pseudoElem.mElement, + pseudoElem.mPseudoType, + nullptr); + + ComposeAnimationRule(pseudoElem.mElement, + pseudoElem.mPseudoType, + cascadeLevel, + mPresContext->RefreshDriver()->MostRecentRefresh()); + + dom::Element* elementToRestyle = + GetElementToRestyle(pseudoElem.mElement, pseudoElem.mPseudoType); + if (elementToRestyle) { + nsRestyleHint rshint = cascadeLevel == CascadeLevel::Transitions ? + eRestyle_CSSTransitions : + eRestyle_CSSAnimations; + aTracker.AddPendingRestyle(elementToRestyle, rshint, nsChangeHint(0)); + } + } + + elementSet.Clear(); + // Note: mElement pointers in elementsToRestyle might now dangle + } +} + +/* static */ bool +EffectCompositor::HasAnimationsForCompositor(const nsIFrame* aFrame, + nsCSSPropertyID aProperty) +{ + return FindAnimationsForCompositor(aFrame, aProperty, nullptr); +} + +/* static */ nsTArray<RefPtr<dom::Animation>> +EffectCompositor::GetAnimationsForCompositor(const nsIFrame* aFrame, + nsCSSPropertyID aProperty) +{ + nsTArray<RefPtr<dom::Animation>> result; + +#ifdef DEBUG + bool foundSome = +#endif + FindAnimationsForCompositor(aFrame, aProperty, &result); + MOZ_ASSERT(!foundSome || !result.IsEmpty(), + "If return value is true, matches array should be non-empty"); + + return result; +} + +/* static */ void +EffectCompositor::ClearIsRunningOnCompositor(const nsIFrame *aFrame, + nsCSSPropertyID aProperty) +{ + EffectSet* effects = EffectSet::GetEffectSet(aFrame); + if (!effects) { + return; + } + + for (KeyframeEffectReadOnly* effect : *effects) { + effect->SetIsRunningOnCompositor(aProperty, false); + } +} + +/* static */ void +EffectCompositor::MaybeUpdateCascadeResults(Element* aElement, + CSSPseudoElementType aPseudoType, + nsStyleContext* aStyleContext) +{ + EffectSet* effects = EffectSet::GetEffectSet(aElement, aPseudoType); + if (!effects || !effects->CascadeNeedsUpdate()) { + return; + } + + nsStyleContext* styleContext = aStyleContext; + if (!styleContext) { + dom::Element* elementToRestyle = GetElementToRestyle(aElement, aPseudoType); + if (elementToRestyle) { + nsIFrame* frame = elementToRestyle->GetPrimaryFrame(); + if (frame) { + styleContext = frame->StyleContext(); + } + } + } + UpdateCascadeResults(*effects, aElement, aPseudoType, styleContext); + + MOZ_ASSERT(!effects->CascadeNeedsUpdate(), "Failed to update cascade state"); +} + +namespace { + class EffectCompositeOrderComparator { + public: + bool Equals(const KeyframeEffectReadOnly* a, + const KeyframeEffectReadOnly* b) const + { + return a == b; + } + + bool LessThan(const KeyframeEffectReadOnly* a, + const KeyframeEffectReadOnly* b) const + { + MOZ_ASSERT(a->GetAnimation() && b->GetAnimation()); + MOZ_ASSERT( + Equals(a, b) || + a->GetAnimation()->HasLowerCompositeOrderThan(*b->GetAnimation()) != + b->GetAnimation()->HasLowerCompositeOrderThan(*a->GetAnimation())); + return a->GetAnimation()->HasLowerCompositeOrderThan(*b->GetAnimation()); + } + }; +} + +/* static */ void +EffectCompositor::UpdateCascadeResults(Element* aElement, + CSSPseudoElementType aPseudoType, + nsStyleContext* aStyleContext) +{ + EffectSet* effects = EffectSet::GetEffectSet(aElement, aPseudoType); + if (!effects) { + return; + } + + UpdateCascadeResults(*effects, aElement, aPseudoType, aStyleContext); +} + +/* static */ Maybe<NonOwningAnimationTarget> +EffectCompositor::GetAnimationElementAndPseudoForFrame(const nsIFrame* aFrame) +{ + // Always return the same object to benefit from return-value optimization. + Maybe<NonOwningAnimationTarget> result; + + CSSPseudoElementType pseudoType = + aFrame->StyleContext()->GetPseudoType(); + + if (pseudoType != CSSPseudoElementType::NotPseudo && + pseudoType != CSSPseudoElementType::before && + pseudoType != CSSPseudoElementType::after) { + return result; + } + + nsIContent* content = aFrame->GetContent(); + if (!content) { + return result; + } + + if (pseudoType == CSSPseudoElementType::before || + pseudoType == CSSPseudoElementType::after) { + content = content->GetParent(); + if (!content) { + return result; + } + } + + if (!content->IsElement()) { + return result; + } + + result.emplace(content->AsElement(), pseudoType); + + return result; +} + +/* static */ void +EffectCompositor::ComposeAnimationRule(dom::Element* aElement, + CSSPseudoElementType aPseudoType, + CascadeLevel aCascadeLevel, + TimeStamp aRefreshTime) +{ + EffectSet* effects = EffectSet::GetEffectSet(aElement, aPseudoType); + if (!effects) { + return; + } + + // The caller is responsible for calling MaybeUpdateCascadeResults first. + MOZ_ASSERT(!effects->CascadeNeedsUpdate(), + "Animation cascade out of date when composing animation rule"); + + // Get a list of effects sorted by composite order. + nsTArray<KeyframeEffectReadOnly*> sortedEffectList(effects->Count()); + for (KeyframeEffectReadOnly* effect : *effects) { + sortedEffectList.AppendElement(effect); + } + sortedEffectList.Sort(EffectCompositeOrderComparator()); + + RefPtr<AnimValuesStyleRule>& animationRule = + effects->AnimationRule(aCascadeLevel); + animationRule = nullptr; + + // If multiple animations affect the same property, animations with higher + // composite order (priority) override or add or animations with lower + // priority except properties in propertiesToSkip. + const nsCSSPropertyIDSet& propertiesToSkip = + aCascadeLevel == CascadeLevel::Animations + ? effects->PropertiesForAnimationsLevel().Invert() + : effects->PropertiesForAnimationsLevel(); + for (KeyframeEffectReadOnly* effect : sortedEffectList) { + effect->GetAnimation()->ComposeStyle(animationRule, propertiesToSkip); + } + + MOZ_ASSERT(effects == EffectSet::GetEffectSet(aElement, aPseudoType), + "EffectSet should not change while composing style"); + + effects->UpdateAnimationRuleRefreshTime(aCascadeLevel, aRefreshTime); +} + +/* static */ void +EffectCompositor::GetOverriddenProperties(nsStyleContext* aStyleContext, + EffectSet& aEffectSet, + nsCSSPropertyIDSet& + aPropertiesOverridden) +{ + AutoTArray<nsCSSPropertyID, LayerAnimationInfo::kRecords> propertiesToTrack; + { + nsCSSPropertyIDSet propertiesToTrackAsSet; + for (KeyframeEffectReadOnly* effect : aEffectSet) { + for (const AnimationProperty& property : effect->Properties()) { + if (nsCSSProps::PropHasFlags(property.mProperty, + CSS_PROPERTY_CAN_ANIMATE_ON_COMPOSITOR) && + !propertiesToTrackAsSet.HasProperty(property.mProperty)) { + propertiesToTrackAsSet.AddProperty(property.mProperty); + propertiesToTrack.AppendElement(property.mProperty); + } + } + // Skip iterating over the rest of the effects if we've already + // found all the compositor-animatable properties. + if (propertiesToTrack.Length() == LayerAnimationInfo::kRecords) { + break; + } + } + } + + if (propertiesToTrack.IsEmpty()) { + return; + } + + nsRuleNode::ComputePropertiesOverridingAnimation(propertiesToTrack, + aStyleContext, + aPropertiesOverridden); +} + +/* static */ void +EffectCompositor::UpdateCascadeResults(EffectSet& aEffectSet, + Element* aElement, + CSSPseudoElementType aPseudoType, + nsStyleContext* aStyleContext) +{ + MOZ_ASSERT(EffectSet::GetEffectSet(aElement, aPseudoType) == &aEffectSet, + "Effect set should correspond to the specified (pseudo-)element"); + if (aEffectSet.IsEmpty()) { + aEffectSet.MarkCascadeUpdated(); + return; + } + + // Get a list of effects sorted by composite order. + nsTArray<KeyframeEffectReadOnly*> sortedEffectList(aEffectSet.Count()); + for (KeyframeEffectReadOnly* effect : aEffectSet) { + sortedEffectList.AppendElement(effect); + } + sortedEffectList.Sort(EffectCompositeOrderComparator()); + + // Get properties that override the *animations* level of the cascade. + // + // We only do this for properties that we can animate on the compositor + // since we will apply other properties on the main thread where the usual + // cascade applies. + nsCSSPropertyIDSet overriddenProperties; + if (aStyleContext) { + GetOverriddenProperties(aStyleContext, aEffectSet, overriddenProperties); + } + + // Returns a bitset the represents which properties from + // LayerAnimationInfo::sRecords are present in |aPropertySet|. + auto compositorPropertiesInSet = + [](nsCSSPropertyIDSet& aPropertySet) -> + std::bitset<LayerAnimationInfo::kRecords> { + std::bitset<LayerAnimationInfo::kRecords> result; + for (size_t i = 0; i < LayerAnimationInfo::kRecords; i++) { + if (aPropertySet.HasProperty( + LayerAnimationInfo::sRecords[i].mProperty)) { + result.set(i); + } + } + return result; + }; + + nsCSSPropertyIDSet& propertiesWithImportantRules = + aEffectSet.PropertiesWithImportantRules(); + nsCSSPropertyIDSet& propertiesForAnimationsLevel = + aEffectSet.PropertiesForAnimationsLevel(); + + // Record which compositor-animatable properties were originally set so we can + // compare for changes later. + std::bitset<LayerAnimationInfo::kRecords> + prevCompositorPropertiesWithImportantRules = + compositorPropertiesInSet(propertiesWithImportantRules); + std::bitset<LayerAnimationInfo::kRecords> + prevCompositorPropertiesForAnimationsLevel = + compositorPropertiesInSet(propertiesForAnimationsLevel); + + propertiesWithImportantRules.Empty(); + propertiesForAnimationsLevel.Empty(); + + bool hasCompositorPropertiesForTransition = false; + + for (const KeyframeEffectReadOnly* effect : sortedEffectList) { + MOZ_ASSERT(effect->GetAnimation(), + "Effects on a target element should have an Animation"); + CascadeLevel cascadeLevel = effect->GetAnimation()->CascadeLevel(); + + for (const AnimationProperty& prop : effect->Properties()) { + if (overriddenProperties.HasProperty(prop.mProperty)) { + propertiesWithImportantRules.AddProperty(prop.mProperty); + } + if (cascadeLevel == EffectCompositor::CascadeLevel::Animations) { + propertiesForAnimationsLevel.AddProperty(prop.mProperty); + } + + if (nsCSSProps::PropHasFlags(prop.mProperty, + CSS_PROPERTY_CAN_ANIMATE_ON_COMPOSITOR) && + cascadeLevel == EffectCompositor::CascadeLevel::Transitions) { + hasCompositorPropertiesForTransition = true; + } + } + } + + aEffectSet.MarkCascadeUpdated(); + + nsPresContext* presContext = GetPresContext(aElement); + if (!presContext) { + return; + } + + // If properties for compositor are newly overridden by !important rules, or + // released from being overridden by !important rules, we need to update + // layers for animations level because it's a trigger to send animations to + // the compositor or pull animations back from the compositor. + if (prevCompositorPropertiesWithImportantRules != + compositorPropertiesInSet(propertiesWithImportantRules)) { + presContext->EffectCompositor()-> + RequestRestyle(aElement, aPseudoType, + EffectCompositor::RestyleType::Layer, + EffectCompositor::CascadeLevel::Animations); + } + // If we have transition properties for compositor and if the same propery + // for animations level is newly added or removed, we need to update layers + // for transitions level because composite order has been changed now. + if (hasCompositorPropertiesForTransition && + prevCompositorPropertiesForAnimationsLevel != + compositorPropertiesInSet(propertiesForAnimationsLevel)) { + presContext->EffectCompositor()-> + RequestRestyle(aElement, aPseudoType, + EffectCompositor::RestyleType::Layer, + EffectCompositor::CascadeLevel::Transitions); + } +} + +/* static */ nsPresContext* +EffectCompositor::GetPresContext(Element* aElement) +{ + MOZ_ASSERT(aElement); + nsIPresShell* shell = nsComputedDOMStyle::GetPresShellForContent(aElement); + if (!shell) { + return nullptr; + } + return shell->GetPresContext(); +} + +/* static */ void +EffectCompositor::SetPerformanceWarning( + const nsIFrame *aFrame, + nsCSSPropertyID aProperty, + const AnimationPerformanceWarning& aWarning) +{ + EffectSet* effects = EffectSet::GetEffectSet(aFrame); + if (!effects) { + return; + } + + for (KeyframeEffectReadOnly* effect : *effects) { + effect->SetPerformanceWarning(aProperty, aWarning); + } +} + +// --------------------------------------------------------- +// +// Nested class: AnimationStyleRuleProcessor +// +// --------------------------------------------------------- + +NS_IMPL_ISUPPORTS(EffectCompositor::AnimationStyleRuleProcessor, + nsIStyleRuleProcessor) + +nsRestyleHint +EffectCompositor::AnimationStyleRuleProcessor::HasStateDependentStyle( + StateRuleProcessorData* aData) +{ + return nsRestyleHint(0); +} + +nsRestyleHint +EffectCompositor::AnimationStyleRuleProcessor::HasStateDependentStyle( + PseudoElementStateRuleProcessorData* aData) +{ + return nsRestyleHint(0); +} + +bool +EffectCompositor::AnimationStyleRuleProcessor::HasDocumentStateDependentStyle( + StateRuleProcessorData* aData) +{ + return false; +} + +nsRestyleHint +EffectCompositor::AnimationStyleRuleProcessor::HasAttributeDependentStyle( + AttributeRuleProcessorData* aData, + RestyleHintData& aRestyleHintDataResult) +{ + return nsRestyleHint(0); +} + +bool +EffectCompositor::AnimationStyleRuleProcessor::MediumFeaturesChanged( + nsPresContext* aPresContext) +{ + return false; +} + +void +EffectCompositor::AnimationStyleRuleProcessor::RulesMatching( + ElementRuleProcessorData* aData) +{ + nsIStyleRule *rule = + mCompositor->GetAnimationRule(aData->mElement, + CSSPseudoElementType::NotPseudo, + mCascadeLevel, + nullptr); + if (rule) { + aData->mRuleWalker->Forward(rule); + aData->mRuleWalker->CurrentNode()->SetIsAnimationRule(); + } +} + +void +EffectCompositor::AnimationStyleRuleProcessor::RulesMatching( + PseudoElementRuleProcessorData* aData) +{ + if (aData->mPseudoType != CSSPseudoElementType::before && + aData->mPseudoType != CSSPseudoElementType::after) { + return; + } + + nsIStyleRule *rule = + mCompositor->GetAnimationRule(aData->mElement, + aData->mPseudoType, + mCascadeLevel, + nullptr); + if (rule) { + aData->mRuleWalker->Forward(rule); + aData->mRuleWalker->CurrentNode()->SetIsAnimationRule(); + } +} + +void +EffectCompositor::AnimationStyleRuleProcessor::RulesMatching( + AnonBoxRuleProcessorData* aData) +{ +} + +#ifdef MOZ_XUL +void +EffectCompositor::AnimationStyleRuleProcessor::RulesMatching( + XULTreeRuleProcessorData* aData) +{ +} +#endif + +size_t +EffectCompositor::AnimationStyleRuleProcessor::SizeOfExcludingThis( + MallocSizeOf aMallocSizeOf) const +{ + return 0; +} + +size_t +EffectCompositor::AnimationStyleRuleProcessor::SizeOfIncludingThis( + MallocSizeOf aMallocSizeOf) const +{ + return aMallocSizeOf(this) + SizeOfExcludingThis(aMallocSizeOf); +} + +} // namespace mozilla diff --git a/dom/animation/EffectCompositor.h b/dom/animation/EffectCompositor.h new file mode 100644 index 000000000..732fbb333 --- /dev/null +++ b/dom/animation/EffectCompositor.h @@ -0,0 +1,307 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* 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/. */ + +#ifndef mozilla_EffectCompositor_h +#define mozilla_EffectCompositor_h + +#include "mozilla/EnumeratedArray.h" +#include "mozilla/Maybe.h" +#include "mozilla/OwningNonNull.h" +#include "mozilla/PseudoElementHashEntry.h" +#include "mozilla/RefPtr.h" +#include "nsCSSPropertyID.h" +#include "nsCycleCollectionParticipant.h" +#include "nsDataHashtable.h" +#include "nsIStyleRuleProcessor.h" +#include "nsTArray.h" + +class nsCSSPropertyIDSet; +class nsIFrame; +class nsIStyleRule; +class nsPresContext; +class nsStyleContext; + +namespace mozilla { + +class EffectSet; +class RestyleTracker; +struct AnimationPerformanceWarning; +struct NonOwningAnimationTarget; + +namespace dom { +class Animation; +class Element; +} + +class EffectCompositor +{ +public: + explicit EffectCompositor(nsPresContext* aPresContext) + : mPresContext(aPresContext) + { + for (size_t i = 0; i < kCascadeLevelCount; i++) { + CascadeLevel cascadeLevel = CascadeLevel(i); + mRuleProcessors[cascadeLevel] = + new AnimationStyleRuleProcessor(this, cascadeLevel); + } + } + + NS_INLINE_DECL_CYCLE_COLLECTING_NATIVE_REFCOUNTING(EffectCompositor) + NS_DECL_CYCLE_COLLECTION_NATIVE_CLASS(EffectCompositor) + + void Disconnect() { + mPresContext = nullptr; + } + + // Animations can be applied at two different levels in the CSS cascade: + enum class CascadeLevel { + // The animations sheet (CSS animations, script-generated animations, + // and CSS transitions that are no longer tied to CSS markup) + Animations, + // The transitions sheet (CSS transitions that are tied to CSS markup) + Transitions + }; + // We don't define this as part of CascadeLevel as then we'd have to add + // explicit checks for the Count enum value everywhere CascadeLevel is used. + static const size_t kCascadeLevelCount = + static_cast<size_t>(CascadeLevel::Transitions) + 1; + + // NOTE: This can return null after Disconnect(). + nsPresContext* PresContext() const { return mPresContext; } + + enum class RestyleType { + // Animation style has changed but the compositor is applying the same + // change so we might be able to defer updating the main thread until it + // becomes necessary. + Throttled, + // Animation style has changed and needs to be updated on the main thread. + Standard, + // Animation style has changed and needs to be updated on the main thread + // as well as forcing animations on layers to be updated. + // This is needed in cases such as when an animation becomes paused or has + // its playback rate changed. In such cases, although the computed style + // and refresh driver time might not change, we still need to ensure the + // corresponding animations on layers are updated to reflect the new + // configuration of the animation. + Layer + }; + + // Notifies the compositor that the animation rule for the specified + // (pseudo-)element at the specified cascade level needs to be updated. + // The specified steps taken to update the animation rule depend on + // |aRestyleType| whose values are described above. + void RequestRestyle(dom::Element* aElement, + CSSPseudoElementType aPseudoType, + RestyleType aRestyleType, + CascadeLevel aCascadeLevel); + + // Schedule an animation restyle. This is called automatically by + // RequestRestyle when necessary. However, it is exposed here since we also + // need to perform this step when triggering transitions *without* also + // invalidating the animation style rule (which RequestRestyle would do). + void PostRestyleForAnimation(dom::Element* aElement, + CSSPseudoElementType aPseudoType, + CascadeLevel aCascadeLevel); + + // Posts an animation restyle for any elements whose animation style rule + // is out of date but for which an animation restyle has not yet been + // posted because updates on the main thread are throttled. + void PostRestyleForThrottledAnimations(); + + // Called when the style context on the specified (pseudo-) element might + // have changed so that any context-sensitive values stored within + // animation effects (e.g. em-based endpoints used in keyframe effects) + // can be re-resolved to computed values. + void UpdateEffectProperties(nsStyleContext* aStyleContext, + dom::Element* aElement, + CSSPseudoElementType aPseudoType); + + // Updates the animation rule stored on the EffectSet for the + // specified (pseudo-)element for cascade level |aLevel|. + // If the animation rule is not marked as needing an update, + // no work is done. + // |aStyleContext| is used for UpdateCascadingResults. + // |aStyleContext| can be nullptr if style context, which is associated with + // the primary frame of the specified (pseudo-)element, is the current style + // context. + // If we are resolving a new style context, we shoud pass the newly created + // style context, otherwise we may use an old style context, it will result + // unexpected cascading results. + void MaybeUpdateAnimationRule(dom::Element* aElement, + CSSPseudoElementType aPseudoType, + CascadeLevel aCascadeLevel, + nsStyleContext *aStyleContext); + + // We need to pass the newly resolved style context as |aStyleContext| when + // we call this function during resolving style context because this function + // calls UpdateCascadingResults with a style context if necessary, at the + // time, we end up using the previous style context if we don't pass the new + // style context. + // When we are not resolving style context, |aStyleContext| can be nullptr, we + // will use a style context associated with the primary frame of the specified + // (pseudo-)element. + nsIStyleRule* GetAnimationRule(dom::Element* aElement, + CSSPseudoElementType aPseudoType, + CascadeLevel aCascadeLevel, + nsStyleContext* aStyleContext); + + bool HasPendingStyleUpdates() const; + bool HasThrottledStyleUpdates() const; + + // Tell the restyle tracker about all the animated styles that have + // pending updates so that it can update the animation rule for these + // elements. + void AddStyleUpdatesTo(RestyleTracker& aTracker); + + nsIStyleRuleProcessor* RuleProcessor(CascadeLevel aCascadeLevel) const + { + return mRuleProcessors[aCascadeLevel]; + } + + static bool HasAnimationsForCompositor(const nsIFrame* aFrame, + nsCSSPropertyID aProperty); + + static nsTArray<RefPtr<dom::Animation>> + GetAnimationsForCompositor(const nsIFrame* aFrame, + nsCSSPropertyID aProperty); + + static void ClearIsRunningOnCompositor(const nsIFrame* aFrame, + nsCSSPropertyID aProperty); + + // Update animation cascade results for the specified (pseudo-)element + // but only if we have marked the cascade as needing an update due a + // the change in the set of effects or a change in one of the effects' + // "in effect" state. + // |aStyleContext| may be nullptr in which case we will use the + // nsStyleContext of the primary frame of the specified (pseudo-)element. + // + // This method does NOT detect if other styles that apply above the + // animation level of the cascade have changed. + static void + MaybeUpdateCascadeResults(dom::Element* aElement, + CSSPseudoElementType aPseudoType, + nsStyleContext* aStyleContext); + + // Update the mPropertiesWithImportantRules and + // mPropertiesForAnimationsLevel members of the corresponding EffectSet. + // + // This can be expensive so we should only call it if styles that apply + // above the animation level of the cascade might have changed. For all + // other cases we should call MaybeUpdateCascadeResults. + static void + UpdateCascadeResults(dom::Element* aElement, + CSSPseudoElementType aPseudoType, + nsStyleContext* aStyleContext); + + // Helper to fetch the corresponding element and pseudo-type from a frame. + // + // For frames corresponding to pseudo-elements, the returned element is the + // element on which we store the animations (i.e. the EffectSet and/or + // AnimationCollection), *not* the generated content. + // + // Returns an empty result when a suitable element cannot be found including + // when the frame represents a pseudo-element on which we do not support + // animations. + static Maybe<NonOwningAnimationTarget> + GetAnimationElementAndPseudoForFrame(const nsIFrame* aFrame); + + // Associates a performance warning with effects on |aFrame| that animates + // |aProperty|. + static void SetPerformanceWarning( + const nsIFrame* aFrame, + nsCSSPropertyID aProperty, + const AnimationPerformanceWarning& aWarning); + +private: + ~EffectCompositor() = default; + + // Rebuilds the animation rule corresponding to |aCascadeLevel| on the + // EffectSet associated with the specified (pseudo-)element. + static void ComposeAnimationRule(dom::Element* aElement, + CSSPseudoElementType aPseudoType, + CascadeLevel aCascadeLevel, + TimeStamp aRefreshTime); + + static dom::Element* GetElementToRestyle(dom::Element* aElement, + CSSPseudoElementType + aPseudoType); + + // Get the properties in |aEffectSet| that we are able to animate on the + // compositor but which are also specified at a higher level in the cascade + // than the animations level in |aStyleContext|. + static void + GetOverriddenProperties(nsStyleContext* aStyleContext, + EffectSet& aEffectSet, + nsCSSPropertyIDSet& aPropertiesOverridden); + + static void + UpdateCascadeResults(EffectSet& aEffectSet, + dom::Element* aElement, + CSSPseudoElementType aPseudoType, + nsStyleContext* aStyleContext); + + static nsPresContext* GetPresContext(dom::Element* aElement); + + nsPresContext* mPresContext; + + // Elements with a pending animation restyle. The associated bool value is + // true if a pending animation restyle has also been dispatched. For + // animations that can be throttled, we will add an entry to the hashtable to + // indicate that the style rule on the element is out of date but without + // posting a restyle to update it. + EnumeratedArray<CascadeLevel, CascadeLevel(kCascadeLevelCount), + nsDataHashtable<PseudoElementHashEntry, bool>> + mElementsToRestyle; + + class AnimationStyleRuleProcessor final : public nsIStyleRuleProcessor + { + public: + AnimationStyleRuleProcessor(EffectCompositor* aCompositor, + CascadeLevel aCascadeLevel) + : mCompositor(aCompositor) + , mCascadeLevel(aCascadeLevel) + { + MOZ_ASSERT(aCompositor); + } + + NS_DECL_ISUPPORTS + + // nsIStyleRuleProcessor (parts) + nsRestyleHint HasStateDependentStyle( + StateRuleProcessorData* aData) override; + nsRestyleHint HasStateDependentStyle( + PseudoElementStateRuleProcessorData* aData) override; + bool HasDocumentStateDependentStyle(StateRuleProcessorData* aData) override; + nsRestyleHint HasAttributeDependentStyle( + AttributeRuleProcessorData* aData, + RestyleHintData& aRestyleHintDataResult) override; + bool MediumFeaturesChanged(nsPresContext* aPresContext) override; + void RulesMatching(ElementRuleProcessorData* aData) override; + void RulesMatching(PseudoElementRuleProcessorData* aData) override; + void RulesMatching(AnonBoxRuleProcessorData* aData) override; +#ifdef MOZ_XUL + void RulesMatching(XULTreeRuleProcessorData* aData) override; +#endif + size_t SizeOfExcludingThis(MallocSizeOf aMallocSizeOf) + const MOZ_MUST_OVERRIDE override; + size_t SizeOfIncludingThis(MallocSizeOf aMallocSizeOf) + const MOZ_MUST_OVERRIDE override; + + private: + ~AnimationStyleRuleProcessor() = default; + + EffectCompositor* mCompositor; + CascadeLevel mCascadeLevel; + }; + + EnumeratedArray<CascadeLevel, CascadeLevel(kCascadeLevelCount), + OwningNonNull<AnimationStyleRuleProcessor>> + mRuleProcessors; +}; + +} // namespace mozilla + +#endif // mozilla_EffectCompositor_h diff --git a/dom/animation/EffectSet.cpp b/dom/animation/EffectSet.cpp new file mode 100644 index 000000000..ffd3bb523 --- /dev/null +++ b/dom/animation/EffectSet.cpp @@ -0,0 +1,177 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* 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 "EffectSet.h" +#include "mozilla/dom/Element.h" // For Element +#include "mozilla/RestyleManagerHandle.h" +#include "mozilla/RestyleManagerHandleInlines.h" +#include "nsCSSPseudoElements.h" // For CSSPseudoElementType +#include "nsCycleCollectionNoteChild.h" // For CycleCollectionNoteChild +#include "nsPresContext.h" +#include "nsLayoutUtils.h" + +namespace mozilla { + +/* static */ void +EffectSet::PropertyDtor(void* aObject, nsIAtom* aPropertyName, + void* aPropertyValue, void* aData) +{ + EffectSet* effectSet = static_cast<EffectSet*>(aPropertyValue); + +#ifdef DEBUG + MOZ_ASSERT(!effectSet->mCalledPropertyDtor, "Should not call dtor twice"); + effectSet->mCalledPropertyDtor = true; +#endif + + delete effectSet; +} + +void +EffectSet::Traverse(nsCycleCollectionTraversalCallback& aCallback) +{ + for (auto iter = mEffects.Iter(); !iter.Done(); iter.Next()) { + CycleCollectionNoteChild(aCallback, iter.Get()->GetKey(), + "EffectSet::mEffects[]", aCallback.Flags()); + } +} + +/* static */ EffectSet* +EffectSet::GetEffectSet(dom::Element* aElement, + CSSPseudoElementType aPseudoType) +{ + nsIAtom* propName = GetEffectSetPropertyAtom(aPseudoType); + return static_cast<EffectSet*>(aElement->GetProperty(propName)); +} + +/* static */ EffectSet* +EffectSet::GetEffectSet(const nsIFrame* aFrame) +{ + Maybe<NonOwningAnimationTarget> target = + EffectCompositor::GetAnimationElementAndPseudoForFrame(aFrame); + + if (!target) { + return nullptr; + } + + if (!target->mElement->MayHaveAnimations()) { + return nullptr; + } + + return GetEffectSet(target->mElement, target->mPseudoType); +} + +/* static */ EffectSet* +EffectSet::GetOrCreateEffectSet(dom::Element* aElement, + CSSPseudoElementType aPseudoType) +{ + EffectSet* effectSet = GetEffectSet(aElement, aPseudoType); + if (effectSet) { + return effectSet; + } + + nsIAtom* propName = GetEffectSetPropertyAtom(aPseudoType); + effectSet = new EffectSet(); + + nsresult rv = aElement->SetProperty(propName, effectSet, + &EffectSet::PropertyDtor, true); + if (NS_FAILED(rv)) { + NS_WARNING("SetProperty failed"); + // The set must be destroyed via PropertyDtor, otherwise + // mCalledPropertyDtor assertion is triggered in destructor. + EffectSet::PropertyDtor(aElement, propName, effectSet, nullptr); + return nullptr; + } + + aElement->SetMayHaveAnimations(); + + return effectSet; +} + +/* static */ void +EffectSet::DestroyEffectSet(dom::Element* aElement, + CSSPseudoElementType aPseudoType) +{ + nsIAtom* propName = GetEffectSetPropertyAtom(aPseudoType); + EffectSet* effectSet = + static_cast<EffectSet*>(aElement->GetProperty(propName)); + if (!effectSet) { + return; + } + + MOZ_ASSERT(!effectSet->IsBeingEnumerated(), + "Should not destroy an effect set while it is being enumerated"); + effectSet = nullptr; + + aElement->DeleteProperty(propName); +} + +void +EffectSet::UpdateAnimationGeneration(nsPresContext* aPresContext) +{ + MOZ_ASSERT(aPresContext->RestyleManager()->IsGecko(), + "stylo: Servo-backed style system should not be using " + "EffectSet"); + mAnimationGeneration = + aPresContext->RestyleManager()->AsGecko()->GetAnimationGeneration(); +} + +/* static */ nsIAtom** +EffectSet::GetEffectSetPropertyAtoms() +{ + static nsIAtom* effectSetPropertyAtoms[] = + { + nsGkAtoms::animationEffectsProperty, + nsGkAtoms::animationEffectsForBeforeProperty, + nsGkAtoms::animationEffectsForAfterProperty, + nullptr + }; + + return effectSetPropertyAtoms; +} + +/* static */ nsIAtom* +EffectSet::GetEffectSetPropertyAtom(CSSPseudoElementType aPseudoType) +{ + switch (aPseudoType) { + case CSSPseudoElementType::NotPseudo: + return nsGkAtoms::animationEffectsProperty; + + case CSSPseudoElementType::before: + return nsGkAtoms::animationEffectsForBeforeProperty; + + case CSSPseudoElementType::after: + return nsGkAtoms::animationEffectsForAfterProperty; + + default: + NS_NOTREACHED("Should not try to get animation effects for a pseudo " + "other that :before or :after"); + return nullptr; + } +} + +void +EffectSet::AddEffect(dom::KeyframeEffectReadOnly& aEffect) +{ + if (mEffects.Contains(&aEffect)) { + return; + } + + mEffects.PutEntry(&aEffect); + MarkCascadeNeedsUpdate(); +} + +void +EffectSet::RemoveEffect(dom::KeyframeEffectReadOnly& aEffect) +{ + if (!mEffects.Contains(&aEffect)) { + return; + } + + mEffects.RemoveEntry(&aEffect); + MarkCascadeNeedsUpdate(); +} + +} // namespace mozilla diff --git a/dom/animation/EffectSet.h b/dom/animation/EffectSet.h new file mode 100644 index 000000000..9ba31ef91 --- /dev/null +++ b/dom/animation/EffectSet.h @@ -0,0 +1,261 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* 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/. */ + +#ifndef mozilla_EffectSet_h +#define mozilla_EffectSet_h + +#include "mozilla/AnimValuesStyleRule.h" +#include "mozilla/DebugOnly.h" +#include "mozilla/EffectCompositor.h" +#include "mozilla/EnumeratedArray.h" +#include "mozilla/TimeStamp.h" +#include "mozilla/dom/KeyframeEffectReadOnly.h" +#include "nsHashKeys.h" // For nsPtrHashKey +#include "nsTHashtable.h" // For nsTHashtable + +class nsPresContext; + +namespace mozilla { + +namespace dom { +class Element; +} // namespace dom + +enum class CSSPseudoElementType : uint8_t; + +// A wrapper around a hashset of AnimationEffect objects to handle +// storing the set as a property of an element. +class EffectSet +{ +public: + EffectSet() + : mCascadeNeedsUpdate(false) + , mAnimationGeneration(0) +#ifdef DEBUG + , mActiveIterators(0) + , mCalledPropertyDtor(false) +#endif + { + MOZ_COUNT_CTOR(EffectSet); + } + + ~EffectSet() + { + MOZ_ASSERT(mCalledPropertyDtor, + "must call destructor through element property dtor"); + MOZ_ASSERT(mActiveIterators == 0, + "Effect set should not be destroyed while it is being " + "enumerated"); + MOZ_COUNT_DTOR(EffectSet); + } + static void PropertyDtor(void* aObject, nsIAtom* aPropertyName, + void* aPropertyValue, void* aData); + + // Methods for supporting cycle-collection + void Traverse(nsCycleCollectionTraversalCallback& aCallback); + + static EffectSet* GetEffectSet(dom::Element* aElement, + CSSPseudoElementType aPseudoType); + static EffectSet* GetEffectSet(const nsIFrame* aFrame); + static EffectSet* GetOrCreateEffectSet(dom::Element* aElement, + CSSPseudoElementType aPseudoType); + static void DestroyEffectSet(dom::Element* aElement, + CSSPseudoElementType aPseudoType); + + void AddEffect(dom::KeyframeEffectReadOnly& aEffect); + void RemoveEffect(dom::KeyframeEffectReadOnly& aEffect); + +private: + typedef nsTHashtable<nsRefPtrHashKey<dom::KeyframeEffectReadOnly>> + OwningEffectSet; + +public: + // A simple iterator to support iterating over the effects in this object in + // range-based for loops. + // + // This allows us to avoid exposing mEffects directly and saves the + // caller from having to dereference hashtable iterators using + // the rather complicated: iter.Get()->GetKey(). + class Iterator + { + public: + explicit Iterator(EffectSet& aEffectSet) + : mEffectSet(aEffectSet) + , mHashIterator(mozilla::Move(aEffectSet.mEffects.Iter())) + , mIsEndIterator(false) + { +#ifdef DEBUG + mEffectSet.mActiveIterators++; +#endif + } + + Iterator(Iterator&& aOther) + : mEffectSet(aOther.mEffectSet) + , mHashIterator(mozilla::Move(aOther.mHashIterator)) + , mIsEndIterator(aOther.mIsEndIterator) + { +#ifdef DEBUG + mEffectSet.mActiveIterators++; +#endif + } + + static Iterator EndIterator(EffectSet& aEffectSet) + { + Iterator result(aEffectSet); + result.mIsEndIterator = true; + return result; + } + + ~Iterator() + { +#ifdef DEBUG + MOZ_ASSERT(mEffectSet.mActiveIterators > 0); + mEffectSet.mActiveIterators--; +#endif + } + + bool operator!=(const Iterator& aOther) const { + if (Done() || aOther.Done()) { + return Done() != aOther.Done(); + } + return mHashIterator.Get() != aOther.mHashIterator.Get(); + } + + Iterator& operator++() { + MOZ_ASSERT(!Done()); + mHashIterator.Next(); + return *this; + } + + dom::KeyframeEffectReadOnly* operator* () + { + MOZ_ASSERT(!Done()); + return mHashIterator.Get()->GetKey(); + } + + private: + Iterator() = delete; + Iterator(const Iterator&) = delete; + Iterator& operator=(const Iterator&) = delete; + Iterator& operator=(const Iterator&&) = delete; + + bool Done() const { + return mIsEndIterator || mHashIterator.Done(); + } + + EffectSet& mEffectSet; + OwningEffectSet::Iterator mHashIterator; + bool mIsEndIterator; + }; + + friend class Iterator; + + Iterator begin() { return Iterator(*this); } + Iterator end() { return Iterator::EndIterator(*this); } +#ifdef DEBUG + bool IsBeingEnumerated() const { return mActiveIterators != 0; } +#endif + + bool IsEmpty() const { return mEffects.IsEmpty(); } + + size_t Count() const { return mEffects.Count(); } + + RefPtr<AnimValuesStyleRule>& AnimationRule(EffectCompositor::CascadeLevel + aCascadeLevel) + { + return mAnimationRule[aCascadeLevel]; + } + + const TimeStamp& AnimationRuleRefreshTime(EffectCompositor::CascadeLevel + aCascadeLevel) const + { + return mAnimationRuleRefreshTime[aCascadeLevel]; + } + void UpdateAnimationRuleRefreshTime(EffectCompositor::CascadeLevel + aCascadeLevel, + const TimeStamp& aRefreshTime) + { + mAnimationRuleRefreshTime[aCascadeLevel] = aRefreshTime; + } + + bool CascadeNeedsUpdate() const { return mCascadeNeedsUpdate; } + void MarkCascadeNeedsUpdate() { mCascadeNeedsUpdate = true; } + void MarkCascadeUpdated() { mCascadeNeedsUpdate = false; } + + void UpdateAnimationGeneration(nsPresContext* aPresContext); + uint64_t GetAnimationGeneration() const { return mAnimationGeneration; } + + static nsIAtom** GetEffectSetPropertyAtoms(); + + nsCSSPropertyIDSet& PropertiesWithImportantRules() + { + return mPropertiesWithImportantRules; + } + nsCSSPropertyIDSet& PropertiesForAnimationsLevel() + { + return mPropertiesForAnimationsLevel; + } + +private: + static nsIAtom* GetEffectSetPropertyAtom(CSSPseudoElementType aPseudoType); + + OwningEffectSet mEffects; + + // These style rules contain the style data for currently animating + // values. They only match when styling with animation. When we + // style without animation, we need to not use them so that we can + // detect any new changes; if necessary we restyle immediately + // afterwards with animation. + EnumeratedArray<EffectCompositor::CascadeLevel, + EffectCompositor::CascadeLevel( + EffectCompositor::kCascadeLevelCount), + RefPtr<AnimValuesStyleRule>> mAnimationRule; + + // A parallel array to mAnimationRule that records the refresh driver + // timestamp when the rule was last updated. This is used for certain + // animations which are updated only periodically (e.g. transform animations + // running on the compositor that affect the scrollable overflow region). + EnumeratedArray<EffectCompositor::CascadeLevel, + EffectCompositor::CascadeLevel( + EffectCompositor::kCascadeLevelCount), + TimeStamp> mAnimationRuleRefreshTime; + + // Dirty flag to represent when the mPropertiesWithImportantRules and + // mPropertiesForAnimationsLevel on effects in this set might need to be + // updated. + // + // Set to true any time the set of effects is changed or when + // one the effects goes in or out of the "in effect" state. + bool mCascadeNeedsUpdate; + + // RestyleManager keeps track of the number of animation restyles. + // 'mini-flushes' (see nsTransitionManager::UpdateAllThrottledStyles()). + // mAnimationGeneration is the sequence number of the last flush where a + // transition/animation changed. We keep a similar count on the + // corresponding layer so we can check that the layer is up to date with + // the animation manager. + uint64_t mAnimationGeneration; + + // Specifies the compositor-animatable properties that are overridden by + // !important rules. + nsCSSPropertyIDSet mPropertiesWithImportantRules; + // Specifies the properties for which the result will be added to the + // animations level of the cascade and hence should be skipped when we are + // composing the animation style for the transitions level of the cascede. + nsCSSPropertyIDSet mPropertiesForAnimationsLevel; + +#ifdef DEBUG + // Track how many iterators are referencing this effect set when we are + // destroyed, we can assert that nothing is still pointing to us. + uint64_t mActiveIterators; + + bool mCalledPropertyDtor; +#endif +}; + +} // namespace mozilla + +#endif // mozilla_EffectSet_h diff --git a/dom/animation/KeyframeEffect.cpp b/dom/animation/KeyframeEffect.cpp new file mode 100644 index 000000000..decbf6305 --- /dev/null +++ b/dom/animation/KeyframeEffect.cpp @@ -0,0 +1,211 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* 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 "mozilla/dom/KeyframeEffect.h" + +#include "mozilla/dom/KeyframeAnimationOptionsBinding.h" + // For UnrestrictedDoubleOrKeyframeAnimationOptions +#include "mozilla/dom/AnimationEffectTiming.h" +#include "mozilla/dom/KeyframeEffectBinding.h" +#include "mozilla/KeyframeUtils.h" +#include "nsDOMMutationObserver.h" // For nsAutoAnimationMutationBatch +#include "nsIScriptError.h" + +namespace mozilla { +namespace dom { + +KeyframeEffect::KeyframeEffect(nsIDocument* aDocument, + const Maybe<OwningAnimationTarget>& aTarget, + const TimingParams& aTiming, + const KeyframeEffectParams& aOptions) + : KeyframeEffectReadOnly(aDocument, aTarget, + new AnimationEffectTiming(aDocument, aTiming, this), + aOptions) +{ +} + +JSObject* +KeyframeEffect::WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) +{ + return KeyframeEffectBinding::Wrap(aCx, this, aGivenProto); +} + +/* static */ already_AddRefed<KeyframeEffect> +KeyframeEffect::Constructor( + const GlobalObject& aGlobal, + const Nullable<ElementOrCSSPseudoElement>& aTarget, + JS::Handle<JSObject*> aKeyframes, + const UnrestrictedDoubleOrKeyframeEffectOptions& aOptions, + ErrorResult& aRv) +{ + return ConstructKeyframeEffect<KeyframeEffect>(aGlobal, aTarget, aKeyframes, + aOptions, aRv); +} + +/* static */ already_AddRefed<KeyframeEffect> +KeyframeEffect::Constructor(const GlobalObject& aGlobal, + KeyframeEffectReadOnly& aSource, + ErrorResult& aRv) +{ + return ConstructKeyframeEffect<KeyframeEffect>(aGlobal, aSource, aRv); +} + +/* static */ already_AddRefed<KeyframeEffect> +KeyframeEffect::Constructor( + const GlobalObject& aGlobal, + const Nullable<ElementOrCSSPseudoElement>& aTarget, + JS::Handle<JSObject*> aKeyframes, + const UnrestrictedDoubleOrKeyframeAnimationOptions& aOptions, + ErrorResult& aRv) +{ + return ConstructKeyframeEffect<KeyframeEffect>(aGlobal, aTarget, aKeyframes, + aOptions, aRv); +} + +void +KeyframeEffect::NotifySpecifiedTimingUpdated() +{ + // Use the same document for a pseudo element and its parent element. + // Use nullptr if we don't have mTarget, so disable the mutation batch. + nsAutoAnimationMutationBatch mb(mTarget ? mTarget->mElement->OwnerDoc() + : nullptr); + + if (mAnimation) { + mAnimation->NotifyEffectTimingUpdated(); + + if (mAnimation->IsRelevant()) { + nsNodeUtils::AnimationChanged(mAnimation); + } + + RequestRestyle(EffectCompositor::RestyleType::Layer); + } +} + +void +KeyframeEffect::SetTarget(const Nullable<ElementOrCSSPseudoElement>& aTarget) +{ + Maybe<OwningAnimationTarget> newTarget = ConvertTarget(aTarget); + if (mTarget == newTarget) { + // Assign the same target, skip it. + return; + } + + if (mTarget) { + UnregisterTarget(); + ResetIsRunningOnCompositor(); + + RequestRestyle(EffectCompositor::RestyleType::Layer); + + nsAutoAnimationMutationBatch mb(mTarget->mElement->OwnerDoc()); + if (mAnimation) { + nsNodeUtils::AnimationRemoved(mAnimation); + } + } + + mTarget = newTarget; + + if (mTarget) { + UpdateTargetRegistration(); + RefPtr<nsStyleContext> styleContext = GetTargetStyleContext(); + if (styleContext) { + UpdateProperties(styleContext); + } else if (mEffectOptions.mSpacingMode == SpacingMode::paced) { + KeyframeUtils::ApplyDistributeSpacing(mKeyframes); + } + + MaybeUpdateFrameForCompositor(); + + RequestRestyle(EffectCompositor::RestyleType::Layer); + + nsAutoAnimationMutationBatch mb(mTarget->mElement->OwnerDoc()); + if (mAnimation) { + nsNodeUtils::AnimationAdded(mAnimation); + } + } else if (mEffectOptions.mSpacingMode == SpacingMode::paced) { + // New target is null, so fall back to distribute spacing. + KeyframeUtils::ApplyDistributeSpacing(mKeyframes); + } +} + +void +KeyframeEffect::SetIterationComposite( + const IterationCompositeOperation& aIterationComposite) +{ + // Ignore iterationComposite if the Web Animations API is not enabled, + // then the default value 'Replace' will be used. + if (!AnimationUtils::IsCoreAPIEnabledForCaller()) { + return; + } + + if (mEffectOptions.mIterationComposite == aIterationComposite) { + return; + } + + if (mAnimation && mAnimation->IsRelevant()) { + nsNodeUtils::AnimationChanged(mAnimation); + } + + mEffectOptions.mIterationComposite = aIterationComposite; + RequestRestyle(EffectCompositor::RestyleType::Layer); +} + +void +KeyframeEffect::SetSpacing(JSContext* aCx, + const nsAString& aSpacing, + ErrorResult& aRv) +{ + SpacingMode spacingMode = SpacingMode::distribute; + nsCSSPropertyID pacedProperty = eCSSProperty_UNKNOWN; + nsAutoString invalidPacedProperty; + KeyframeEffectParams::ParseSpacing(aSpacing, + spacingMode, + pacedProperty, + invalidPacedProperty, + aRv); + if (aRv.Failed()) { + return; + } + + if (!invalidPacedProperty.IsEmpty()) { + const char16_t* params[] = { invalidPacedProperty.get() }; + nsIDocument* doc = AnimationUtils::GetCurrentRealmDocument(aCx); + nsContentUtils::ReportToConsole(nsIScriptError::warningFlag, + NS_LITERAL_CSTRING("Animation"), + doc, + nsContentUtils::eDOM_PROPERTIES, + "UnanimatablePacedProperty", + params, ArrayLength(params)); + } + + if (mEffectOptions.mSpacingMode == spacingMode && + mEffectOptions.mPacedProperty == pacedProperty) { + return; + } + + mEffectOptions.mSpacingMode = spacingMode; + mEffectOptions.mPacedProperty = pacedProperty; + + // Apply spacing. We apply distribute here. If the new spacing is paced, + // UpdateProperties() will apply it. + if (mEffectOptions.mSpacingMode == SpacingMode::distribute) { + KeyframeUtils::ApplyDistributeSpacing(mKeyframes); + } + + if (mAnimation && mAnimation->IsRelevant()) { + nsNodeUtils::AnimationChanged(mAnimation); + } + + if (mTarget) { + RefPtr<nsStyleContext> styleContext = GetTargetStyleContext(); + if (styleContext) { + UpdateProperties(styleContext); + } + } +} + +} // namespace dom +} // namespace mozilla diff --git a/dom/animation/KeyframeEffect.h b/dom/animation/KeyframeEffect.h new file mode 100644 index 000000000..3c704a820 --- /dev/null +++ b/dom/animation/KeyframeEffect.h @@ -0,0 +1,82 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* 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/. */ + +#ifndef mozilla_dom_KeyframeEffect_h +#define mozilla_dom_KeyframeEffect_h + +#include "nsWrapperCache.h" +#include "mozilla/dom/KeyframeEffectReadOnly.h" +#include "mozilla/AnimationTarget.h" // For (Non)OwningAnimationTarget +#include "mozilla/Maybe.h" + +struct JSContext; +class JSObject; +class nsIDocument; + +namespace mozilla { + +class ErrorResult; +struct KeyframeEffectParams; +struct TimingParams; + +namespace dom { + +class ElementOrCSSPseudoElement; +class GlobalObject; +class UnrestrictedDoubleOrKeyframeAnimationOptions; +class UnrestrictedDoubleOrKeyframeEffectOptions; + +class KeyframeEffect : public KeyframeEffectReadOnly +{ +public: + KeyframeEffect(nsIDocument* aDocument, + const Maybe<OwningAnimationTarget>& aTarget, + const TimingParams& aTiming, + const KeyframeEffectParams& aOptions); + + JSObject* WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) override; + + static already_AddRefed<KeyframeEffect> + Constructor(const GlobalObject& aGlobal, + const Nullable<ElementOrCSSPseudoElement>& aTarget, + JS::Handle<JSObject*> aKeyframes, + const UnrestrictedDoubleOrKeyframeEffectOptions& aOptions, + ErrorResult& aRv); + + static already_AddRefed<KeyframeEffect> + Constructor(const GlobalObject& aGlobal, + KeyframeEffectReadOnly& aSource, + ErrorResult& aRv); + + // Variant of Constructor that accepts a KeyframeAnimationOptions object + // for use with for Animatable.animate. + // Not exposed to content. + static already_AddRefed<KeyframeEffect> + Constructor(const GlobalObject& aGlobal, + const Nullable<ElementOrCSSPseudoElement>& aTarget, + JS::Handle<JSObject*> aKeyframes, + const UnrestrictedDoubleOrKeyframeAnimationOptions& aOptions, + ErrorResult& aRv); + + void NotifySpecifiedTimingUpdated(); + + // This method calls GetTargetStyleContext which is not safe to use when + // we are in the middle of updating style. If we need to use this when + // updating style, we should pass the nsStyleContext into this method and use + // that to update the properties rather than calling + // GetStyleContextForElement. + void SetTarget(const Nullable<ElementOrCSSPseudoElement>& aTarget); + + void SetSpacing(JSContext* aCx, const nsAString& aSpacing, ErrorResult& aRv); + void SetIterationComposite( + const IterationCompositeOperation& aIterationComposite); +}; + +} // namespace dom +} // namespace mozilla + +#endif // mozilla_dom_KeyframeEffect_h diff --git a/dom/animation/KeyframeEffectParams.cpp b/dom/animation/KeyframeEffectParams.cpp new file mode 100644 index 000000000..257640691 --- /dev/null +++ b/dom/animation/KeyframeEffectParams.cpp @@ -0,0 +1,169 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* 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 "mozilla/KeyframeEffectParams.h" + +#include "mozilla/AnimationUtils.h" +#include "mozilla/ErrorResult.h" +#include "mozilla/KeyframeUtils.h" +#include "mozilla/RangedPtr.h" +#include "nsReadableUtils.h" + +namespace mozilla { + +static inline bool +IsLetter(char16_t aCh) +{ + return (0x41 <= aCh && aCh <= 0x5A) || (0x61 <= aCh && aCh <= 0x7A); +} + +static inline bool +IsDigit(char16_t aCh) +{ + return 0x30 <= aCh && aCh <= 0x39; +} + +static inline bool +IsNameStartCode(char16_t aCh) +{ + return IsLetter(aCh) || aCh >= 0x80 || aCh == '_'; +} + +static inline bool +IsNameCode(char16_t aCh) +{ + return IsNameStartCode(aCh) || IsDigit(aCh) || aCh == '-'; +} + +static inline bool +IsNewLine(char16_t aCh) +{ + // 0x0A (LF), 0x0C (FF), 0x0D (CR), or pairs of CR followed by LF are + // replaced by LF. + return aCh == 0x0A || aCh == 0x0C || aCh == 0x0D; +} + +static inline bool +IsValidEscape(char16_t aFirst, char16_t aSecond) +{ + return aFirst == '\\' && !IsNewLine(aSecond); +} + +static bool +IsIdentStart(RangedPtr<const char16_t> aIter, + const char16_t* const aEnd) +{ + if (aIter == aEnd) { + return false; + } + + if (*aIter == '-') { + if (aIter + 1 == aEnd) { + return false; + } + char16_t second = *(aIter + 1); + return IsNameStartCode(second) || + second == '-' || + (aIter + 2 != aEnd && IsValidEscape(second, *(aIter + 2))); + } + return IsNameStartCode(*aIter) || + (aIter + 1 != aEnd && IsValidEscape(*aIter, *(aIter + 1))); +} + +static void +ConsumeIdentToken(RangedPtr<const char16_t>& aIter, + const char16_t* const aEnd, + nsAString& aResult) +{ + aResult.Truncate(); + + // Check if it starts with an identifier. + if (!IsIdentStart(aIter, aEnd)) { + return; + } + + // Start to consume. + while (aIter != aEnd) { + if (IsNameCode(*aIter)) { + aResult.Append(*aIter); + } else if (*aIter == '\\') { + const RangedPtr<const char16_t> secondChar = aIter + 1; + if (secondChar == aEnd || !IsValidEscape(*aIter, *secondChar)) { + break; + } + // Consume '\\' and append the character following this '\\'. + ++aIter; + aResult.Append(*aIter); + } else { + break; + } + ++aIter; + } +} + +/* static */ void +KeyframeEffectParams::ParseSpacing(const nsAString& aSpacing, + SpacingMode& aSpacingMode, + nsCSSPropertyID& aPacedProperty, + nsAString& aInvalidPacedProperty, + ErrorResult& aRv) +{ + aInvalidPacedProperty.Truncate(); + + // Ignore spacing if the core API is not enabled since it is not yet ready to + // ship. + if (!AnimationUtils::IsCoreAPIEnabledForCaller()) { + aSpacingMode = SpacingMode::distribute; + return; + } + + // Parse spacing. + // distribute | paced({ident}) + // https://w3c.github.io/web-animations/#dom-keyframeeffectreadonly-spacing + // 1. distribute spacing. + if (aSpacing.EqualsLiteral("distribute")) { + aSpacingMode = SpacingMode::distribute; + return; + } + + // 2. paced spacing. + static const nsLiteralString kPacedPrefix = NS_LITERAL_STRING("paced("); + if (!StringBeginsWith(aSpacing, kPacedPrefix)) { + aRv.ThrowTypeError<dom::MSG_INVALID_SPACING_MODE_ERROR>(aSpacing); + return; + } + + RangedPtr<const char16_t> iter(aSpacing.Data() + kPacedPrefix.Length(), + aSpacing.Data(), aSpacing.Length()); + const char16_t* const end = aSpacing.EndReading(); + + nsAutoString identToken; + ConsumeIdentToken(iter, end, identToken); + if (identToken.IsEmpty()) { + aRv.ThrowTypeError<dom::MSG_INVALID_SPACING_MODE_ERROR>(aSpacing); + return; + } + + aPacedProperty = + nsCSSProps::LookupProperty(identToken, CSSEnabledState::eForAllContent); + if (aPacedProperty == eCSSProperty_UNKNOWN || + aPacedProperty == eCSSPropertyExtra_variable || + !KeyframeUtils::IsAnimatableProperty(aPacedProperty)) { + aPacedProperty = eCSSProperty_UNKNOWN; + aInvalidPacedProperty = identToken; + } + + if (end - iter.get() != 1 || *iter != ')') { + aRv.ThrowTypeError<dom::MSG_INVALID_SPACING_MODE_ERROR>(aSpacing); + return; + } + + aSpacingMode = aPacedProperty == eCSSProperty_UNKNOWN + ? SpacingMode::distribute + : SpacingMode::paced; +} + +} // namespace mozilla diff --git a/dom/animation/KeyframeEffectParams.h b/dom/animation/KeyframeEffectParams.h new file mode 100644 index 000000000..92a284eae --- /dev/null +++ b/dom/animation/KeyframeEffectParams.h @@ -0,0 +1,68 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* 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/. */ + +#ifndef mozilla_KeyframeEffectParams_h +#define mozilla_KeyframeEffectParams_h + +#include "nsCSSProps.h" +#include "nsString.h" +// X11 has a #define for None +#ifdef None +#undef None +#endif +#include "mozilla/dom/KeyframeEffectBinding.h" // IterationCompositeOperation + +namespace mozilla { + +class ErrorResult; + +enum class SpacingMode +{ + distribute, + paced +}; + +struct KeyframeEffectParams +{ + void GetSpacingAsString(nsAString& aSpacing) const + { + if (mSpacingMode == SpacingMode::distribute) { + aSpacing.AssignLiteral("distribute"); + } else { + aSpacing.AssignLiteral("paced("); + aSpacing.AppendASCII(nsCSSProps::GetStringValue(mPacedProperty).get()); + aSpacing.AppendLiteral(")"); + } + } + + /** + * Parse spacing string. + * + * @param aSpacing The input spacing string. + * @param [out] aSpacingMode The parsed spacing mode. + * @param [out] aPacedProperty The parsed CSS property if using paced spacing. + * @param [out] aInvalidPacedProperty A string that, if we parsed a string of + * the form 'paced(<ident>)' where <ident> + * is not a recognized animatable property, + * will be set to <ident>. + * @param [out] aRv The error result. + */ + static void ParseSpacing(const nsAString& aSpacing, + SpacingMode& aSpacingMode, + nsCSSPropertyID& aPacedProperty, + nsAString& aInvalidPacedProperty, + ErrorResult& aRv); + + dom::IterationCompositeOperation mIterationComposite = + dom::IterationCompositeOperation::Replace; + // FIXME: Bug 1216844: Add CompositeOperation + SpacingMode mSpacingMode = SpacingMode::distribute; + nsCSSPropertyID mPacedProperty = eCSSProperty_UNKNOWN; +}; + +} // namespace mozilla + +#endif // mozilla_KeyframeEffectParams_h diff --git a/dom/animation/KeyframeEffectReadOnly.cpp b/dom/animation/KeyframeEffectReadOnly.cpp new file mode 100644 index 000000000..639e0b2b0 --- /dev/null +++ b/dom/animation/KeyframeEffectReadOnly.cpp @@ -0,0 +1,1430 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* 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 "mozilla/dom/KeyframeEffectReadOnly.h" + +#include "mozilla/dom/KeyframeAnimationOptionsBinding.h" + // For UnrestrictedDoubleOrKeyframeAnimationOptions; +#include "mozilla/dom/CSSPseudoElement.h" +#include "mozilla/dom/KeyframeEffectBinding.h" +#include "mozilla/AnimationUtils.h" +#include "mozilla/EffectSet.h" +#include "mozilla/FloatingPoint.h" // For IsFinite +#include "mozilla/LookAndFeel.h" // For LookAndFeel::GetInt +#include "mozilla/KeyframeUtils.h" +#include "mozilla/ServoBindings.h" +#include "mozilla/StyleAnimationValue.h" +#include "Layers.h" // For Layer +#include "nsComputedDOMStyle.h" // nsComputedDOMStyle::GetStyleContextForElement +#include "nsContentUtils.h" // nsContentUtils::ReportToConsole +#include "nsCSSPropertyIDSet.h" +#include "nsCSSProps.h" // For nsCSSProps::PropHasFlags +#include "nsCSSPseudoElements.h" // For CSSPseudoElementType +#include "nsIPresShell.h" +#include "nsIScriptError.h" + +namespace mozilla { + +bool +PropertyValuePair::operator==(const PropertyValuePair& aOther) const +{ + if (mProperty != aOther.mProperty || mValue != aOther.mValue) { + return false; + } + if (mServoDeclarationBlock == aOther.mServoDeclarationBlock) { + return true; + } + if (!mServoDeclarationBlock || !aOther.mServoDeclarationBlock) { + return false; + } + return Servo_DeclarationBlock_Equals(mServoDeclarationBlock, + aOther.mServoDeclarationBlock); +} + +namespace dom { + +NS_IMPL_CYCLE_COLLECTION_INHERITED(KeyframeEffectReadOnly, + AnimationEffectReadOnly, + mTarget) + +NS_IMPL_CYCLE_COLLECTION_TRACE_BEGIN_INHERITED(KeyframeEffectReadOnly, + AnimationEffectReadOnly) +NS_IMPL_CYCLE_COLLECTION_TRACE_END + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION_INHERITED(KeyframeEffectReadOnly) +NS_INTERFACE_MAP_END_INHERITING(AnimationEffectReadOnly) + +NS_IMPL_ADDREF_INHERITED(KeyframeEffectReadOnly, AnimationEffectReadOnly) +NS_IMPL_RELEASE_INHERITED(KeyframeEffectReadOnly, AnimationEffectReadOnly) + +KeyframeEffectReadOnly::KeyframeEffectReadOnly( + nsIDocument* aDocument, + const Maybe<OwningAnimationTarget>& aTarget, + const TimingParams& aTiming, + const KeyframeEffectParams& aOptions) + : KeyframeEffectReadOnly(aDocument, aTarget, + new AnimationEffectTimingReadOnly(aDocument, + aTiming), + aOptions) +{ +} + +KeyframeEffectReadOnly::KeyframeEffectReadOnly( + nsIDocument* aDocument, + const Maybe<OwningAnimationTarget>& aTarget, + AnimationEffectTimingReadOnly* aTiming, + const KeyframeEffectParams& aOptions) + : AnimationEffectReadOnly(aDocument, aTiming) + , mTarget(aTarget) + , mEffectOptions(aOptions) + , mInEffectOnLastAnimationTimingUpdate(false) + , mCumulativeChangeHint(nsChangeHint(0)) +{ +} + +JSObject* +KeyframeEffectReadOnly::WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) +{ + return KeyframeEffectReadOnlyBinding::Wrap(aCx, this, aGivenProto); +} + +IterationCompositeOperation +KeyframeEffectReadOnly::IterationComposite() const +{ + return mEffectOptions.mIterationComposite; +} + +CompositeOperation +KeyframeEffectReadOnly::Composite() const +{ + return CompositeOperation::Replace; +} + +void +KeyframeEffectReadOnly::NotifyAnimationTimingUpdated() +{ + UpdateTargetRegistration(); + + // If the effect is not relevant it will be removed from the target + // element's effect set. However, effects not in the effect set + // will not be included in the set of candidate effects for running on + // the compositor and hence they won't have their compositor status + // updated. As a result, we need to make sure we clear their compositor + // status here. + bool isRelevant = mAnimation && mAnimation->IsRelevant(); + if (!isRelevant) { + ResetIsRunningOnCompositor(); + } + + // Detect changes to "in effect" status since we need to recalculate the + // animation cascade for this element whenever that changes. + bool inEffect = IsInEffect(); + if (inEffect != mInEffectOnLastAnimationTimingUpdate) { + MarkCascadeNeedsUpdate(); + mInEffectOnLastAnimationTimingUpdate = inEffect; + } + + // Request restyle if necessary. + if (mAnimation && !mProperties.IsEmpty() && HasComputedTimingChanged()) { + EffectCompositor::RestyleType restyleType = + CanThrottle() ? + EffectCompositor::RestyleType::Throttled : + EffectCompositor::RestyleType::Standard; + RequestRestyle(restyleType); + } + + // If we're no longer "in effect", our ComposeStyle method will never be + // called and we will never have a chance to update mProgressOnLastCompose + // and mCurrentIterationOnLastCompose. + // We clear them here to ensure that if we later become "in effect" we will + // request a restyle (above). + if (!inEffect) { + mProgressOnLastCompose.SetNull(); + mCurrentIterationOnLastCompose = 0; + } +} + +static bool +KeyframesEqualIgnoringComputedOffsets(const nsTArray<Keyframe>& aLhs, + const nsTArray<Keyframe>& aRhs) +{ + if (aLhs.Length() != aRhs.Length()) { + return false; + } + + for (size_t i = 0, len = aLhs.Length(); i < len; ++i) { + const Keyframe& a = aLhs[i]; + const Keyframe& b = aRhs[i]; + if (a.mOffset != b.mOffset || + a.mTimingFunction != b.mTimingFunction || + a.mPropertyValues != b.mPropertyValues) { + return false; + } + } + return true; +} + +// https://w3c.github.io/web-animations/#dom-keyframeeffect-setkeyframes +void +KeyframeEffectReadOnly::SetKeyframes(JSContext* aContext, + JS::Handle<JSObject*> aKeyframes, + ErrorResult& aRv) +{ + nsTArray<Keyframe> keyframes = + KeyframeUtils::GetKeyframesFromObject(aContext, mDocument, aKeyframes, aRv); + if (aRv.Failed()) { + return; + } + + RefPtr<nsStyleContext> styleContext = GetTargetStyleContext(); + SetKeyframes(Move(keyframes), styleContext); +} + +void +KeyframeEffectReadOnly::SetKeyframes(nsTArray<Keyframe>&& aKeyframes, + nsStyleContext* aStyleContext) +{ + if (KeyframesEqualIgnoringComputedOffsets(aKeyframes, mKeyframes)) { + return; + } + + mKeyframes = Move(aKeyframes); + // Apply distribute spacing irrespective of the spacing mode. We will apply + // the specified spacing mode when we generate computed animation property + // values from the keyframes since both operations require a style context + // and need to be performed whenever the style context changes. + KeyframeUtils::ApplyDistributeSpacing(mKeyframes); + + if (mAnimation && mAnimation->IsRelevant()) { + nsNodeUtils::AnimationChanged(mAnimation); + } + + if (aStyleContext) { + UpdateProperties(aStyleContext); + MaybeUpdateFrameForCompositor(); + } +} + +const AnimationProperty* +KeyframeEffectReadOnly::GetEffectiveAnimationOfProperty( + nsCSSPropertyID aProperty) const +{ + EffectSet* effectSet = + EffectSet::GetEffectSet(mTarget->mElement, mTarget->mPseudoType); + for (size_t propIdx = 0, propEnd = mProperties.Length(); + propIdx != propEnd; ++propIdx) { + if (aProperty == mProperties[propIdx].mProperty) { + const AnimationProperty* result = &mProperties[propIdx]; + // Skip if there is a property of animation level that is overridden + // by !important rules. + if (effectSet && + effectSet->PropertiesWithImportantRules() + .HasProperty(result->mProperty) && + effectSet->PropertiesForAnimationsLevel() + .HasProperty(result->mProperty)) { + result = nullptr; + } + return result; + } + } + return nullptr; +} + +bool +KeyframeEffectReadOnly::HasAnimationOfProperty(nsCSSPropertyID aProperty) const +{ + for (const AnimationProperty& property : mProperties) { + if (property.mProperty == aProperty) { + return true; + } + } + return false; +} + +#ifdef DEBUG +bool +SpecifiedKeyframeArraysAreEqual(const nsTArray<Keyframe>& aA, + const nsTArray<Keyframe>& aB) +{ + if (aA.Length() != aB.Length()) { + return false; + } + + for (size_t i = 0; i < aA.Length(); i++) { + const Keyframe& a = aA[i]; + const Keyframe& b = aB[i]; + if (a.mOffset != b.mOffset || + a.mTimingFunction != b.mTimingFunction || + a.mPropertyValues != b.mPropertyValues) { + return false; + } + } + + return true; +} +#endif + +void +KeyframeEffectReadOnly::UpdateProperties(nsStyleContext* aStyleContext) +{ + MOZ_ASSERT(aStyleContext); + + nsTArray<AnimationProperty> properties = BuildProperties(aStyleContext); + + if (mProperties == properties) { + return; + } + + // Preserve the state of the mIsRunningOnCompositor flag. + nsCSSPropertyIDSet runningOnCompositorProperties; + + for (const AnimationProperty& property : mProperties) { + if (property.mIsRunningOnCompositor) { + runningOnCompositorProperties.AddProperty(property.mProperty); + } + } + + mProperties = Move(properties); + + for (AnimationProperty& property : mProperties) { + property.mIsRunningOnCompositor = + runningOnCompositorProperties.HasProperty(property.mProperty); + } + + // FIXME (bug 1303235): Do this for Servo too + if (aStyleContext->PresContext()->StyleSet()->IsGecko()) { + CalculateCumulativeChangeHint(aStyleContext); + } + + MarkCascadeNeedsUpdate(); + + RequestRestyle(EffectCompositor::RestyleType::Layer); +} + +void +KeyframeEffectReadOnly::ComposeStyle( + RefPtr<AnimValuesStyleRule>& aStyleRule, + const nsCSSPropertyIDSet& aPropertiesToSkip) +{ + ComputedTiming computedTiming = GetComputedTiming(); + mProgressOnLastCompose = computedTiming.mProgress; + mCurrentIterationOnLastCompose = computedTiming.mCurrentIteration; + + // If the progress is null, we don't have fill data for the current + // time so we shouldn't animate. + if (computedTiming.mProgress.IsNull()) { + return; + } + + for (size_t propIdx = 0, propEnd = mProperties.Length(); + propIdx != propEnd; ++propIdx) + { + const AnimationProperty& prop = mProperties[propIdx]; + + MOZ_ASSERT(prop.mSegments[0].mFromKey == 0.0, "incorrect first from key"); + MOZ_ASSERT(prop.mSegments[prop.mSegments.Length() - 1].mToKey == 1.0, + "incorrect last to key"); + + if (aPropertiesToSkip.HasProperty(prop.mProperty)) { + continue; + } + + MOZ_ASSERT(prop.mSegments.Length() > 0, + "property should not be in animations if it has no segments"); + + // FIXME: Maybe cache the current segment? + const AnimationPropertySegment *segment = prop.mSegments.Elements(), + *segmentEnd = segment + prop.mSegments.Length(); + while (segment->mToKey <= computedTiming.mProgress.Value()) { + MOZ_ASSERT(segment->mFromKey <= segment->mToKey, "incorrect keys"); + if ((segment+1) == segmentEnd) { + break; + } + ++segment; + MOZ_ASSERT(segment->mFromKey == (segment-1)->mToKey, "incorrect keys"); + } + MOZ_ASSERT(segment->mFromKey <= segment->mToKey, "incorrect keys"); + MOZ_ASSERT(segment >= prop.mSegments.Elements() && + size_t(segment - prop.mSegments.Elements()) < + prop.mSegments.Length(), + "out of array bounds"); + + if (!aStyleRule) { + // Allocate the style rule now that we know we have animation data. + aStyleRule = new AnimValuesStyleRule(); + } + + StyleAnimationValue fromValue = segment->mFromValue; + StyleAnimationValue toValue = segment->mToValue; + // Iteration composition for accumulate + if (mEffectOptions.mIterationComposite == + IterationCompositeOperation::Accumulate && + computedTiming.mCurrentIteration > 0) { + const AnimationPropertySegment& lastSegment = + prop.mSegments.LastElement(); + // FIXME: Bug 1293492: Add a utility function to calculate both of + // below StyleAnimationValues. + DebugOnly<bool> accumulateResult = + StyleAnimationValue::Accumulate(prop.mProperty, + fromValue, + lastSegment.mToValue, + computedTiming.mCurrentIteration); + // We can't check the accumulation result in case of filter property. + // That's because some filter property can't accumulate, + // e.g. 'contrast(2) brightness(2)' onto 'brightness(1) contrast(1)' + // because of mismatch of the order. + MOZ_ASSERT(accumulateResult || prop.mProperty == eCSSProperty_filter, + "could not accumulate value"); + accumulateResult = + StyleAnimationValue::Accumulate(prop.mProperty, + toValue, + lastSegment.mToValue, + computedTiming.mCurrentIteration); + MOZ_ASSERT(accumulateResult || prop.mProperty == eCSSProperty_filter, + "could not accumulate value"); + } + + // Special handling for zero-length segments + if (segment->mToKey == segment->mFromKey) { + if (computedTiming.mProgress.Value() < 0) { + aStyleRule->AddValue(prop.mProperty, Move(fromValue)); + } else { + aStyleRule->AddValue(prop.mProperty, Move(toValue)); + } + continue; + } + + double positionInSegment = + (computedTiming.mProgress.Value() - segment->mFromKey) / + (segment->mToKey - segment->mFromKey); + double valuePosition = + ComputedTimingFunction::GetPortion(segment->mTimingFunction, + positionInSegment, + computedTiming.mBeforeFlag); + + MOZ_ASSERT(IsFinite(valuePosition), "Position value should be finite"); + StyleAnimationValue val; + if (StyleAnimationValue::Interpolate(prop.mProperty, + fromValue, + toValue, + valuePosition, val)) { + aStyleRule->AddValue(prop.mProperty, Move(val)); + } else if (valuePosition < 0.5) { + aStyleRule->AddValue(prop.mProperty, Move(fromValue)); + } else { + aStyleRule->AddValue(prop.mProperty, Move(toValue)); + } + } +} + +bool +KeyframeEffectReadOnly::IsRunningOnCompositor() const +{ + // We consider animation is running on compositor if there is at least + // one property running on compositor. + // Animation.IsRunningOnCompotitor will return more fine grained + // information in bug 1196114. + for (const AnimationProperty& property : mProperties) { + if (property.mIsRunningOnCompositor) { + return true; + } + } + return false; +} + +void +KeyframeEffectReadOnly::SetIsRunningOnCompositor(nsCSSPropertyID aProperty, + bool aIsRunning) +{ + MOZ_ASSERT(nsCSSProps::PropHasFlags(aProperty, + CSS_PROPERTY_CAN_ANIMATE_ON_COMPOSITOR), + "Property being animated on compositor is a recognized " + "compositor-animatable property"); + for (AnimationProperty& property : mProperties) { + if (property.mProperty == aProperty) { + property.mIsRunningOnCompositor = aIsRunning; + // We currently only set a performance warning message when animations + // cannot be run on the compositor, so if this animation is running + // on the compositor we don't need a message. + if (aIsRunning) { + property.mPerformanceWarning.reset(); + } + return; + } + } +} + +void +KeyframeEffectReadOnly::ResetIsRunningOnCompositor() +{ + for (AnimationProperty& property : mProperties) { + property.mIsRunningOnCompositor = false; + } +} + +static const KeyframeEffectOptions& +KeyframeEffectOptionsFromUnion( + const UnrestrictedDoubleOrKeyframeEffectOptions& aOptions) +{ + MOZ_ASSERT(aOptions.IsKeyframeEffectOptions()); + return aOptions.GetAsKeyframeEffectOptions(); +} + +static const KeyframeEffectOptions& +KeyframeEffectOptionsFromUnion( + const UnrestrictedDoubleOrKeyframeAnimationOptions& aOptions) +{ + MOZ_ASSERT(aOptions.IsKeyframeAnimationOptions()); + return aOptions.GetAsKeyframeAnimationOptions(); +} + +template <class OptionsType> +static KeyframeEffectParams +KeyframeEffectParamsFromUnion(const OptionsType& aOptions, + nsAString& aInvalidPacedProperty, + ErrorResult& aRv) +{ + KeyframeEffectParams result; + if (!aOptions.IsUnrestrictedDouble()) { + const KeyframeEffectOptions& options = + KeyframeEffectOptionsFromUnion(aOptions); + KeyframeEffectParams::ParseSpacing(options.mSpacing, + result.mSpacingMode, + result.mPacedProperty, + aInvalidPacedProperty, + aRv); + // Ignore iterationComposite if the Web Animations API is not enabled, + // then the default value 'Replace' will be used. + if (AnimationUtils::IsCoreAPIEnabledForCaller()) { + result.mIterationComposite = options.mIterationComposite; + } + } + return result; +} + +/* static */ Maybe<OwningAnimationTarget> +KeyframeEffectReadOnly::ConvertTarget( + const Nullable<ElementOrCSSPseudoElement>& aTarget) +{ + // Return value optimization. + Maybe<OwningAnimationTarget> result; + + if (aTarget.IsNull()) { + return result; + } + + const ElementOrCSSPseudoElement& target = aTarget.Value(); + MOZ_ASSERT(target.IsElement() || target.IsCSSPseudoElement(), + "Uninitialized target"); + + if (target.IsElement()) { + result.emplace(&target.GetAsElement()); + } else { + RefPtr<Element> elem = target.GetAsCSSPseudoElement().ParentElement(); + result.emplace(elem, target.GetAsCSSPseudoElement().GetType()); + } + return result; +} + +template <class KeyframeEffectType, class OptionsType> +/* static */ already_AddRefed<KeyframeEffectType> +KeyframeEffectReadOnly::ConstructKeyframeEffect( + const GlobalObject& aGlobal, + const Nullable<ElementOrCSSPseudoElement>& aTarget, + JS::Handle<JSObject*> aKeyframes, + const OptionsType& aOptions, + ErrorResult& aRv) +{ + nsIDocument* doc = AnimationUtils::GetCurrentRealmDocument(aGlobal.Context()); + if (!doc) { + aRv.Throw(NS_ERROR_FAILURE); + return nullptr; + } + + TimingParams timingParams = + TimingParams::FromOptionsUnion(aOptions, doc, aRv); + if (aRv.Failed()) { + return nullptr; + } + + nsAutoString invalidPacedProperty; + KeyframeEffectParams effectOptions = + KeyframeEffectParamsFromUnion(aOptions, invalidPacedProperty, aRv); + if (aRv.Failed()) { + return nullptr; + } + + if (!invalidPacedProperty.IsEmpty()) { + const char16_t* params[] = { invalidPacedProperty.get() }; + nsContentUtils::ReportToConsole(nsIScriptError::warningFlag, + NS_LITERAL_CSTRING("Animation"), + doc, + nsContentUtils::eDOM_PROPERTIES, + "UnanimatablePacedProperty", + params, ArrayLength(params)); + } + + Maybe<OwningAnimationTarget> target = ConvertTarget(aTarget); + RefPtr<KeyframeEffectType> effect = + new KeyframeEffectType(doc, target, timingParams, effectOptions); + + effect->SetKeyframes(aGlobal.Context(), aKeyframes, aRv); + if (aRv.Failed()) { + return nullptr; + } + + return effect.forget(); +} + +template<class KeyframeEffectType> +/* static */ already_AddRefed<KeyframeEffectType> +KeyframeEffectReadOnly::ConstructKeyframeEffect(const GlobalObject& aGlobal, + KeyframeEffectReadOnly& aSource, + ErrorResult& aRv) +{ + nsIDocument* doc = AnimationUtils::GetCurrentRealmDocument(aGlobal.Context()); + if (!doc) { + aRv.Throw(NS_ERROR_FAILURE); + return nullptr; + } + + // Create a new KeyframeEffectReadOnly object with aSource's target, + // iteration composite operation, composite operation, and spacing mode. + // The constructor creates a new AnimationEffect(ReadOnly) object by + // aSource's TimingParams. + // Note: we don't need to re-throw exceptions since the value specified on + // aSource's timing object can be assumed valid. + RefPtr<KeyframeEffectType> effect = + new KeyframeEffectType(doc, + aSource.mTarget, + aSource.SpecifiedTiming(), + aSource.mEffectOptions); + // Copy cumulative change hint. mCumulativeChangeHint should be the same as + // the source one because both of targets are the same. + effect->mCumulativeChangeHint = aSource.mCumulativeChangeHint; + + // Copy aSource's keyframes and animation properties. + // Note: We don't call SetKeyframes directly, which might revise the + // computed offsets and rebuild the animation properties. + // FIXME: Bug 1314537: We have to make sure SharedKeyframeList is handled + // properly. + effect->mKeyframes = aSource.mKeyframes; + effect->mProperties = aSource.mProperties; + return effect.forget(); +} + +nsTArray<AnimationProperty> +KeyframeEffectReadOnly::BuildProperties(nsStyleContext* aStyleContext) +{ + MOZ_ASSERT(aStyleContext); + + nsTArray<AnimationProperty> result; + // If mTarget is null, return an empty property array. + if (!mTarget) { + return result; + } + + // When GetComputedKeyframeValues or GetAnimationPropertiesFromKeyframes + // calculate computed values from |mKeyframes|, they could possibly + // trigger a subsequent restyle in which we rebuild animations. If that + // happens we could find that |mKeyframes| is overwritten while it is + // being iterated over. Normally that shouldn't happen but just in case we + // make a copy of |mKeyframes| first and iterate over that instead. + auto keyframesCopy(mKeyframes); + + nsTArray<ComputedKeyframeValues> computedValues = + KeyframeUtils::GetComputedKeyframeValues(keyframesCopy, + mTarget->mElement, + aStyleContext); + + if (mEffectOptions.mSpacingMode == SpacingMode::paced) { + KeyframeUtils::ApplySpacing(keyframesCopy, SpacingMode::paced, + mEffectOptions.mPacedProperty, + computedValues, aStyleContext); + } + + result = KeyframeUtils::GetAnimationPropertiesFromKeyframes(keyframesCopy, + computedValues, + aStyleContext); + +#ifdef DEBUG + MOZ_ASSERT(SpecifiedKeyframeArraysAreEqual(mKeyframes, keyframesCopy), + "Apart from the computed offset members, the keyframes array" + " should not be modified"); +#endif + + mKeyframes.SwapElements(keyframesCopy); + return result; +} + +void +KeyframeEffectReadOnly::UpdateTargetRegistration() +{ + if (!mTarget) { + return; + } + + bool isRelevant = mAnimation && mAnimation->IsRelevant(); + + // Animation::IsRelevant() returns a cached value. It only updates when + // something calls Animation::UpdateRelevance. Whenever our timing changes, + // we should be notifying our Animation before calling this, so + // Animation::IsRelevant() should be up-to-date by the time we get here. + MOZ_ASSERT(isRelevant == IsCurrent() || IsInEffect(), + "Out of date Animation::IsRelevant value"); + + if (isRelevant) { + EffectSet* effectSet = + EffectSet::GetOrCreateEffectSet(mTarget->mElement, mTarget->mPseudoType); + effectSet->AddEffect(*this); + } else { + UnregisterTarget(); + } +} + +void +KeyframeEffectReadOnly::UnregisterTarget() +{ + EffectSet* effectSet = + EffectSet::GetEffectSet(mTarget->mElement, mTarget->mPseudoType); + if (effectSet) { + effectSet->RemoveEffect(*this); + if (effectSet->IsEmpty()) { + EffectSet::DestroyEffectSet(mTarget->mElement, mTarget->mPseudoType); + } + } +} + +void +KeyframeEffectReadOnly::RequestRestyle( + EffectCompositor::RestyleType aRestyleType) +{ + nsPresContext* presContext = GetPresContext(); + if (presContext && mTarget && mAnimation) { + presContext->EffectCompositor()-> + RequestRestyle(mTarget->mElement, mTarget->mPseudoType, + aRestyleType, mAnimation->CascadeLevel()); + } +} + +already_AddRefed<nsStyleContext> +KeyframeEffectReadOnly::GetTargetStyleContext() +{ + nsIPresShell* shell = GetPresShell(); + if (!shell) { + return nullptr; + } + + MOZ_ASSERT(mTarget, + "Should only have a presshell when we have a target element"); + + nsIAtom* pseudo = mTarget->mPseudoType < CSSPseudoElementType::Count + ? nsCSSPseudoElements::GetPseudoAtom(mTarget->mPseudoType) + : nullptr; + return nsComputedDOMStyle::GetStyleContextForElement(mTarget->mElement, + pseudo, shell); +} + +#ifdef DEBUG +void +DumpAnimationProperties(nsTArray<AnimationProperty>& aAnimationProperties) +{ + for (auto& p : aAnimationProperties) { + printf("%s\n", nsCSSProps::GetStringValue(p.mProperty).get()); + for (auto& s : p.mSegments) { + nsString fromValue, toValue; + Unused << StyleAnimationValue::UncomputeValue(p.mProperty, + s.mFromValue, + fromValue); + Unused << StyleAnimationValue::UncomputeValue(p.mProperty, + s.mToValue, + toValue); + printf(" %f..%f: %s..%s\n", s.mFromKey, s.mToKey, + NS_ConvertUTF16toUTF8(fromValue).get(), + NS_ConvertUTF16toUTF8(toValue).get()); + } + } +} +#endif + +/* static */ already_AddRefed<KeyframeEffectReadOnly> +KeyframeEffectReadOnly::Constructor( + const GlobalObject& aGlobal, + const Nullable<ElementOrCSSPseudoElement>& aTarget, + JS::Handle<JSObject*> aKeyframes, + const UnrestrictedDoubleOrKeyframeEffectOptions& aOptions, + ErrorResult& aRv) +{ + return ConstructKeyframeEffect<KeyframeEffectReadOnly>(aGlobal, aTarget, + aKeyframes, aOptions, + aRv); +} + +/* static */ already_AddRefed<KeyframeEffectReadOnly> +KeyframeEffectReadOnly::Constructor(const GlobalObject& aGlobal, + KeyframeEffectReadOnly& aSource, + ErrorResult& aRv) +{ + return ConstructKeyframeEffect<KeyframeEffectReadOnly>(aGlobal, aSource, aRv); +} + +void +KeyframeEffectReadOnly::GetTarget( + Nullable<OwningElementOrCSSPseudoElement>& aRv) const +{ + if (!mTarget) { + aRv.SetNull(); + return; + } + + switch (mTarget->mPseudoType) { + case CSSPseudoElementType::before: + case CSSPseudoElementType::after: + aRv.SetValue().SetAsCSSPseudoElement() = + CSSPseudoElement::GetCSSPseudoElement(mTarget->mElement, + mTarget->mPseudoType); + break; + + case CSSPseudoElementType::NotPseudo: + aRv.SetValue().SetAsElement() = mTarget->mElement; + break; + + default: + NS_NOTREACHED("Animation of unsupported pseudo-type"); + aRv.SetNull(); + } +} + +static void +CreatePropertyValue(nsCSSPropertyID aProperty, + float aOffset, + const Maybe<ComputedTimingFunction>& aTimingFunction, + const StyleAnimationValue& aValue, + AnimationPropertyValueDetails& aResult) +{ + aResult.mOffset = aOffset; + + nsString stringValue; + DebugOnly<bool> uncomputeResult = + StyleAnimationValue::UncomputeValue(aProperty, aValue, stringValue); + MOZ_ASSERT(uncomputeResult, "failed to uncompute value"); + aResult.mValue = stringValue; + + if (aTimingFunction) { + aResult.mEasing.Construct(); + aTimingFunction->AppendToString(aResult.mEasing.Value()); + } else { + aResult.mEasing.Construct(NS_LITERAL_STRING("linear")); + } + + aResult.mComposite = CompositeOperation::Replace; +} + +void +KeyframeEffectReadOnly::GetProperties( + nsTArray<AnimationPropertyDetails>& aProperties, + ErrorResult& aRv) const +{ + for (const AnimationProperty& property : mProperties) { + AnimationPropertyDetails propertyDetails; + propertyDetails.mProperty = + NS_ConvertASCIItoUTF16(nsCSSProps::GetStringValue(property.mProperty)); + propertyDetails.mRunningOnCompositor = property.mIsRunningOnCompositor; + + nsXPIDLString localizedString; + if (property.mPerformanceWarning && + property.mPerformanceWarning->ToLocalizedString(localizedString)) { + propertyDetails.mWarning.Construct(localizedString); + } + + if (!propertyDetails.mValues.SetCapacity(property.mSegments.Length(), + mozilla::fallible)) { + aRv.Throw(NS_ERROR_OUT_OF_MEMORY); + return; + } + + for (size_t segmentIdx = 0, segmentLen = property.mSegments.Length(); + segmentIdx < segmentLen; + segmentIdx++) + { + const AnimationPropertySegment& segment = property.mSegments[segmentIdx]; + + binding_detail::FastAnimationPropertyValueDetails fromValue; + CreatePropertyValue(property.mProperty, segment.mFromKey, + segment.mTimingFunction, segment.mFromValue, + fromValue); + // We don't apply timing functions for zero-length segments, so + // don't return one here. + if (segment.mFromKey == segment.mToKey) { + fromValue.mEasing.Reset(); + } + // The following won't fail since we have already allocated the capacity + // above. + propertyDetails.mValues.AppendElement(fromValue, mozilla::fallible); + + // Normally we can ignore the to-value for this segment since it is + // identical to the from-value from the next segment. However, we need + // to add it if either: + // a) this is the last segment, or + // b) the next segment's from-value differs. + if (segmentIdx == segmentLen - 1 || + property.mSegments[segmentIdx + 1].mFromValue != segment.mToValue) { + binding_detail::FastAnimationPropertyValueDetails toValue; + CreatePropertyValue(property.mProperty, segment.mToKey, + Nothing(), segment.mToValue, toValue); + // It doesn't really make sense to have a timing function on the + // last property value or before a sudden jump so we just drop the + // easing property altogether. + toValue.mEasing.Reset(); + propertyDetails.mValues.AppendElement(toValue, mozilla::fallible); + } + } + + aProperties.AppendElement(propertyDetails); + } +} + +void +KeyframeEffectReadOnly::GetKeyframes(JSContext*& aCx, + nsTArray<JSObject*>& aResult, + ErrorResult& aRv) +{ + MOZ_ASSERT(aResult.IsEmpty()); + MOZ_ASSERT(!aRv.Failed()); + + if (!aResult.SetCapacity(mKeyframes.Length(), mozilla::fallible)) { + aRv.Throw(NS_ERROR_OUT_OF_MEMORY); + return; + } + + for (const Keyframe& keyframe : mKeyframes) { + // Set up a dictionary object for the explicit members + BaseComputedKeyframe keyframeDict; + if (keyframe.mOffset) { + keyframeDict.mOffset.SetValue(keyframe.mOffset.value()); + } + MOZ_ASSERT(keyframe.mComputedOffset != Keyframe::kComputedOffsetNotSet, + "Invalid computed offset"); + keyframeDict.mComputedOffset.Construct(keyframe.mComputedOffset); + if (keyframe.mTimingFunction) { + keyframeDict.mEasing.Truncate(); + keyframe.mTimingFunction.ref().AppendToString(keyframeDict.mEasing); + } // else if null, leave easing as its default "linear". + + JS::Rooted<JS::Value> keyframeJSValue(aCx); + if (!ToJSValue(aCx, keyframeDict, &keyframeJSValue)) { + aRv.Throw(NS_ERROR_FAILURE); + return; + } + + JS::Rooted<JSObject*> keyframeObject(aCx, &keyframeJSValue.toObject()); + for (const PropertyValuePair& propertyValue : keyframe.mPropertyValues) { + + const char* name = nsCSSProps::PropertyIDLName(propertyValue.mProperty); + + // nsCSSValue::AppendToString does not accept shorthands properties but + // works with token stream values if we pass eCSSProperty_UNKNOWN as + // the property. + nsCSSPropertyID propertyForSerializing = + nsCSSProps::IsShorthand(propertyValue.mProperty) + ? eCSSProperty_UNKNOWN + : propertyValue.mProperty; + + nsAutoString stringValue; + if (propertyValue.mServoDeclarationBlock) { + Servo_DeclarationBlock_SerializeOneValue( + propertyValue.mServoDeclarationBlock, &stringValue); + } else { + propertyValue.mValue.AppendToString( + propertyForSerializing, stringValue, nsCSSValue::eNormalized); + } + + JS::Rooted<JS::Value> value(aCx); + if (!ToJSValue(aCx, stringValue, &value) || + !JS_DefineProperty(aCx, keyframeObject, name, value, + JSPROP_ENUMERATE)) { + aRv.Throw(NS_ERROR_FAILURE); + return; + } + } + + aResult.AppendElement(keyframeObject); + } +} + +/* static */ const TimeDuration +KeyframeEffectReadOnly::OverflowRegionRefreshInterval() +{ + // The amount of time we can wait between updating throttled animations + // on the main thread that influence the overflow region. + static const TimeDuration kOverflowRegionRefreshInterval = + TimeDuration::FromMilliseconds(200); + + return kOverflowRegionRefreshInterval; +} + +bool +KeyframeEffectReadOnly::CanThrottle() const +{ + // Unthrottle if we are not in effect or current. This will be the case when + // our owning animation has finished, is idle, or when we are in the delay + // phase (but without a backwards fill). In each case the computed progress + // value produced on each tick will be the same so we will skip requesting + // unnecessary restyles in NotifyAnimationTimingUpdated. Any calls we *do* get + // here will be because of a change in state (e.g. we are newly finished or + // newly no longer in effect) in which case we shouldn't throttle the sample. + if (!IsInEffect() || !IsCurrent()) { + return false; + } + + nsIFrame* frame = GetAnimationFrame(); + if (!frame) { + // There are two possible cases here. + // a) No target element + // b) The target element has no frame, e.g. because it is in a display:none + // subtree. + // In either case we can throttle the animation because there is no + // need to update on the main thread. + return true; + } + + // We can throttle the animation if the animation is paint only and + // the target frame is out of view or the document is in background tabs. + if (CanIgnoreIfNotVisible()) { + nsIPresShell* presShell = GetPresShell(); + if ((presShell && !presShell->IsActive()) || + frame->IsScrolledOutOfView()) { + return true; + } + } + + // First we need to check layer generation and transform overflow + // prior to the property.mIsRunningOnCompositor check because we should + // occasionally unthrottle these animations even if the animations are + // already running on compositor. + for (const LayerAnimationInfo::Record& record : + LayerAnimationInfo::sRecords) { + // Skip properties that are overridden by !important rules. + // (GetEffectiveAnimationOfProperty, as called by + // HasEffectiveAnimationOfProperty, only returns a property which is + // neither overridden by !important rules nor overridden by other + // animation.) + if (!HasEffectiveAnimationOfProperty(record.mProperty)) { + continue; + } + + EffectSet* effectSet = EffectSet::GetEffectSet(mTarget->mElement, + mTarget->mPseudoType); + MOZ_ASSERT(effectSet, "CanThrottle should be called on an effect " + "associated with a target element"); + layers::Layer* layer = + FrameLayerBuilder::GetDedicatedLayer(frame, record.mLayerType); + // Unthrottle if the layer needs to be brought up to date + if (!layer || + effectSet->GetAnimationGeneration() != + layer->GetAnimationGeneration()) { + return false; + } + + // If this is a transform animation that affects the overflow region, + // we should unthrottle the animation periodically. + if (record.mProperty == eCSSProperty_transform && + !CanThrottleTransformChanges(*frame)) { + return false; + } + } + + for (const AnimationProperty& property : mProperties) { + if (!property.mIsRunningOnCompositor) { + return false; + } + } + + return true; +} + +bool +KeyframeEffectReadOnly::CanThrottleTransformChanges(nsIFrame& aFrame) const +{ + // If we know that the animation cannot cause overflow, + // we can just disable flushes for this animation. + + // If we don't show scrollbars, we don't care about overflow. + if (LookAndFeel::GetInt(LookAndFeel::eIntID_ShowHideScrollbars) == 0) { + return true; + } + + nsPresContext* presContext = GetPresContext(); + // CanThrottleTransformChanges is only called as part of a refresh driver tick + // in which case we expect to has a pres context. + MOZ_ASSERT(presContext); + + TimeStamp now = + presContext->RefreshDriver()->MostRecentRefresh(); + + EffectSet* effectSet = EffectSet::GetEffectSet(mTarget->mElement, + mTarget->mPseudoType); + MOZ_ASSERT(effectSet, "CanThrottleTransformChanges is expected to be called" + " on an effect in an effect set"); + MOZ_ASSERT(mAnimation, "CanThrottleTransformChanges is expected to be called" + " on an effect with a parent animation"); + TimeStamp animationRuleRefreshTime = + effectSet->AnimationRuleRefreshTime(mAnimation->CascadeLevel()); + // If this animation can cause overflow, we can throttle some of the ticks. + if (!animationRuleRefreshTime.IsNull() && + (now - animationRuleRefreshTime) < OverflowRegionRefreshInterval()) { + return true; + } + + // If the nearest scrollable ancestor has overflow:hidden, + // we don't care about overflow. + nsIScrollableFrame* scrollable = + nsLayoutUtils::GetNearestScrollableFrame(&aFrame); + if (!scrollable) { + return true; + } + + ScrollbarStyles ss = scrollable->GetScrollbarStyles(); + if (ss.mVertical == NS_STYLE_OVERFLOW_HIDDEN && + ss.mHorizontal == NS_STYLE_OVERFLOW_HIDDEN && + scrollable->GetLogicalScrollPosition() == nsPoint(0, 0)) { + return true; + } + + return false; +} + +nsIFrame* +KeyframeEffectReadOnly::GetAnimationFrame() const +{ + if (!mTarget) { + return nullptr; + } + + nsIFrame* frame = mTarget->mElement->GetPrimaryFrame(); + if (!frame) { + return nullptr; + } + + if (mTarget->mPseudoType == CSSPseudoElementType::before) { + frame = nsLayoutUtils::GetBeforeFrame(frame); + } else if (mTarget->mPseudoType == CSSPseudoElementType::after) { + frame = nsLayoutUtils::GetAfterFrame(frame); + } else { + MOZ_ASSERT(mTarget->mPseudoType == CSSPseudoElementType::NotPseudo, + "unknown mTarget->mPseudoType"); + } + if (!frame) { + return nullptr; + } + + return nsLayoutUtils::GetStyleFrame(frame); +} + +nsIDocument* +KeyframeEffectReadOnly::GetRenderedDocument() const +{ + if (!mTarget) { + return nullptr; + } + return mTarget->mElement->GetComposedDoc(); +} + +nsIPresShell* +KeyframeEffectReadOnly::GetPresShell() const +{ + nsIDocument* doc = GetRenderedDocument(); + if (!doc) { + return nullptr; + } + return doc->GetShell(); +} + +nsPresContext* +KeyframeEffectReadOnly::GetPresContext() const +{ + nsIPresShell* shell = GetPresShell(); + if (!shell) { + return nullptr; + } + return shell->GetPresContext(); +} + +/* static */ bool +KeyframeEffectReadOnly::IsGeometricProperty( + const nsCSSPropertyID aProperty) +{ + switch (aProperty) { + case eCSSProperty_bottom: + case eCSSProperty_height: + case eCSSProperty_left: + case eCSSProperty_right: + case eCSSProperty_top: + case eCSSProperty_width: + return true; + default: + return false; + } +} + +/* static */ bool +KeyframeEffectReadOnly::CanAnimateTransformOnCompositor( + const nsIFrame* aFrame, + AnimationPerformanceWarning::Type& aPerformanceWarning) +{ + // Disallow OMTA for preserve-3d transform. Note that we check the style property + // rather than Extend3DContext() since that can recurse back into this function + // via HasOpacity(). See bug 779598. + if (aFrame->Combines3DTransformWithAncestors() || + aFrame->StyleDisplay()->mTransformStyle == NS_STYLE_TRANSFORM_STYLE_PRESERVE_3D) { + aPerformanceWarning = AnimationPerformanceWarning::Type::TransformPreserve3D; + return false; + } + // Note that testing BackfaceIsHidden() is not a sufficient test for + // what we need for animating backface-visibility correctly if we + // remove the above test for Extend3DContext(); that would require + // looking at backface-visibility on descendants as well. See bug 1186204. + if (aFrame->BackfaceIsHidden()) { + aPerformanceWarning = + AnimationPerformanceWarning::Type::TransformBackfaceVisibilityHidden; + return false; + } + // Async 'transform' animations of aFrames with SVG transforms is not + // supported. See bug 779599. + if (aFrame->IsSVGTransformed()) { + aPerformanceWarning = AnimationPerformanceWarning::Type::TransformSVG; + return false; + } + + return true; +} + +bool +KeyframeEffectReadOnly::ShouldBlockAsyncTransformAnimations( + const nsIFrame* aFrame, + AnimationPerformanceWarning::Type& aPerformanceWarning) const +{ + // We currently only expect this method to be called for effects whose + // animations are eligible for the compositor since, Animations that are + // paused, zero-duration, finished etc. should not block other animations from + // running on the compositor. + MOZ_ASSERT(mAnimation && mAnimation->IsPlayableOnCompositor()); + + EffectSet* effectSet = + EffectSet::GetEffectSet(mTarget->mElement, mTarget->mPseudoType); + for (const AnimationProperty& property : mProperties) { + // If there is a property for animations level that is overridden by + // !important rules, it should not block other animations from running + // on the compositor. + // NOTE: We don't currently check for !important rules for properties that + // don't run on the compositor. As result such properties (e.g. margin-left) + // can still block async animations even if they are overridden by + // !important rules. + if (effectSet && + effectSet->PropertiesWithImportantRules() + .HasProperty(property.mProperty) && + effectSet->PropertiesForAnimationsLevel() + .HasProperty(property.mProperty)) { + continue; + } + // Check for geometric properties + if (IsGeometricProperty(property.mProperty)) { + aPerformanceWarning = + AnimationPerformanceWarning::Type::TransformWithGeometricProperties; + return true; + } + + // Check for unsupported transform animations + if (property.mProperty == eCSSProperty_transform) { + if (!CanAnimateTransformOnCompositor(aFrame, + aPerformanceWarning)) { + return true; + } + } + } + + return false; +} + +void +KeyframeEffectReadOnly::SetPerformanceWarning( + nsCSSPropertyID aProperty, + const AnimationPerformanceWarning& aWarning) +{ + for (AnimationProperty& property : mProperties) { + if (property.mProperty == aProperty && + (!property.mPerformanceWarning || + *property.mPerformanceWarning != aWarning)) { + property.mPerformanceWarning = Some(aWarning); + + nsXPIDLString localizedString; + if (nsLayoutUtils::IsAnimationLoggingEnabled() && + property.mPerformanceWarning->ToLocalizedString(localizedString)) { + nsAutoCString logMessage = NS_ConvertUTF16toUTF8(localizedString); + AnimationUtils::LogAsyncAnimationFailure(logMessage, mTarget->mElement); + } + return; + } + } +} + +static already_AddRefed<nsStyleContext> +CreateStyleContextForAnimationValue(nsCSSPropertyID aProperty, + const StyleAnimationValue& aValue, + nsStyleContext* aBaseStyleContext) +{ + MOZ_ASSERT(aBaseStyleContext, + "CreateStyleContextForAnimationValue needs to be called " + "with a valid nsStyleContext"); + + RefPtr<AnimValuesStyleRule> styleRule = new AnimValuesStyleRule(); + styleRule->AddValue(aProperty, aValue); + + nsCOMArray<nsIStyleRule> rules; + rules.AppendObject(styleRule); + + MOZ_ASSERT(aBaseStyleContext->PresContext()->StyleSet()->IsGecko(), + "ServoStyleSet should not use StyleAnimationValue for animations"); + nsStyleSet* styleSet = + aBaseStyleContext->PresContext()->StyleSet()->AsGecko(); + + RefPtr<nsStyleContext> styleContext = + styleSet->ResolveStyleByAddingRules(aBaseStyleContext, rules); + + // We need to call StyleData to generate cached data for the style context. + // Otherwise CalcStyleDifference returns no meaningful result. + styleContext->StyleData(nsCSSProps::kSIDTable[aProperty]); + + return styleContext.forget(); +} + +void +KeyframeEffectReadOnly::CalculateCumulativeChangeHint( + nsStyleContext *aStyleContext) +{ + mCumulativeChangeHint = nsChangeHint(0); + + for (const AnimationProperty& property : mProperties) { + for (const AnimationPropertySegment& segment : property.mSegments) { + RefPtr<nsStyleContext> fromContext = + CreateStyleContextForAnimationValue(property.mProperty, + segment.mFromValue, aStyleContext); + + RefPtr<nsStyleContext> toContext = + CreateStyleContextForAnimationValue(property.mProperty, + segment.mToValue, aStyleContext); + + uint32_t equalStructs = 0; + uint32_t samePointerStructs = 0; + nsChangeHint changeHint = + fromContext->CalcStyleDifference(toContext, + nsChangeHint(0), + &equalStructs, + &samePointerStructs); + + mCumulativeChangeHint |= changeHint; + } + } +} + +void +KeyframeEffectReadOnly::SetAnimation(Animation* aAnimation) +{ + if (mAnimation == aAnimation) { + return; + } + + // Restyle for the old animation. + RequestRestyle(EffectCompositor::RestyleType::Layer); + + mAnimation = aAnimation; + + // The order of these function calls is important: + // NotifyAnimationTimingUpdated() need the updated mIsRelevant flag to check + // if it should create the effectSet or not, and MarkCascadeNeedsUpdate() + // needs a valid effectSet, so we should call them in this order. + if (mAnimation) { + mAnimation->UpdateRelevance(); + } + NotifyAnimationTimingUpdated(); + if (mAnimation) { + MarkCascadeNeedsUpdate(); + } +} + +bool +KeyframeEffectReadOnly::CanIgnoreIfNotVisible() const +{ + if (!AnimationUtils::IsOffscreenThrottlingEnabled()) { + return false; + } + + // FIXME (bug 1303235): We don't calculate mCumulativeChangeHint for + // the Servo backend yet + nsPresContext* presContext = GetPresContext(); + if (!presContext || presContext->StyleSet()->IsServo()) { + return false; + } + + // FIXME: For further sophisticated optimization we need to check + // change hint on the segment corresponding to computedTiming.progress. + return NS_IsHintSubset( + mCumulativeChangeHint, nsChangeHint_Hints_CanIgnoreIfNotVisible); +} + +void +KeyframeEffectReadOnly::MaybeUpdateFrameForCompositor() +{ + nsIFrame* frame = GetAnimationFrame(); + if (!frame) { + return; + } + + // FIXME: Bug 1272495: If this effect does not win in the cascade, the + // NS_FRAME_MAY_BE_TRANSFORMED flag should be removed when the animation + // will be removed from effect set or the transform keyframes are removed + // by setKeyframes. The latter case will be hard to solve though. + for (const AnimationProperty& property : mProperties) { + if (property.mProperty == eCSSProperty_transform) { + frame->AddStateBits(NS_FRAME_MAY_BE_TRANSFORMED); + return; + } + } +} + +void +KeyframeEffectReadOnly::MarkCascadeNeedsUpdate() +{ + if (!mTarget) { + return; + } + + EffectSet* effectSet = EffectSet::GetEffectSet(mTarget->mElement, + mTarget->mPseudoType); + if (!effectSet) { + return; + } + effectSet->MarkCascadeNeedsUpdate(); +} + +bool +KeyframeEffectReadOnly::HasComputedTimingChanged() const +{ + // Typically we don't need to request a restyle if the progress hasn't + // changed since the last call to ComposeStyle. The one exception is if the + // iteration composite mode is 'accumulate' and the current iteration has + // changed, since that will often produce a different result. + ComputedTiming computedTiming = GetComputedTiming(); + return computedTiming.mProgress != mProgressOnLastCompose || + (mEffectOptions.mIterationComposite == + IterationCompositeOperation::Accumulate && + computedTiming.mCurrentIteration != + mCurrentIterationOnLastCompose); +} + +} // namespace dom +} // namespace mozilla diff --git a/dom/animation/KeyframeEffectReadOnly.h b/dom/animation/KeyframeEffectReadOnly.h new file mode 100644 index 000000000..889159b38 --- /dev/null +++ b/dom/animation/KeyframeEffectReadOnly.h @@ -0,0 +1,439 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* 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/. */ + +#ifndef mozilla_dom_KeyframeEffectReadOnly_h +#define mozilla_dom_KeyframeEffectReadOnly_h + +#include "nsChangeHint.h" +#include "nsCSSPropertyID.h" +#include "nsCSSValue.h" +#include "nsCycleCollectionParticipant.h" +#include "nsTArray.h" +#include "nsWrapperCache.h" +#include "mozilla/AnimationPerformanceWarning.h" +#include "mozilla/AnimationTarget.h" +#include "mozilla/Attributes.h" +#include "mozilla/ComputedTimingFunction.h" +#include "mozilla/EffectCompositor.h" +#include "mozilla/KeyframeEffectParams.h" +#include "mozilla/LayerAnimationInfo.h" // LayerAnimations::kRecords +#include "mozilla/ServoBindingTypes.h" // RawServoDeclarationBlock and + // associated RefPtrTraits +#include "mozilla/StyleAnimationValue.h" +#include "mozilla/dom/AnimationEffectReadOnly.h" +#include "mozilla/dom/Element.h" + +struct JSContext; +class JSObject; +class nsCSSPropertyIDSet; +class nsIContent; +class nsIDocument; +class nsIFrame; +class nsIPresShell; +class nsPresContext; + +namespace mozilla { + +class AnimValuesStyleRule; +enum class CSSPseudoElementType : uint8_t; +class ErrorResult; +struct TimingParams; + +namespace dom { +class ElementOrCSSPseudoElement; +class GlobalObject; +class OwningElementOrCSSPseudoElement; +class UnrestrictedDoubleOrKeyframeAnimationOptions; +class UnrestrictedDoubleOrKeyframeEffectOptions; +enum class IterationCompositeOperation : uint32_t; +enum class CompositeOperation : uint32_t; +struct AnimationPropertyDetails; +} + +/** + * A property-value pair specified on a keyframe. + */ +struct PropertyValuePair +{ + nsCSSPropertyID mProperty; + // The specified value for the property. For shorthand properties or invalid + // property values, we store the specified property value as a token stream + // (string). + nsCSSValue mValue; + + // The specified value when using the Servo backend. However, even when + // using the Servo backend, we still fill in |mValue| in the case where we + // fail to parse the value since we use it to store the original string. + RefPtr<RawServoDeclarationBlock> mServoDeclarationBlock; + + bool operator==(const PropertyValuePair&) const; +}; + +/** + * A single keyframe. + * + * This is the canonical form in which keyframe effects are stored and + * corresponds closely to the type of objects returned via the getKeyframes() + * API. + * + * Before computing an output animation value, however, we flatten these frames + * down to a series of per-property value arrays where we also resolve any + * overlapping shorthands/longhands, convert specified CSS values to computed + * values, etc. + * + * When the target element or style context changes, however, we rebuild these + * per-property arrays from the original list of keyframes objects. As a result, + * these objects represent the master definition of the effect's values. + */ +struct Keyframe +{ + Keyframe() = default; + Keyframe(const Keyframe& aOther) = default; + Keyframe(Keyframe&& aOther) + { + *this = Move(aOther); + } + + Keyframe& operator=(const Keyframe& aOther) = default; + Keyframe& operator=(Keyframe&& aOther) + { + mOffset = aOther.mOffset; + mComputedOffset = aOther.mComputedOffset; + mTimingFunction = Move(aOther.mTimingFunction); + mPropertyValues = Move(aOther.mPropertyValues); + return *this; + } + + Maybe<double> mOffset; + static constexpr double kComputedOffsetNotSet = -1.0; + double mComputedOffset = kComputedOffsetNotSet; + Maybe<ComputedTimingFunction> mTimingFunction; // Nothing() here means + // "linear" + nsTArray<PropertyValuePair> mPropertyValues; +}; + +struct AnimationPropertySegment +{ + float mFromKey, mToKey; + StyleAnimationValue mFromValue, mToValue; + Maybe<ComputedTimingFunction> mTimingFunction; + + bool operator==(const AnimationPropertySegment& aOther) const + { + return mFromKey == aOther.mFromKey && + mToKey == aOther.mToKey && + mFromValue == aOther.mFromValue && + mToValue == aOther.mToValue && + mTimingFunction == aOther.mTimingFunction; + } + bool operator!=(const AnimationPropertySegment& aOther) const + { + return !(*this == aOther); + } +}; + +struct AnimationProperty +{ + nsCSSPropertyID mProperty = eCSSProperty_UNKNOWN; + + // If true, the propery is currently being animated on the compositor. + // + // Note that when the owning Animation requests a non-throttled restyle, in + // between calling RequestRestyle on its EffectCompositor and when the + // restyle is performed, this member may temporarily become false even if + // the animation remains on the layer after the restyle. + // + // **NOTE**: This member is not included when comparing AnimationProperty + // objects for equality. + bool mIsRunningOnCompositor = false; + + Maybe<AnimationPerformanceWarning> mPerformanceWarning; + + InfallibleTArray<AnimationPropertySegment> mSegments; + + // The copy constructor/assignment doesn't copy mIsRunningOnCompositor and + // mPerformanceWarning. + AnimationProperty() = default; + AnimationProperty(const AnimationProperty& aOther) + : mProperty(aOther.mProperty), mSegments(aOther.mSegments) { } + AnimationProperty& operator=(const AnimationProperty& aOther) + { + mProperty = aOther.mProperty; + mSegments = aOther.mSegments; + return *this; + } + + // NOTE: This operator does *not* compare the mIsRunningOnCompositor member. + // This is because AnimationProperty objects are compared when recreating + // CSS animations to determine if mutation observer change records need to + // be created or not. However, at the point when these objects are compared + // the mIsRunningOnCompositor will not have been set on the new objects so + // we ignore this member to avoid generating spurious change records. + bool operator==(const AnimationProperty& aOther) const + { + return mProperty == aOther.mProperty && + mSegments == aOther.mSegments; + } + bool operator!=(const AnimationProperty& aOther) const + { + return !(*this == aOther); + } +}; + +struct ElementPropertyTransition; + +namespace dom { + +class Animation; + +class KeyframeEffectReadOnly : public AnimationEffectReadOnly +{ +public: + KeyframeEffectReadOnly(nsIDocument* aDocument, + const Maybe<OwningAnimationTarget>& aTarget, + const TimingParams& aTiming, + const KeyframeEffectParams& aOptions); + + NS_DECL_ISUPPORTS_INHERITED + NS_DECL_CYCLE_COLLECTION_SCRIPT_HOLDER_CLASS_INHERITED(KeyframeEffectReadOnly, + AnimationEffectReadOnly) + + virtual JSObject* WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) override; + + KeyframeEffectReadOnly* AsKeyframeEffect() override { return this; } + + // KeyframeEffectReadOnly interface + static already_AddRefed<KeyframeEffectReadOnly> + Constructor(const GlobalObject& aGlobal, + const Nullable<ElementOrCSSPseudoElement>& aTarget, + JS::Handle<JSObject*> aKeyframes, + const UnrestrictedDoubleOrKeyframeEffectOptions& aOptions, + ErrorResult& aRv); + + static already_AddRefed<KeyframeEffectReadOnly> + Constructor(const GlobalObject& aGlobal, + KeyframeEffectReadOnly& aSource, + ErrorResult& aRv); + + void GetTarget(Nullable<OwningElementOrCSSPseudoElement>& aRv) const; + Maybe<NonOwningAnimationTarget> GetTarget() const + { + Maybe<NonOwningAnimationTarget> result; + if (mTarget) { + result.emplace(*mTarget); + } + return result; + } + void GetKeyframes(JSContext*& aCx, + nsTArray<JSObject*>& aResult, + ErrorResult& aRv); + void GetProperties(nsTArray<AnimationPropertyDetails>& aProperties, + ErrorResult& aRv) const; + + IterationCompositeOperation IterationComposite() const; + CompositeOperation Composite() const; + void GetSpacing(nsString& aRetVal) const + { + mEffectOptions.GetSpacingAsString(aRetVal); + } + + void NotifyAnimationTimingUpdated(); + + void SetAnimation(Animation* aAnimation) override; + + void SetKeyframes(JSContext* aContext, JS::Handle<JSObject*> aKeyframes, + ErrorResult& aRv); + void SetKeyframes(nsTArray<Keyframe>&& aKeyframes, + nsStyleContext* aStyleContext); + + // Returns true if the effect includes |aProperty| regardless of whether the + // property is overridden by !important rule. + bool HasAnimationOfProperty(nsCSSPropertyID aProperty) const; + + // GetEffectiveAnimationOfProperty returns AnimationProperty corresponding + // to a given CSS property if the effect includes the property and the + // property is not overridden by !important rules. + // Also EffectiveAnimationOfProperty returns true under the same condition. + // + // NOTE: We don't currently check for !important rules for properties that + // can't run on the compositor. + bool HasEffectiveAnimationOfProperty(nsCSSPropertyID aProperty) const + { + return GetEffectiveAnimationOfProperty(aProperty) != nullptr; + } + const AnimationProperty* GetEffectiveAnimationOfProperty( + nsCSSPropertyID aProperty) const; + + const InfallibleTArray<AnimationProperty>& Properties() const + { + return mProperties; + } + + // Update |mProperties| by recalculating from |mKeyframes| using + // |aStyleContext| to resolve specified values. + void UpdateProperties(nsStyleContext* aStyleContext); + + // Updates |aStyleRule| with the animation values produced by this + // AnimationEffect for the current time except any properties contained + // in |aPropertiesToSkip|. + void ComposeStyle(RefPtr<AnimValuesStyleRule>& aStyleRule, + const nsCSSPropertyIDSet& aPropertiesToSkip); + // Returns true if at least one property is being animated on compositor. + bool IsRunningOnCompositor() const; + void SetIsRunningOnCompositor(nsCSSPropertyID aProperty, bool aIsRunning); + void ResetIsRunningOnCompositor(); + + // Returns true if this effect, applied to |aFrame|, contains properties + // that mean we shouldn't run transform compositor animations on this element. + // + // For example, if we have an animation of geometric properties like 'left' + // and 'top' on an element, we force all 'transform' animations running at + // the same time on the same element to run on the main thread. + // + // When returning true, |aPerformanceWarning| stores the reason why + // we shouldn't run the transform animations. + bool ShouldBlockAsyncTransformAnimations( + const nsIFrame* aFrame, + AnimationPerformanceWarning::Type& aPerformanceWarning) const; + + nsIDocument* GetRenderedDocument() const; + nsPresContext* GetPresContext() const; + nsIPresShell* GetPresShell() const; + + // Associates a warning with the animated property on the specified frame + // indicating why, for example, the property could not be animated on the + // compositor. |aParams| and |aParamsLength| are optional parameters which + // will be used to generate a localized message for devtools. + void SetPerformanceWarning( + nsCSSPropertyID aProperty, + const AnimationPerformanceWarning& aWarning); + + // Cumulative change hint on each segment for each property. + // This is used for deciding the animation is paint-only. + void CalculateCumulativeChangeHint(nsStyleContext* aStyleContext); + + // Returns true if all of animation properties' change hints + // can ignore painting if the animation is not visible. + // See nsChangeHint_Hints_CanIgnoreIfNotVisible in nsChangeHint.h + // in detail which change hint can be ignored. + bool CanIgnoreIfNotVisible() const; + +protected: + KeyframeEffectReadOnly(nsIDocument* aDocument, + const Maybe<OwningAnimationTarget>& aTarget, + AnimationEffectTimingReadOnly* aTiming, + const KeyframeEffectParams& aOptions); + + ~KeyframeEffectReadOnly() override = default; + + static Maybe<OwningAnimationTarget> + ConvertTarget(const Nullable<ElementOrCSSPseudoElement>& aTarget); + + template<class KeyframeEffectType, class OptionsType> + static already_AddRefed<KeyframeEffectType> + ConstructKeyframeEffect(const GlobalObject& aGlobal, + const Nullable<ElementOrCSSPseudoElement>& aTarget, + JS::Handle<JSObject*> aKeyframes, + const OptionsType& aOptions, + ErrorResult& aRv); + + template<class KeyframeEffectType> + static already_AddRefed<KeyframeEffectType> + ConstructKeyframeEffect(const GlobalObject& aGlobal, + KeyframeEffectReadOnly& aSource, + ErrorResult& aRv); + + // Build properties by recalculating from |mKeyframes| using |aStyleContext| + // to resolve specified values. This function also applies paced spacing if + // needed. + nsTArray<AnimationProperty> BuildProperties(nsStyleContext* aStyleContext); + + // This effect is registered with its target element so long as: + // + // (a) It has a target element, and + // (b) It is "relevant" (i.e. yet to finish but not idle, or finished but + // filling forwards) + // + // As a result, we need to make sure this gets called whenever anything + // changes with regards to this effects's timing including changes to the + // owning Animation's timing. + void UpdateTargetRegistration(); + + // Remove the current effect target from its EffectSet. + void UnregisterTarget(); + + void RequestRestyle(EffectCompositor::RestyleType aRestyleType); + + // Update the associated frame state bits so that, if necessary, a stacking + // context will be created and the effect sent to the compositor. We + // typically need to do this when the properties referenced by the keyframe + // have changed, or when the target frame might have changed. + void MaybeUpdateFrameForCompositor(); + + // Looks up the style context associated with the target element, if any. + // We need to be careful to *not* call this when we are updating the style + // context. That's because calling GetStyleContextForElement when we are in + // the process of building a style context may trigger various forms of + // infinite recursion. + already_AddRefed<nsStyleContext> + GetTargetStyleContext(); + + // A wrapper for marking cascade update according to the current + // target and its effectSet. + void MarkCascadeNeedsUpdate(); + + Maybe<OwningAnimationTarget> mTarget; + + KeyframeEffectParams mEffectOptions; + + // The specified keyframes. + nsTArray<Keyframe> mKeyframes; + + // A set of per-property value arrays, derived from |mKeyframes|. + nsTArray<AnimationProperty> mProperties; + + // The computed progress last time we composed the style rule. This is + // used to detect when the progress is not changing (e.g. due to a step + // timing function) so we can avoid unnecessary style updates. + Nullable<double> mProgressOnLastCompose; + + // The purpose of this value is the same as mProgressOnLastCompose but + // this is used to detect when the current iteration is not changing + // in the case when iterationComposite is accumulate. + uint64_t mCurrentIterationOnLastCompose = 0; + + // We need to track when we go to or from being "in effect" since + // we need to re-evaluate the cascade of animations when that changes. + bool mInEffectOnLastAnimationTimingUpdate; + +private: + nsChangeHint mCumulativeChangeHint; + + nsIFrame* GetAnimationFrame() const; + + bool CanThrottle() const; + bool CanThrottleTransformChanges(nsIFrame& aFrame) const; + + // Returns true if the computedTiming has changed since the last + // composition. + bool HasComputedTimingChanged() const; + + // Returns true unless Gecko limitations prevent performing transform + // animations for |aFrame|. When returning true, the reason for the + // limitation is stored in |aOutPerformanceWarning|. + static bool CanAnimateTransformOnCompositor( + const nsIFrame* aFrame, + AnimationPerformanceWarning::Type& aPerformanceWarning); + static bool IsGeometricProperty(const nsCSSPropertyID aProperty); + + static const TimeDuration OverflowRegionRefreshInterval(); +}; + +} // namespace dom +} // namespace mozilla + +#endif // mozilla_dom_KeyframeEffectReadOnly_h diff --git a/dom/animation/KeyframeUtils.cpp b/dom/animation/KeyframeUtils.cpp new file mode 100644 index 000000000..8e396f84c --- /dev/null +++ b/dom/animation/KeyframeUtils.cpp @@ -0,0 +1,1667 @@ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* 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 "mozilla/KeyframeUtils.h" + +#include "mozilla/AnimationUtils.h" +#include "mozilla/ErrorResult.h" +#include "mozilla/Move.h" +#include "mozilla/RangedArray.h" +#include "mozilla/ServoBindings.h" +#include "mozilla/StyleAnimationValue.h" +#include "mozilla/TimingParams.h" +#include "mozilla/dom/BaseKeyframeTypesBinding.h" // For FastBaseKeyframe etc. +#include "mozilla/dom/Element.h" +#include "mozilla/dom/KeyframeEffectBinding.h" +#include "mozilla/dom/KeyframeEffectReadOnly.h" // For PropertyValuesPair etc. +#include "jsapi.h" // For ForOfIterator etc. +#include "nsClassHashtable.h" +#include "nsCSSParser.h" +#include "nsCSSPropertyIDSet.h" +#include "nsCSSProps.h" +#include "nsCSSPseudoElements.h" // For CSSPseudoElementType +#include "nsTArray.h" +#include <algorithm> // For std::stable_sort + +namespace mozilla { + +// ------------------------------------------------------------------ +// +// Internal data types +// +// ------------------------------------------------------------------ + +// This is used while calculating paced spacing. If the keyframe is not pacable, +// we set its cumulative distance to kNotPaceable, so we can use this to check. +const double kNotPaceable = -1.0; + +// For the aAllowList parameter of AppendStringOrStringSequence and +// GetPropertyValuesPairs. +enum class ListAllowance { eDisallow, eAllow }; + +/** + * A comparator to sort nsCSSPropertyID values such that longhands are sorted + * before shorthands, and shorthands with fewer components are sorted before + * shorthands with more components. + * + * Using this allows us to prioritize values specified by longhands (or smaller + * shorthand subsets) when longhands and shorthands are both specified + * on the one keyframe. + * + * Example orderings that result from this: + * + * margin-left, margin + * + * and: + * + * border-top-color, border-color, border-top, border + */ +class PropertyPriorityComparator +{ +public: + PropertyPriorityComparator() + : mSubpropertyCountInitialized(false) {} + + bool Equals(nsCSSPropertyID aLhs, nsCSSPropertyID aRhs) const + { + return aLhs == aRhs; + } + + bool LessThan(nsCSSPropertyID aLhs, + nsCSSPropertyID aRhs) const + { + bool isShorthandLhs = nsCSSProps::IsShorthand(aLhs); + bool isShorthandRhs = nsCSSProps::IsShorthand(aRhs); + + if (isShorthandLhs) { + if (isShorthandRhs) { + // First, sort shorthands by the number of longhands they have. + uint32_t subpropCountLhs = SubpropertyCount(aLhs); + uint32_t subpropCountRhs = SubpropertyCount(aRhs); + if (subpropCountLhs != subpropCountRhs) { + return subpropCountLhs < subpropCountRhs; + } + // Otherwise, sort by IDL name below. + } else { + // Put longhands before shorthands. + return false; + } + } else { + if (isShorthandRhs) { + // Put longhands before shorthands. + return true; + } + } + // For two longhand properties, or two shorthand with the same number + // of longhand components, sort by IDL name. + return nsCSSProps::PropertyIDLNameSortPosition(aLhs) < + nsCSSProps::PropertyIDLNameSortPosition(aRhs); + } + + uint32_t SubpropertyCount(nsCSSPropertyID aProperty) const + { + if (!mSubpropertyCountInitialized) { + PodZero(&mSubpropertyCount); + mSubpropertyCountInitialized = true; + } + if (mSubpropertyCount[aProperty] == 0) { + uint32_t count = 0; + CSSPROPS_FOR_SHORTHAND_SUBPROPERTIES( + p, aProperty, CSSEnabledState::eForAllContent) { + ++count; + } + mSubpropertyCount[aProperty] = count; + } + return mSubpropertyCount[aProperty]; + } + +private: + // Cache of shorthand subproperty counts. + mutable RangedArray< + uint32_t, + eCSSProperty_COUNT_no_shorthands, + eCSSProperty_COUNT - eCSSProperty_COUNT_no_shorthands> mSubpropertyCount; + mutable bool mSubpropertyCountInitialized; +}; + +/** + * Adaptor for PropertyPriorityComparator to sort objects which have + * a mProperty member. + */ +template <typename T> +class TPropertyPriorityComparator : PropertyPriorityComparator +{ +public: + bool Equals(const T& aLhs, const T& aRhs) const + { + return PropertyPriorityComparator::Equals(aLhs.mProperty, aRhs.mProperty); + } + bool LessThan(const T& aLhs, const T& aRhs) const + { + return PropertyPriorityComparator::LessThan(aLhs.mProperty, aRhs.mProperty); + } +}; + +/** + * Iterator to walk through a PropertyValuePair array using the ordering + * provided by PropertyPriorityComparator. + */ +class PropertyPriorityIterator +{ +public: + explicit PropertyPriorityIterator( + const nsTArray<PropertyValuePair>& aProperties) + : mProperties(aProperties) + { + mSortedPropertyIndices.SetCapacity(mProperties.Length()); + for (size_t i = 0, len = mProperties.Length(); i < len; ++i) { + PropertyAndIndex propertyIndex = { mProperties[i].mProperty, i }; + mSortedPropertyIndices.AppendElement(propertyIndex); + } + mSortedPropertyIndices.Sort(PropertyAndIndex::Comparator()); + } + + class Iter + { + public: + explicit Iter(const PropertyPriorityIterator& aParent) + : mParent(aParent) + , mIndex(0) { } + + static Iter EndIter(const PropertyPriorityIterator &aParent) + { + Iter iter(aParent); + iter.mIndex = aParent.mSortedPropertyIndices.Length(); + return iter; + } + + bool operator!=(const Iter& aOther) const + { + return mIndex != aOther.mIndex; + } + + Iter& operator++() + { + MOZ_ASSERT(mIndex + 1 <= mParent.mSortedPropertyIndices.Length(), + "Should not seek past end iterator"); + mIndex++; + return *this; + } + + const PropertyValuePair& operator*() + { + MOZ_ASSERT(mIndex < mParent.mSortedPropertyIndices.Length(), + "Should not try to dereference an end iterator"); + return mParent.mProperties[mParent.mSortedPropertyIndices[mIndex].mIndex]; + } + + private: + const PropertyPriorityIterator& mParent; + size_t mIndex; + }; + + Iter begin() { return Iter(*this); } + Iter end() { return Iter::EndIter(*this); } + +private: + struct PropertyAndIndex + { + nsCSSPropertyID mProperty; + size_t mIndex; // Index of mProperty within mProperties + + typedef TPropertyPriorityComparator<PropertyAndIndex> Comparator; + }; + + const nsTArray<PropertyValuePair>& mProperties; + nsTArray<PropertyAndIndex> mSortedPropertyIndices; +}; + +/** + * A property-values pair obtained from the open-ended properties + * discovered on a regular keyframe or property-indexed keyframe object. + * + * Single values (as required by a regular keyframe, and as also supported + * on property-indexed keyframes) are stored as the only element in + * mValues. + */ +struct PropertyValuesPair +{ + nsCSSPropertyID mProperty; + nsTArray<nsString> mValues; + + typedef TPropertyPriorityComparator<PropertyValuesPair> Comparator; +}; + +/** + * An additional property (for a property-values pair) found on a + * BaseKeyframe or BasePropertyIndexedKeyframe object. + */ +struct AdditionalProperty +{ + nsCSSPropertyID mProperty; + size_t mJsidIndex; // Index into |ids| in GetPropertyValuesPairs. + + struct PropertyComparator + { + bool Equals(const AdditionalProperty& aLhs, + const AdditionalProperty& aRhs) const + { + return aLhs.mProperty == aRhs.mProperty; + } + bool LessThan(const AdditionalProperty& aLhs, + const AdditionalProperty& aRhs) const + { + return nsCSSProps::PropertyIDLNameSortPosition(aLhs.mProperty) < + nsCSSProps::PropertyIDLNameSortPosition(aRhs.mProperty); + } + }; +}; + +/** + * Data for a segment in a keyframe animation of a given property + * whose value is a StyleAnimationValue. + * + * KeyframeValueEntry is used in GetAnimationPropertiesFromKeyframes + * to gather data for each individual segment. + */ +struct KeyframeValueEntry +{ + nsCSSPropertyID mProperty; + StyleAnimationValue mValue; + float mOffset; + Maybe<ComputedTimingFunction> mTimingFunction; + + struct PropertyOffsetComparator + { + static bool Equals(const KeyframeValueEntry& aLhs, + const KeyframeValueEntry& aRhs) + { + return aLhs.mProperty == aRhs.mProperty && + aLhs.mOffset == aRhs.mOffset; + } + static bool LessThan(const KeyframeValueEntry& aLhs, + const KeyframeValueEntry& aRhs) + { + // First, sort by property IDL name. + int32_t order = nsCSSProps::PropertyIDLNameSortPosition(aLhs.mProperty) - + nsCSSProps::PropertyIDLNameSortPosition(aRhs.mProperty); + if (order != 0) { + return order < 0; + } + + // Then, by offset. + return aLhs.mOffset < aRhs.mOffset; + } + }; +}; + +class ComputedOffsetComparator +{ +public: + static bool Equals(const Keyframe& aLhs, const Keyframe& aRhs) + { + return aLhs.mComputedOffset == aRhs.mComputedOffset; + } + + static bool LessThan(const Keyframe& aLhs, const Keyframe& aRhs) + { + return aLhs.mComputedOffset < aRhs.mComputedOffset; + } +}; + +// ------------------------------------------------------------------ +// +// Inlined helper methods +// +// ------------------------------------------------------------------ + +inline bool +IsInvalidValuePair(const PropertyValuePair& aPair, StyleBackendType aBackend) +{ + if (aBackend == StyleBackendType::Servo) { + return !aPair.mServoDeclarationBlock; + } + + // There are three types of values we store as token streams: + // + // * Shorthand values (where we manually extract the token stream's string + // value) and pass that along to various parsing methods + // * Longhand values with variable references + // * Invalid values + // + // We can distinguish between the last two cases because for invalid values + // we leave the token stream's mPropertyID as eCSSProperty_UNKNOWN. + return !nsCSSProps::IsShorthand(aPair.mProperty) && + aPair.mValue.GetUnit() == eCSSUnit_TokenStream && + aPair.mValue.GetTokenStreamValue()->mPropertyID + == eCSSProperty_UNKNOWN; +} + + +// ------------------------------------------------------------------ +// +// Internal helper method declarations +// +// ------------------------------------------------------------------ + +static void +GetKeyframeListFromKeyframeSequence(JSContext* aCx, + nsIDocument* aDocument, + JS::ForOfIterator& aIterator, + nsTArray<Keyframe>& aResult, + ErrorResult& aRv); + +static bool +ConvertKeyframeSequence(JSContext* aCx, + nsIDocument* aDocument, + JS::ForOfIterator& aIterator, + nsTArray<Keyframe>& aResult); + +static bool +GetPropertyValuesPairs(JSContext* aCx, + JS::Handle<JSObject*> aObject, + ListAllowance aAllowLists, + nsTArray<PropertyValuesPair>& aResult); + +static bool +AppendStringOrStringSequenceToArray(JSContext* aCx, + JS::Handle<JS::Value> aValue, + ListAllowance aAllowLists, + nsTArray<nsString>& aValues); + +static bool +AppendValueAsString(JSContext* aCx, + nsTArray<nsString>& aValues, + JS::Handle<JS::Value> aValue); + +static PropertyValuePair +MakePropertyValuePair(nsCSSPropertyID aProperty, const nsAString& aStringValue, + nsCSSParser& aParser, nsIDocument* aDocument); + +static bool +HasValidOffsets(const nsTArray<Keyframe>& aKeyframes); + +static void +MarkAsComputeValuesFailureKey(PropertyValuePair& aPair); + +static bool +IsComputeValuesFailureKey(const PropertyValuePair& aPair); + +static void +BuildSegmentsFromValueEntries(nsTArray<KeyframeValueEntry>& aEntries, + nsTArray<AnimationProperty>& aResult); + +static void +GetKeyframeListFromPropertyIndexedKeyframe(JSContext* aCx, + nsIDocument* aDocument, + JS::Handle<JS::Value> aValue, + nsTArray<Keyframe>& aResult, + ErrorResult& aRv); + +static bool +RequiresAdditiveAnimation(const nsTArray<Keyframe>& aKeyframes, + nsIDocument* aDocument); + +static void +DistributeRange(const Range<Keyframe>& aSpacingRange, + const Range<Keyframe>& aRangeToAdjust); + +static void +DistributeRange(const Range<Keyframe>& aSpacingRange); + +static void +PaceRange(const Range<Keyframe>& aKeyframes, + const Range<double>& aCumulativeDistances); + +static nsTArray<double> +GetCumulativeDistances(const nsTArray<ComputedKeyframeValues>& aValues, + nsCSSPropertyID aProperty, + nsStyleContext* aStyleContext); + +// ------------------------------------------------------------------ +// +// Public API +// +// ------------------------------------------------------------------ + +/* static */ nsTArray<Keyframe> +KeyframeUtils::GetKeyframesFromObject(JSContext* aCx, + nsIDocument* aDocument, + JS::Handle<JSObject*> aFrames, + ErrorResult& aRv) +{ + MOZ_ASSERT(!aRv.Failed()); + + nsTArray<Keyframe> keyframes; + + if (!aFrames) { + // The argument was explicitly null meaning no keyframes. + return keyframes; + } + + // At this point we know we have an object. We try to convert it to a + // sequence of keyframes first, and if that fails due to not being iterable, + // we try to convert it to a property-indexed keyframe. + JS::Rooted<JS::Value> objectValue(aCx, JS::ObjectValue(*aFrames)); + JS::ForOfIterator iter(aCx); + if (!iter.init(objectValue, JS::ForOfIterator::AllowNonIterable)) { + aRv.Throw(NS_ERROR_FAILURE); + return keyframes; + } + + if (iter.valueIsIterable()) { + GetKeyframeListFromKeyframeSequence(aCx, aDocument, iter, keyframes, aRv); + } else { + GetKeyframeListFromPropertyIndexedKeyframe(aCx, aDocument, objectValue, + keyframes, aRv); + } + + if (aRv.Failed()) { + MOZ_ASSERT(keyframes.IsEmpty(), + "Should not set any keyframes when there is an error"); + return keyframes; + } + + // We currently don't support additive animation. However, Web Animations + // says that if you don't have a keyframe at offset 0 or 1, then you should + // synthesize one using an additive zero value when you go to compose style. + // Until we implement additive animations we just throw if we encounter any + // set of keyframes that would put us in that situation. + + if (RequiresAdditiveAnimation(keyframes, aDocument)) { + aRv.Throw(NS_ERROR_DOM_ANIM_MISSING_PROPS_ERR); + keyframes.Clear(); + } + + return keyframes; +} + +/* static */ void +KeyframeUtils::ApplySpacing(nsTArray<Keyframe>& aKeyframes, + SpacingMode aSpacingMode, + nsCSSPropertyID aProperty, + nsTArray<ComputedKeyframeValues>& aComputedValues, + nsStyleContext* aStyleContext) +{ + if (aKeyframes.IsEmpty()) { + return; + } + + nsTArray<double> cumulativeDistances; + if (aSpacingMode == SpacingMode::paced) { + MOZ_ASSERT(IsAnimatableProperty(aProperty), + "Paced property should be animatable"); + + cumulativeDistances = GetCumulativeDistances(aComputedValues, aProperty, + aStyleContext); + // Reset the computed offsets if using paced spacing. + for (Keyframe& keyframe : aKeyframes) { + keyframe.mComputedOffset = Keyframe::kComputedOffsetNotSet; + } + } + + // If the first keyframe has an unspecified offset, fill it in with 0%. + // If there is only a single keyframe, then it gets 100%. + if (aKeyframes.Length() > 1) { + Keyframe& firstElement = aKeyframes[0]; + firstElement.mComputedOffset = firstElement.mOffset.valueOr(0.0); + // We will fill in the last keyframe's offset below + } else { + Keyframe& lastElement = aKeyframes.LastElement(); + lastElement.mComputedOffset = lastElement.mOffset.valueOr(1.0); + } + + // Fill in remaining missing offsets. + const Keyframe* const last = aKeyframes.cend() - 1; + const RangedPtr<Keyframe> begin(aKeyframes.begin(), aKeyframes.Length()); + RangedPtr<Keyframe> keyframeA = begin; + while (keyframeA != last) { + // Find keyframe A and keyframe B *between* which we will apply spacing. + RangedPtr<Keyframe> keyframeB = keyframeA + 1; + while (keyframeB->mOffset.isNothing() && keyframeB != last) { + ++keyframeB; + } + keyframeB->mComputedOffset = keyframeB->mOffset.valueOr(1.0); + + // Fill computed offsets in (keyframe A, keyframe B). + if (aSpacingMode == SpacingMode::distribute) { + DistributeRange(Range<Keyframe>(keyframeA, keyframeB + 1)); + } else { + // a) Find Paced A (first paceable keyframe) and + // Paced B (last paceable keyframe) in [keyframe A, keyframe B]. + RangedPtr<Keyframe> pacedA = keyframeA; + while (pacedA < keyframeB && + cumulativeDistances[pacedA - begin] == kNotPaceable) { + ++pacedA; + } + RangedPtr<Keyframe> pacedB = keyframeB; + while (pacedB > keyframeA && + cumulativeDistances[pacedB - begin] == kNotPaceable) { + --pacedB; + } + // As spec says, if there is no paceable keyframe + // in [keyframe A, keyframe B], we let Paced A and Paced B refer to + // keyframe B. + if (pacedA > pacedB) { + pacedA = pacedB = keyframeB; + } + // b) Apply distributing offsets in (keyframe A, Paced A] and + // [Paced B, keyframe B). + DistributeRange(Range<Keyframe>(keyframeA, keyframeB + 1), + Range<Keyframe>(keyframeA + 1, pacedA + 1)); + DistributeRange(Range<Keyframe>(keyframeA, keyframeB + 1), + Range<Keyframe>(pacedB, keyframeB)); + // c) Apply paced offsets to each paceable keyframe in (Paced A, Paced B). + // We pass the range [Paced A, Paced B] since PaceRange needs the end + // points of the range in order to calculate the correct offset. + PaceRange(Range<Keyframe>(pacedA, pacedB + 1), + Range<double>(&cumulativeDistances[pacedA - begin], + pacedB - pacedA + 1)); + // d) Fill in any computed offsets in (Paced A, Paced B) that are still + // not set (e.g. because the keyframe was not paceable, or because the + // cumulative distance between paceable properties was zero). + for (RangedPtr<Keyframe> frame = pacedA + 1; frame < pacedB; ++frame) { + if (frame->mComputedOffset != Keyframe::kComputedOffsetNotSet) { + continue; + } + + RangedPtr<Keyframe> start = frame - 1; + RangedPtr<Keyframe> end = frame + 1; + while (end < pacedB && + end->mComputedOffset == Keyframe::kComputedOffsetNotSet) { + ++end; + } + DistributeRange(Range<Keyframe>(start, end + 1)); + frame = end; + } + } + keyframeA = keyframeB; + } +} + +/* static */ void +KeyframeUtils::ApplyDistributeSpacing(nsTArray<Keyframe>& aKeyframes) +{ + nsTArray<ComputedKeyframeValues> emptyArray; + ApplySpacing(aKeyframes, SpacingMode::distribute, eCSSProperty_UNKNOWN, + emptyArray, nullptr); +} + +/* static */ nsTArray<ComputedKeyframeValues> +KeyframeUtils::GetComputedKeyframeValues(const nsTArray<Keyframe>& aKeyframes, + dom::Element* aElement, + nsStyleContext* aStyleContext) +{ + MOZ_ASSERT(aStyleContext); + MOZ_ASSERT(aElement); + + StyleBackendType styleBackend = aElement->OwnerDoc()->GetStyleBackendType(); + + const size_t len = aKeyframes.Length(); + nsTArray<ComputedKeyframeValues> result(len); + + for (const Keyframe& frame : aKeyframes) { + nsCSSPropertyIDSet propertiesOnThisKeyframe; + ComputedKeyframeValues* computedValues = result.AppendElement(); + for (const PropertyValuePair& pair : + PropertyPriorityIterator(frame.mPropertyValues)) { + MOZ_ASSERT(!pair.mServoDeclarationBlock || + styleBackend == StyleBackendType::Servo, + "Animation values were parsed using Servo backend but target" + " element is not using Servo backend?"); + + if (IsInvalidValuePair(pair, styleBackend)) { + continue; + } + + // Expand each value into the set of longhands and produce + // a KeyframeValueEntry for each value. + nsTArray<PropertyStyleAnimationValuePair> values; + + if (styleBackend == StyleBackendType::Servo) { + if (!StyleAnimationValue::ComputeValues(pair.mProperty, + CSSEnabledState::eForAllContent, aStyleContext, + *pair.mServoDeclarationBlock, values)) { + continue; + } + } else { + // For shorthands, we store the string as a token stream so we need to + // extract that first. + if (nsCSSProps::IsShorthand(pair.mProperty)) { + nsCSSValueTokenStream* tokenStream = pair.mValue.GetTokenStreamValue(); + if (!StyleAnimationValue::ComputeValues(pair.mProperty, + CSSEnabledState::eForAllContent, aElement, aStyleContext, + tokenStream->mTokenStream, /* aUseSVGMode */ false, values) || + IsComputeValuesFailureKey(pair)) { + continue; + } + } else { + if (!StyleAnimationValue::ComputeValues(pair.mProperty, + CSSEnabledState::eForAllContent, aElement, aStyleContext, + pair.mValue, /* aUseSVGMode */ false, values)) { + continue; + } + MOZ_ASSERT(values.Length() == 1, + "Longhand properties should produce a single" + " StyleAnimationValue"); + } + } + + for (auto& value : values) { + // If we already got a value for this property on the keyframe, + // skip this one. + if (propertiesOnThisKeyframe.HasProperty(value.mProperty)) { + continue; + } + computedValues->AppendElement(value); + propertiesOnThisKeyframe.AddProperty(value.mProperty); + } + } + } + + MOZ_ASSERT(result.Length() == aKeyframes.Length(), "Array length mismatch"); + return result; +} + +/* static */ nsTArray<AnimationProperty> +KeyframeUtils::GetAnimationPropertiesFromKeyframes( + const nsTArray<Keyframe>& aKeyframes, + const nsTArray<ComputedKeyframeValues>& aComputedValues, + nsStyleContext* aStyleContext) +{ + MOZ_ASSERT(aKeyframes.Length() == aComputedValues.Length(), + "Array length mismatch"); + + nsTArray<KeyframeValueEntry> entries(aKeyframes.Length()); + + const size_t len = aKeyframes.Length(); + for (size_t i = 0; i < len; ++i) { + const Keyframe& frame = aKeyframes[i]; + for (auto& value : aComputedValues[i]) { + MOZ_ASSERT(frame.mComputedOffset != Keyframe::kComputedOffsetNotSet, + "Invalid computed offset"); + KeyframeValueEntry* entry = entries.AppendElement(); + entry->mOffset = frame.mComputedOffset; + entry->mProperty = value.mProperty; + entry->mValue = value.mValue; + entry->mTimingFunction = frame.mTimingFunction; + } + } + + nsTArray<AnimationProperty> result; + BuildSegmentsFromValueEntries(entries, result); + return result; +} + +/* static */ bool +KeyframeUtils::IsAnimatableProperty(nsCSSPropertyID aProperty) +{ + if (aProperty == eCSSProperty_UNKNOWN) { + return false; + } + + if (!nsCSSProps::IsShorthand(aProperty)) { + return nsCSSProps::kAnimTypeTable[aProperty] != eStyleAnimType_None; + } + + CSSPROPS_FOR_SHORTHAND_SUBPROPERTIES(subprop, aProperty, + CSSEnabledState::eForAllContent) { + if (nsCSSProps::kAnimTypeTable[*subprop] != eStyleAnimType_None) { + return true; + } + } + + return false; +} + +// ------------------------------------------------------------------ +// +// Internal helpers +// +// ------------------------------------------------------------------ + +/** + * Converts a JS object to an IDL sequence<Keyframe>. + * + * @param aCx The JSContext corresponding to |aIterator|. + * @param aDocument The document to use when parsing CSS properties. + * @param aIterator An already-initialized ForOfIterator for the JS + * object to iterate over as a sequence. + * @param aResult The array into which the resulting Keyframe objects will be + * appended. + * @param aRv Out param to store any errors thrown by this function. + */ +static void +GetKeyframeListFromKeyframeSequence(JSContext* aCx, + nsIDocument* aDocument, + JS::ForOfIterator& aIterator, + nsTArray<Keyframe>& aResult, + ErrorResult& aRv) +{ + MOZ_ASSERT(!aRv.Failed()); + MOZ_ASSERT(aResult.IsEmpty()); + + // Convert the object in aIterator to a sequence of keyframes producing + // an array of Keyframe objects. + if (!ConvertKeyframeSequence(aCx, aDocument, aIterator, aResult)) { + aRv.Throw(NS_ERROR_FAILURE); + aResult.Clear(); + return; + } + + // If the sequence<> had zero elements, we won't generate any + // keyframes. + if (aResult.IsEmpty()) { + return; + } + + // Check that the keyframes are loosely sorted and with values all + // between 0% and 100%. + if (!HasValidOffsets(aResult)) { + aRv.ThrowTypeError<dom::MSG_INVALID_KEYFRAME_OFFSETS>(); + aResult.Clear(); + return; + } +} + +/** + * Converts a JS object wrapped by the given JS::ForIfIterator to an + * IDL sequence<Keyframe> and stores the resulting Keyframe objects in + * aResult. + */ +static bool +ConvertKeyframeSequence(JSContext* aCx, + nsIDocument* aDocument, + JS::ForOfIterator& aIterator, + nsTArray<Keyframe>& aResult) +{ + JS::Rooted<JS::Value> value(aCx); + nsCSSParser parser(aDocument->CSSLoader()); + + for (;;) { + bool done; + if (!aIterator.next(&value, &done)) { + return false; + } + if (done) { + break; + } + // Each value found when iterating the object must be an object + // or null/undefined (which gets treated as a default {} dictionary + // value). + if (!value.isObject() && !value.isNullOrUndefined()) { + dom::ThrowErrorMessage(aCx, dom::MSG_NOT_OBJECT, + "Element of sequence<Keyframe> argument"); + return false; + } + + // Convert the JS value into a BaseKeyframe dictionary value. + dom::binding_detail::FastBaseKeyframe keyframeDict; + if (!keyframeDict.Init(aCx, value, + "Element of sequence<Keyframe> argument")) { + return false; + } + + Keyframe* keyframe = aResult.AppendElement(fallible); + if (!keyframe) { + return false; + } + if (!keyframeDict.mOffset.IsNull()) { + keyframe->mOffset.emplace(keyframeDict.mOffset.Value()); + } + + ErrorResult rv; + keyframe->mTimingFunction = + TimingParams::ParseEasing(keyframeDict.mEasing, aDocument, rv); + if (rv.MaybeSetPendingException(aCx)) { + return false; + } + + // Look for additional property-values pairs on the object. + nsTArray<PropertyValuesPair> propertyValuePairs; + if (value.isObject()) { + JS::Rooted<JSObject*> object(aCx, &value.toObject()); + if (!GetPropertyValuesPairs(aCx, object, + ListAllowance::eDisallow, + propertyValuePairs)) { + return false; + } + } + + for (PropertyValuesPair& pair : propertyValuePairs) { + MOZ_ASSERT(pair.mValues.Length() == 1); + keyframe->mPropertyValues.AppendElement( + MakePropertyValuePair(pair.mProperty, pair.mValues[0], parser, + aDocument)); + + // When we go to convert keyframes into arrays of property values we + // call StyleAnimation::ComputeValues. This should normally return true + // but in order to test the case where it does not, BaseKeyframeDict + // includes a chrome-only member that can be set to indicate that + // ComputeValues should fail for shorthand property values on that + // keyframe. + if (nsCSSProps::IsShorthand(pair.mProperty) && + keyframeDict.mSimulateComputeValuesFailure) { + MarkAsComputeValuesFailureKey(keyframe->mPropertyValues.LastElement()); + } + } + } + + return true; +} + +/** + * Reads the property-values pairs from the specified JS object. + * + * @param aObject The JS object to look at. + * @param aAllowLists If eAllow, values will be converted to + * (DOMString or sequence<DOMString); if eDisallow, values + * will be converted to DOMString. + * @param aResult The array into which the enumerated property-values + * pairs will be stored. + * @return false on failure or JS exception thrown while interacting + * with aObject; true otherwise. + */ +static bool +GetPropertyValuesPairs(JSContext* aCx, + JS::Handle<JSObject*> aObject, + ListAllowance aAllowLists, + nsTArray<PropertyValuesPair>& aResult) +{ + nsTArray<AdditionalProperty> properties; + + // Iterate over all the properties on aObject and append an + // entry to properties for them. + // + // We don't compare the jsids that we encounter with those for + // the explicit dictionary members, since we know that none + // of the CSS property IDL names clash with them. + JS::Rooted<JS::IdVector> ids(aCx, JS::IdVector(aCx)); + if (!JS_Enumerate(aCx, aObject, &ids)) { + return false; + } + for (size_t i = 0, n = ids.length(); i < n; i++) { + nsAutoJSString propName; + if (!propName.init(aCx, ids[i])) { + return false; + } + nsCSSPropertyID property = + nsCSSProps::LookupPropertyByIDLName(propName, + CSSEnabledState::eForAllContent); + if (KeyframeUtils::IsAnimatableProperty(property)) { + AdditionalProperty* p = properties.AppendElement(); + p->mProperty = property; + p->mJsidIndex = i; + } + } + + // Sort the entries by IDL name and then get each value and + // convert it either to a DOMString or to a + // (DOMString or sequence<DOMString>), depending on aAllowLists, + // and build up aResult. + properties.Sort(AdditionalProperty::PropertyComparator()); + + for (AdditionalProperty& p : properties) { + JS::Rooted<JS::Value> value(aCx); + if (!JS_GetPropertyById(aCx, aObject, ids[p.mJsidIndex], &value)) { + return false; + } + PropertyValuesPair* pair = aResult.AppendElement(); + pair->mProperty = p.mProperty; + if (!AppendStringOrStringSequenceToArray(aCx, value, aAllowLists, + pair->mValues)) { + return false; + } + } + + return true; +} + +/** + * Converts aValue to DOMString, if aAllowLists is eDisallow, or + * to (DOMString or sequence<DOMString>) if aAllowLists is aAllow. + * The resulting strings are appended to aValues. + */ +static bool +AppendStringOrStringSequenceToArray(JSContext* aCx, + JS::Handle<JS::Value> aValue, + ListAllowance aAllowLists, + nsTArray<nsString>& aValues) +{ + if (aAllowLists == ListAllowance::eAllow && aValue.isObject()) { + // The value is an object, and we want to allow lists; convert + // aValue to (DOMString or sequence<DOMString>). + JS::ForOfIterator iter(aCx); + if (!iter.init(aValue, JS::ForOfIterator::AllowNonIterable)) { + return false; + } + if (iter.valueIsIterable()) { + // If the object is iterable, convert it to sequence<DOMString>. + JS::Rooted<JS::Value> element(aCx); + for (;;) { + bool done; + if (!iter.next(&element, &done)) { + return false; + } + if (done) { + break; + } + if (!AppendValueAsString(aCx, aValues, element)) { + return false; + } + } + return true; + } + } + + // Either the object is not iterable, or aAllowLists doesn't want + // a list; convert it to DOMString. + if (!AppendValueAsString(aCx, aValues, aValue)) { + return false; + } + + return true; +} + +/** + * Converts aValue to DOMString and appends it to aValues. + */ +static bool +AppendValueAsString(JSContext* aCx, + nsTArray<nsString>& aValues, + JS::Handle<JS::Value> aValue) +{ + return ConvertJSValueToString(aCx, aValue, dom::eStringify, dom::eStringify, + *aValues.AppendElement()); +} + +/** + * Construct a PropertyValuePair parsing the given string into a suitable + * nsCSSValue object. + * + * @param aProperty The CSS property. + * @param aStringValue The property value to parse. + * @param aParser The CSS parser object to use. + * @param aDocument The document to use when parsing. + * @return The constructed PropertyValuePair object. + */ +static PropertyValuePair +MakePropertyValuePair(nsCSSPropertyID aProperty, const nsAString& aStringValue, + nsCSSParser& aParser, nsIDocument* aDocument) +{ + MOZ_ASSERT(aDocument); + PropertyValuePair result; + + result.mProperty = aProperty; + + if (aDocument->GetStyleBackendType() == StyleBackendType::Servo) { + nsCString name = nsCSSProps::GetStringValue(aProperty); + + NS_ConvertUTF16toUTF8 value(aStringValue); + RefPtr<ThreadSafeURIHolder> base = + new ThreadSafeURIHolder(aDocument->GetDocumentURI()); + RefPtr<ThreadSafeURIHolder> referrer = + new ThreadSafeURIHolder(aDocument->GetDocumentURI()); + RefPtr<ThreadSafePrincipalHolder> principal = + new ThreadSafePrincipalHolder(aDocument->NodePrincipal()); + + nsCString baseString; + aDocument->GetDocumentURI()->GetSpec(baseString); + + RefPtr<RawServoDeclarationBlock> servoDeclarationBlock = + Servo_ParseProperty(&name, &value, &baseString, + base, referrer, principal).Consume(); + + if (servoDeclarationBlock) { + result.mServoDeclarationBlock = servoDeclarationBlock.forget(); + return result; + } + } + + nsCSSValue value; + if (!nsCSSProps::IsShorthand(aProperty)) { + aParser.ParseLonghandProperty(aProperty, + aStringValue, + aDocument->GetDocumentURI(), + aDocument->GetDocumentURI(), + aDocument->NodePrincipal(), + value); + } + + if (value.GetUnit() == eCSSUnit_Null) { + // Either we have a shorthand, or we failed to parse a longhand. + // In either case, store the string value as a token stream. + nsCSSValueTokenStream* tokenStream = new nsCSSValueTokenStream; + tokenStream->mTokenStream = aStringValue; + + // We are about to convert a null value to a token stream value but + // by leaving the mPropertyID as unknown, we will be able to + // distinguish between invalid values and valid token stream values + // (e.g. values with variable references). + MOZ_ASSERT(tokenStream->mPropertyID == eCSSProperty_UNKNOWN, + "The property of a token stream should be initialized" + " to unknown"); + + // By leaving mShorthandPropertyID as unknown, we ensure that when + // we call nsCSSValue::AppendToString we get back the string stored + // in mTokenStream. + MOZ_ASSERT(tokenStream->mShorthandPropertyID == eCSSProperty_UNKNOWN, + "The shorthand property of a token stream should be initialized" + " to unknown"); + value.SetTokenStreamValue(tokenStream); + } else { + // If we succeeded in parsing with Gecko, but not Servo the animation is + // not going to work since, for the purposes of animation, we're going to + // ignore |mValue| when the backend is Servo. + NS_WARNING_ASSERTION(aDocument->GetStyleBackendType() != + StyleBackendType::Servo, + "Gecko succeeded in parsing where Servo failed"); + } + + result.mValue = value; + + return result; +} + +/** + * Checks that the given keyframes are loosely ordered (each keyframe's + * offset that is not null is greater than or equal to the previous + * non-null offset) and that all values are within the range [0.0, 1.0]. + * + * @return true if the keyframes' offsets are correctly ordered and + * within range; false otherwise. + */ +static bool +HasValidOffsets(const nsTArray<Keyframe>& aKeyframes) +{ + double offset = 0.0; + for (const Keyframe& keyframe : aKeyframes) { + if (keyframe.mOffset) { + double thisOffset = keyframe.mOffset.value(); + if (thisOffset < offset || thisOffset > 1.0f) { + return false; + } + offset = thisOffset; + } + } + return true; +} + +/** + * Takes a property-value pair for a shorthand property and modifies the + * value to indicate that when we call StyleAnimationValue::ComputeValues on + * that value we should behave as if that function had failed. + * + * @param aPair The PropertyValuePair to modify. |aPair.mProperty| must be + * a shorthand property. + */ +static void +MarkAsComputeValuesFailureKey(PropertyValuePair& aPair) +{ + MOZ_ASSERT(nsCSSProps::IsShorthand(aPair.mProperty), + "Only shorthand property values can be marked as failure values"); + + // We store shorthand values as nsCSSValueTokenStream objects whose mProperty + // and mShorthandPropertyID are eCSSProperty_UNKNOWN and whose mTokenStream + // member contains the shorthand property's value as a string. + // + // We need to leave mShorthandPropertyID as eCSSProperty_UNKNOWN so that + // nsCSSValue::AppendToString returns the mTokenStream value, but we can + // update mPropertyID to a special value to indicate that this is + // a special failure sentinel. + nsCSSValueTokenStream* tokenStream = aPair.mValue.GetTokenStreamValue(); + MOZ_ASSERT(tokenStream->mPropertyID == eCSSProperty_UNKNOWN, + "Shorthand value should initially have an unknown property ID"); + tokenStream->mPropertyID = eCSSPropertyExtra_no_properties; +} + +/** + * Returns true if |aPair| is a property-value pair on which we have + * previously called MarkAsComputeValuesFailureKey (and hence we should + * simulate failure when calling StyleAnimationValue::ComputeValues using its + * value). + * + * @param aPair The property-value pair to test. + * @return True if |aPair| represents a failure value. + */ +static bool +IsComputeValuesFailureKey(const PropertyValuePair& aPair) +{ + return nsCSSProps::IsShorthand(aPair.mProperty) && + aPair.mValue.GetTokenStreamValue()->mPropertyID == + eCSSPropertyExtra_no_properties; +} + +/** + * Builds an array of AnimationProperty objects to represent the keyframe + * animation segments in aEntries. + */ +static void +BuildSegmentsFromValueEntries(nsTArray<KeyframeValueEntry>& aEntries, + nsTArray<AnimationProperty>& aResult) +{ + if (aEntries.IsEmpty()) { + return; + } + + // Sort the KeyframeValueEntry objects so that all entries for a given + // property are together, and the entries are sorted by offset otherwise. + std::stable_sort(aEntries.begin(), aEntries.end(), + &KeyframeValueEntry::PropertyOffsetComparator::LessThan); + + // For a given index i, we want to generate a segment from aEntries[i] + // to aEntries[j], if: + // + // * j > i, + // * aEntries[i + 1]'s offset/property is different from aEntries[i]'s, and + // * aEntries[j - 1]'s offset/property is different from aEntries[j]'s. + // + // That will eliminate runs of same offset/property values where there's no + // point generating zero length segments in the middle of the animation. + // + // Additionally we need to generate a zero length segment at offset 0 and at + // offset 1, if we have multiple values for a given property at that offset, + // since we need to retain the very first and very last value so they can + // be used for reverse and forward filling. + // + // Typically, for each property in |aEntries|, we expect there to be at least + // one KeyframeValueEntry with offset 0.0, and at least one with offset 1.0. + // However, since it is possible that when building |aEntries|, the call to + // StyleAnimationValue::ComputeValues might fail, this can't be guaranteed. + // Furthermore, since we don't yet implement additive animation and hence + // don't have sensible fallback behavior when these values are missing, the + // following loop takes care to identify properties that lack a value at + // offset 0.0/1.0 and drops those properties from |aResult|. + + nsCSSPropertyID lastProperty = eCSSProperty_UNKNOWN; + AnimationProperty* animationProperty = nullptr; + + size_t i = 0, n = aEntries.Length(); + + while (i < n) { + // Check that the last property ends with an entry at offset 1. + if (i + 1 == n) { + if (aEntries[i].mOffset != 1.0f && animationProperty) { + aResult.RemoveElementAt(aResult.Length() - 1); + animationProperty = nullptr; + } + break; + } + + MOZ_ASSERT(aEntries[i].mProperty != eCSSProperty_UNKNOWN && + aEntries[i + 1].mProperty != eCSSProperty_UNKNOWN, + "Each entry should specify a valid property"); + + // Skip properties that don't have an entry with offset 0. + if (aEntries[i].mProperty != lastProperty && + aEntries[i].mOffset != 0.0f) { + // Since the entries are sorted by offset for a given property, and + // since we don't update |lastProperty|, we will keep hitting this + // condition until we change property. + ++i; + continue; + } + + // Drop properties that don't end with an entry with offset 1. + if (aEntries[i].mProperty != aEntries[i + 1].mProperty && + aEntries[i].mOffset != 1.0f) { + if (animationProperty) { + aResult.RemoveElementAt(aResult.Length() - 1); + animationProperty = nullptr; + } + ++i; + continue; + } + + // Starting from i, determine the next [i, j] interval from which to + // generate a segment. + size_t j; + if (aEntries[i].mOffset == 0.0f && aEntries[i + 1].mOffset == 0.0f) { + // We need to generate an initial zero-length segment. + MOZ_ASSERT(aEntries[i].mProperty == aEntries[i + 1].mProperty); + j = i + 1; + while (aEntries[j + 1].mOffset == 0.0f && + aEntries[j + 1].mProperty == aEntries[j].mProperty) { + ++j; + } + } else if (aEntries[i].mOffset == 1.0f) { + if (aEntries[i + 1].mOffset == 1.0f && + aEntries[i + 1].mProperty == aEntries[i].mProperty) { + // We need to generate a final zero-length segment. + j = i + 1; + while (j + 1 < n && + aEntries[j + 1].mOffset == 1.0f && + aEntries[j + 1].mProperty == aEntries[j].mProperty) { + ++j; + } + } else { + // New property. + MOZ_ASSERT(aEntries[i].mProperty != aEntries[i + 1].mProperty); + animationProperty = nullptr; + ++i; + continue; + } + } else { + while (aEntries[i].mOffset == aEntries[i + 1].mOffset && + aEntries[i].mProperty == aEntries[i + 1].mProperty) { + ++i; + } + j = i + 1; + } + + // If we've moved on to a new property, create a new AnimationProperty + // to insert segments into. + if (aEntries[i].mProperty != lastProperty) { + MOZ_ASSERT(aEntries[i].mOffset == 0.0f); + MOZ_ASSERT(!animationProperty); + animationProperty = aResult.AppendElement(); + animationProperty->mProperty = aEntries[i].mProperty; + lastProperty = aEntries[i].mProperty; + } + + MOZ_ASSERT(animationProperty, "animationProperty should be valid pointer."); + + // Now generate the segment. + AnimationPropertySegment* segment = + animationProperty->mSegments.AppendElement(); + segment->mFromKey = aEntries[i].mOffset; + segment->mToKey = aEntries[j].mOffset; + segment->mFromValue = aEntries[i].mValue; + segment->mToValue = aEntries[j].mValue; + segment->mTimingFunction = aEntries[i].mTimingFunction; + + i = j; + } +} + +/** + * Converts a JS object representing a property-indexed keyframe into + * an array of Keyframe objects. + * + * @param aCx The JSContext for |aValue|. + * @param aDocument The document to use when parsing CSS properties. + * @param aValue The JS object. + * @param aResult The array into which the resulting AnimationProperty + * objects will be appended. + * @param aRv Out param to store any errors thrown by this function. + */ +static void +GetKeyframeListFromPropertyIndexedKeyframe(JSContext* aCx, + nsIDocument* aDocument, + JS::Handle<JS::Value> aValue, + nsTArray<Keyframe>& aResult, + ErrorResult& aRv) +{ + MOZ_ASSERT(aValue.isObject()); + MOZ_ASSERT(aResult.IsEmpty()); + MOZ_ASSERT(!aRv.Failed()); + + // Convert the object to a property-indexed keyframe dictionary to + // get its explicit dictionary members. + dom::binding_detail::FastBasePropertyIndexedKeyframe keyframeDict; + if (!keyframeDict.Init(aCx, aValue, "BasePropertyIndexedKeyframe argument", + false)) { + aRv.Throw(NS_ERROR_FAILURE); + return; + } + + Maybe<ComputedTimingFunction> easing = + TimingParams::ParseEasing(keyframeDict.mEasing, aDocument, aRv); + if (aRv.Failed()) { + return; + } + + // Get all the property--value-list pairs off the object. + JS::Rooted<JSObject*> object(aCx, &aValue.toObject()); + nsTArray<PropertyValuesPair> propertyValuesPairs; + if (!GetPropertyValuesPairs(aCx, object, ListAllowance::eAllow, + propertyValuesPairs)) { + aRv.Throw(NS_ERROR_FAILURE); + return; + } + + // Create a set of keyframes for each property. + nsCSSParser parser(aDocument->CSSLoader()); + nsClassHashtable<nsFloatHashKey, Keyframe> processedKeyframes; + for (const PropertyValuesPair& pair : propertyValuesPairs) { + size_t count = pair.mValues.Length(); + if (count == 0) { + // No animation values for this property. + continue; + } + if (count == 1) { + // We don't support additive values and so can't support an + // animation that goes from the underlying value to this + // specified value. Throw an exception until we do support this. + aRv.Throw(NS_ERROR_DOM_ANIM_MISSING_PROPS_ERR); + return; + } + + size_t n = pair.mValues.Length() - 1; + size_t i = 0; + + for (const nsString& stringValue : pair.mValues) { + double offset = i++ / double(n); + Keyframe* keyframe = processedKeyframes.LookupOrAdd(offset); + if (keyframe->mPropertyValues.IsEmpty()) { + keyframe->mTimingFunction = easing; + keyframe->mComputedOffset = offset; + } + keyframe->mPropertyValues.AppendElement( + MakePropertyValuePair(pair.mProperty, stringValue, parser, aDocument)); + } + } + + aResult.SetCapacity(processedKeyframes.Count()); + for (auto iter = processedKeyframes.Iter(); !iter.Done(); iter.Next()) { + aResult.AppendElement(Move(*iter.UserData())); + } + + aResult.Sort(ComputedOffsetComparator()); +} + +/** + * Returns true if the supplied set of keyframes has keyframe values for + * any property for which it does not also supply a value for the 0% and 100% + * offsets. In this case we are supposed to synthesize an additive zero value + * but since we don't support additive animation yet we can't support this + * case. We try to detect that here so we can throw an exception. The check is + * not entirely accurate but should detect most common cases. + * + * @param aKeyframes The set of keyframes to analyze. + * @param aDocument The document to use when parsing keyframes so we can + * try to detect where we have an invalid value at 0%/100%. + */ +static bool +RequiresAdditiveAnimation(const nsTArray<Keyframe>& aKeyframes, + nsIDocument* aDocument) +{ + // We are looking to see if that every property referenced in |aKeyframes| + // has a valid property at offset 0.0 and 1.0. The check as to whether a + // property is valid or not, however, is not precise. We only check if the + // property can be parsed, NOT whether it can also be converted to a + // StyleAnimationValue since doing that requires a target element bound to + // a document which we might not always have at the point where we want to + // perform this check. + // + // This is only a temporary measure until we implement additive animation. + // So as long as this check catches most cases, and we don't do anything + // horrible in one of the cases we can't detect, it should be sufficient. + + nsCSSPropertyIDSet properties; // All properties encountered. + nsCSSPropertyIDSet propertiesWithFromValue; // Those with a defined 0% value. + nsCSSPropertyIDSet propertiesWithToValue; // Those with a defined 100% value. + + auto addToPropertySets = [&](nsCSSPropertyID aProperty, double aOffset) { + properties.AddProperty(aProperty); + if (aOffset == 0.0) { + propertiesWithFromValue.AddProperty(aProperty); + } else if (aOffset == 1.0) { + propertiesWithToValue.AddProperty(aProperty); + } + }; + + StyleBackendType styleBackend = aDocument->GetStyleBackendType(); + + for (size_t i = 0, len = aKeyframes.Length(); i < len; i++) { + const Keyframe& frame = aKeyframes[i]; + + // We won't have called ApplySpacing when this is called so + // we can't use frame.mComputedOffset. Instead we do a rough version + // of that algorithm that substitutes null offsets with 0.0 for the first + // frame, 1.0 for the last frame, and 0.5 for everything else. + double computedOffset = i == len - 1 + ? 1.0 + : i == 0 ? 0.0 : 0.5; + double offsetToUse = frame.mOffset + ? frame.mOffset.value() + : computedOffset; + + for (const PropertyValuePair& pair : frame.mPropertyValues) { + if (IsInvalidValuePair(pair, styleBackend)) { + continue; + } + + if (nsCSSProps::IsShorthand(pair.mProperty)) { + if (styleBackend == StyleBackendType::Gecko) { + nsCSSValueTokenStream* tokenStream = + pair.mValue.GetTokenStreamValue(); + nsCSSParser parser(aDocument->CSSLoader()); + if (!parser.IsValueValidForProperty(pair.mProperty, + tokenStream->mTokenStream)) { + continue; + } + } + // For the Servo backend, invalid shorthand values are represented by + // a null mServoDeclarationBlock member which we skip above in + // IsInvalidValuePair. + MOZ_ASSERT(styleBackend != StyleBackendType::Servo || + pair.mServoDeclarationBlock); + CSSPROPS_FOR_SHORTHAND_SUBPROPERTIES( + prop, pair.mProperty, CSSEnabledState::eForAllContent) { + addToPropertySets(*prop, offsetToUse); + } + } else { + addToPropertySets(pair.mProperty, offsetToUse); + } + } + } + + return !propertiesWithFromValue.Equals(properties) || + !propertiesWithToValue.Equals(properties); +} + +/** + * Evenly distribute the computed offsets in (A, B). + * We pass the range keyframes in [A, B] and use A, B to calculate distributing + * computed offsets in (A, B). The second range, aRangeToAdjust, is passed, so + * we can know which keyframe we want to apply to. aRangeToAdjust should be in + * the range of aSpacingRange. + * + * @param aSpacingRange The sequence of keyframes between whose endpoints we + * should apply distribute spacing. + * @param aRangeToAdjust The range of keyframes we want to apply to. + */ +static void +DistributeRange(const Range<Keyframe>& aSpacingRange, + const Range<Keyframe>& aRangeToAdjust) +{ + MOZ_ASSERT(aRangeToAdjust.begin() >= aSpacingRange.begin() && + aRangeToAdjust.end() <= aSpacingRange.end(), + "Out of range"); + const size_t n = aSpacingRange.length() - 1; + const double startOffset = aSpacingRange[0].mComputedOffset; + const double diffOffset = aSpacingRange[n].mComputedOffset - startOffset; + for (auto iter = aRangeToAdjust.begin(); + iter != aRangeToAdjust.end(); + ++iter) { + size_t index = iter - aSpacingRange.begin(); + iter->mComputedOffset = startOffset + double(index) / n * diffOffset; + } +} + +/** + * Overload of DistributeRange to apply distribute spacing to all keyframes in + * between the endpoints of the given range. + * + * @param aSpacingRange The sequence of keyframes between whose endpoints we + * should apply distribute spacing. + */ +static void +DistributeRange(const Range<Keyframe>& aSpacingRange) +{ + // We don't need to apply distribute spacing to keyframe A and keyframe B. + DistributeRange(aSpacingRange, + Range<Keyframe>(aSpacingRange.begin() + 1, + aSpacingRange.end() - 1)); +} + +/** + * Apply paced spacing to all paceable keyframes in between the endpoints of the + * given range. + * + * @param aKeyframes The range of keyframes between whose endpoints we should + * apply paced spacing. Both endpoints should be paceable, i.e. the + * corresponding elements in |aCumulativeDist| should not be kNotPaceable. + * Within this function, we refer to the start and end points of this range + * as Paced A and Paced B respectively in keeping with the notation used in + * the spec. + * @param aCumulativeDistances The sequence of cumulative distances of the paced + * property as returned by GetCumulativeDistances(). This acts as a + * parallel range to |aKeyframes|. + */ +static void +PaceRange(const Range<Keyframe>& aKeyframes, + const Range<double>& aCumulativeDistances) +{ + MOZ_ASSERT(aKeyframes.length() == aCumulativeDistances.length(), + "Range length mismatch"); + + const size_t len = aKeyframes.length(); + // If there is nothing between the end points, there is nothing to space. + if (len < 3) { + return; + } + + const double distA = *(aCumulativeDistances.begin()); + const double distB = *(aCumulativeDistances.end() - 1); + MOZ_ASSERT(distA != kNotPaceable && distB != kNotPaceable, + "Both Paced A and Paced B should be paceable"); + + // If the total distance is zero, we should fall back to distribute spacing. + // The caller will fill-in any keyframes without a computed offset using + // distribute spacing so we can just return here. + if (distA == distB) { + return; + } + + const RangedPtr<Keyframe> pacedA = aKeyframes.begin(); + const RangedPtr<Keyframe> pacedB = aKeyframes.end() - 1; + MOZ_ASSERT(pacedA->mComputedOffset != Keyframe::kComputedOffsetNotSet && + pacedB->mComputedOffset != Keyframe::kComputedOffsetNotSet, + "Both Paced A and Paced B should have valid computed offsets"); + + // Apply computed offset. + const double offsetA = pacedA->mComputedOffset; + const double diffOffset = pacedB->mComputedOffset - offsetA; + const double initialDist = distA; + const double totalDist = distB - initialDist; + for (auto iter = pacedA + 1; iter != pacedB; ++iter) { + size_t k = iter - aKeyframes.begin(); + if (aCumulativeDistances[k] == kNotPaceable) { + continue; + } + + double dist = aCumulativeDistances[k] - initialDist; + iter->mComputedOffset = offsetA + diffOffset * dist / totalDist; + } +} + +/** + * Get cumulative distances for the paced property. + * + * @param aValues The computed values returned by GetComputedKeyframeValues. + * @param aPacedProperty The paced property. + * @param aStyleContext The style context for computing distance on transform. + * @return The cumulative distances for the paced property. The length will be + * the same as aValues. + */ +static nsTArray<double> +GetCumulativeDistances(const nsTArray<ComputedKeyframeValues>& aValues, + nsCSSPropertyID aPacedProperty, + nsStyleContext* aStyleContext) +{ + // a) If aPacedProperty is a shorthand property, get its components. + // Otherwise, just add the longhand property into the set. + size_t pacedPropertyCount = 0; + nsCSSPropertyIDSet pacedPropertySet; + bool isShorthand = nsCSSProps::IsShorthand(aPacedProperty); + if (isShorthand) { + CSSPROPS_FOR_SHORTHAND_SUBPROPERTIES(p, aPacedProperty, + CSSEnabledState::eForAllContent) { + pacedPropertySet.AddProperty(*p); + ++pacedPropertyCount; + } + } else { + pacedPropertySet.AddProperty(aPacedProperty); + pacedPropertyCount = 1; + } + + // b) Search each component (shorthand) or the longhand property, and + // calculate the cumulative distances of paceable keyframe pairs. + const size_t len = aValues.Length(); + nsTArray<double> cumulativeDistances(len); + // cumulativeDistances is a parallel array to |aValues|, so set its length to + // the length of |aValues|. + cumulativeDistances.SetLength(len); + ComputedKeyframeValues prevPacedValues; + size_t preIdx = 0; + for (size_t i = 0; i < len; ++i) { + // Find computed values of the paced property. + ComputedKeyframeValues pacedValues; + for (const PropertyStyleAnimationValuePair& pair : aValues[i]) { + if (pacedPropertySet.HasProperty(pair.mProperty)) { + pacedValues.AppendElement(pair); + } + } + + // Check we have values for all the paceable longhand components. + if (pacedValues.Length() != pacedPropertyCount) { + // This keyframe is not paceable, assign kNotPaceable and skip it. + cumulativeDistances[i] = kNotPaceable; + continue; + } + + // Sort the pacedValues first, so the order of subproperties of + // pacedValues is always the same as that of prevPacedValues. + if (isShorthand) { + pacedValues.Sort( + TPropertyPriorityComparator<PropertyStyleAnimationValuePair>()); + } + + if (prevPacedValues.IsEmpty()) { + // This is the first paceable keyframe so its cumulative distance is 0.0. + cumulativeDistances[i] = 0.0; + } else { + double dist = 0.0; + if (isShorthand) { + // Apply the distance by the square root of the sum of squares of + // longhand component distances. + for (size_t propIdx = 0; propIdx < pacedPropertyCount; ++propIdx) { + nsCSSPropertyID prop = prevPacedValues[propIdx].mProperty; + MOZ_ASSERT(pacedValues[propIdx].mProperty == prop, + "Property mismatch"); + + double componentDistance = 0.0; + if (StyleAnimationValue::ComputeDistance( + prop, + prevPacedValues[propIdx].mValue, + pacedValues[propIdx].mValue, + aStyleContext, + componentDistance)) { + dist += componentDistance * componentDistance; + } + } + dist = sqrt(dist); + } else { + // If the property is longhand, we just use the 1st value. + // If ComputeDistance() fails, |dist| will remain zero so there will be + // no distance between the previous paced value and this value. + Unused << + StyleAnimationValue::ComputeDistance(aPacedProperty, + prevPacedValues[0].mValue, + pacedValues[0].mValue, + aStyleContext, + dist); + } + cumulativeDistances[i] = cumulativeDistances[preIdx] + dist; + } + prevPacedValues.SwapElements(pacedValues); + preIdx = i; + } + return cumulativeDistances; +} + +} // namespace mozilla diff --git a/dom/animation/KeyframeUtils.h b/dom/animation/KeyframeUtils.h new file mode 100644 index 000000000..dbaed97f5 --- /dev/null +++ b/dom/animation/KeyframeUtils.h @@ -0,0 +1,151 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* 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/. */ + +#ifndef mozilla_KeyframeUtils_h +#define mozilla_KeyframeUtils_h + +#include "nsTArrayForwardDeclare.h" // For nsTArray +#include "js/RootingAPI.h" // For JS::Handle +#include "mozilla/KeyframeEffectParams.h" // SpacingMode + +struct JSContext; +class JSObject; +class nsIDocument; +class nsStyleContext; + +namespace mozilla { +struct AnimationProperty; +enum class CSSPseudoElementType : uint8_t; +class ErrorResult; +struct Keyframe; +struct PropertyStyleAnimationValuePair; + +namespace dom { +class Element; +} // namespace dom +} // namespace mozilla + + +namespace mozilla { + +// Represents the set of property-value pairs on a Keyframe converted to +// computed values. +using ComputedKeyframeValues = nsTArray<PropertyStyleAnimationValuePair>; + +/** + * Utility methods for processing keyframes. + */ +class KeyframeUtils +{ +public: + /** + * Converts a JS value representing a property-indexed keyframe or a sequence + * of keyframes to an array of Keyframe objects. + * + * @param aCx The JSContext that corresponds to |aFrames|. + * @param aDocument The document to use when parsing CSS properties. + * @param aFrames The JS value, provided as an optional IDL |object?| value, + * that is the keyframe list specification. + * @param aRv (out) Out-param to hold any error returned by this function. + * Must be initially empty. + * @return The set of processed keyframes. If an error occurs, aRv will be + * filled-in with the appropriate error code and an empty array will be + * returned. + */ + static nsTArray<Keyframe> + GetKeyframesFromObject(JSContext* aCx, + nsIDocument* aDocument, + JS::Handle<JSObject*> aFrames, + ErrorResult& aRv); + + /** + * Calculate the StyleAnimationValues of properties of each keyframe. + * This involves expanding shorthand properties into longhand properties, + * removing the duplicated properties for each keyframe, and creating an + * array of |property:computed value| pairs for each keyframe. + * + * These computed values are used *both* when computing the final set of + * per-property animation values (see GetAnimationPropertiesFromKeyframes) as + * well when applying paced spacing. By returning these values here, we allow + * the result to be re-used in both operations. + * + * @param aKeyframes The input keyframes. + * @param aElement The context element. + * @param aStyleContext The style context to use when computing values. + * @return The set of ComputedKeyframeValues. The length will be the same as + * aFrames. + */ + static nsTArray<ComputedKeyframeValues> + GetComputedKeyframeValues(const nsTArray<Keyframe>& aKeyframes, + dom::Element* aElement, + nsStyleContext* aStyleContext); + + /** + * Fills in the mComputedOffset member of each keyframe in the given array + * using the specified spacing mode. + * + * https://w3c.github.io/web-animations/#spacing-keyframes + * + * @param aKeyframes The set of keyframes to adjust. + * @param aSpacingMode The spacing mode to apply. + * @param aProperty The paced property. Only used when |aSpacingMode| is + * SpacingMode::paced. In all other cases it is ignored and hence may be + * any value, e.g. eCSSProperty_UNKNOWN. + * @param aComputedValues The set of computed keyframe values as returned by + * GetComputedKeyframeValues. Only used when |aSpacingMode| is + * SpacingMode::paced. In all other cases this parameter is unused and may + * be any value including an empty array. + * @param aStyleContext The style context used for calculating paced spacing + * on transform. + */ + static void ApplySpacing(nsTArray<Keyframe>& aKeyframes, + SpacingMode aSpacingMode, + nsCSSPropertyID aProperty, + nsTArray<ComputedKeyframeValues>& aComputedValues, + nsStyleContext* aStyleContext); + + /** + * Wrapper for ApplySpacing to simplify using distribute spacing. + * + * @param aKeyframes The set of keyframes to adjust. + */ + static void ApplyDistributeSpacing(nsTArray<Keyframe>& aKeyframes); + + /** + * Converts an array of Keyframe objects into an array of AnimationProperty + * objects. This involves creating an array of computed values for each + * longhand property and determining the offset and timing function to use + * for each value. + * + * @param aKeyframes The input keyframes. + * @param aComputedValues The computed keyframe values (as returned by + * GetComputedKeyframeValues) used to fill in the individual + * AnimationPropertySegment objects. Although these values could be + * calculated from |aKeyframes|, passing them in as a separate parameter + * allows the result of GetComputedKeyframeValues to be re-used both + * here and in ApplySpacing. + * @param aStyleContext The style context to calculate the style difference. + * @return The set of animation properties. If an error occurs, the returned + * array will be empty. + */ + static nsTArray<AnimationProperty> GetAnimationPropertiesFromKeyframes( + const nsTArray<Keyframe>& aKeyframes, + const nsTArray<ComputedKeyframeValues>& aComputedValues, + nsStyleContext* aStyleContext); + + /** + * Check if the property or, for shorthands, one or more of + * its subproperties, is animatable. + * + * @param aProperty The property to check. + * @return true if |aProperty| is animatable. + */ + static bool IsAnimatableProperty(nsCSSPropertyID aProperty); +}; + +} // namespace mozilla + +#endif // mozilla_KeyframeUtils_h diff --git a/dom/animation/PendingAnimationTracker.cpp b/dom/animation/PendingAnimationTracker.cpp new file mode 100644 index 000000000..a97814a7c --- /dev/null +++ b/dom/animation/PendingAnimationTracker.cpp @@ -0,0 +1,124 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* 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 "PendingAnimationTracker.h" + +#include "mozilla/dom/AnimationTimeline.h" +#include "nsIFrame.h" +#include "nsIPresShell.h" + +using namespace mozilla; + +namespace mozilla { + +NS_IMPL_CYCLE_COLLECTION(PendingAnimationTracker, + mPlayPendingSet, + mPausePendingSet, + mDocument) + +NS_IMPL_CYCLE_COLLECTION_ROOT_NATIVE(PendingAnimationTracker, AddRef) +NS_IMPL_CYCLE_COLLECTION_UNROOT_NATIVE(PendingAnimationTracker, Release) + +void +PendingAnimationTracker::AddPending(dom::Animation& aAnimation, + AnimationSet& aSet) +{ + aSet.PutEntry(&aAnimation); + + // Schedule a paint. Otherwise animations that don't trigger a paint by + // themselves (e.g. CSS animations with an empty keyframes rule) won't + // start until something else paints. + EnsurePaintIsScheduled(); +} + +void +PendingAnimationTracker::RemovePending(dom::Animation& aAnimation, + AnimationSet& aSet) +{ + aSet.RemoveEntry(&aAnimation); +} + +bool +PendingAnimationTracker::IsWaiting(const dom::Animation& aAnimation, + const AnimationSet& aSet) const +{ + return aSet.Contains(const_cast<dom::Animation*>(&aAnimation)); +} + +void +PendingAnimationTracker::TriggerPendingAnimationsOnNextTick(const TimeStamp& + aReadyTime) +{ + auto triggerAnimationsAtReadyTime = [aReadyTime](AnimationSet& aAnimationSet) + { + for (auto iter = aAnimationSet.Iter(); !iter.Done(); iter.Next()) { + dom::Animation* animation = iter.Get()->GetKey(); + dom::AnimationTimeline* timeline = animation->GetTimeline(); + + // If the animation does not have a timeline, just drop it from the map. + // The animation will detect that it is not being tracked and will trigger + // itself on the next tick where it has a timeline. + if (!timeline) { + iter.Remove(); + continue; + } + + // When the timeline's refresh driver is under test control, its values + // have no correspondance to wallclock times so we shouldn't try to + // convert aReadyTime (which is a wallclock time) to a timeline value. + // Instead, the animation will be started/paused when the refresh driver + // is next advanced since this will trigger a call to + // TriggerPendingAnimationsNow. + if (!timeline->TracksWallclockTime()) { + continue; + } + + Nullable<TimeDuration> readyTime = timeline->ToTimelineTime(aReadyTime); + animation->TriggerOnNextTick(readyTime); + + iter.Remove(); + } + }; + + triggerAnimationsAtReadyTime(mPlayPendingSet); + triggerAnimationsAtReadyTime(mPausePendingSet); +} + +void +PendingAnimationTracker::TriggerPendingAnimationsNow() +{ + auto triggerAndClearAnimations = [](AnimationSet& aAnimationSet) { + for (auto iter = aAnimationSet.Iter(); !iter.Done(); iter.Next()) { + iter.Get()->GetKey()->TriggerNow(); + } + aAnimationSet.Clear(); + }; + + triggerAndClearAnimations(mPlayPendingSet); + triggerAndClearAnimations(mPausePendingSet); +} + +void +PendingAnimationTracker::EnsurePaintIsScheduled() +{ + if (!mDocument) { + return; + } + + nsIPresShell* presShell = mDocument->GetShell(); + if (!presShell) { + return; + } + + nsIFrame* rootFrame = presShell->GetRootFrame(); + if (!rootFrame) { + return; + } + + rootFrame->SchedulePaint(); +} + +} // namespace mozilla diff --git a/dom/animation/PendingAnimationTracker.h b/dom/animation/PendingAnimationTracker.h new file mode 100644 index 000000000..8d638c73f --- /dev/null +++ b/dom/animation/PendingAnimationTracker.h @@ -0,0 +1,84 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* 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/. */ + +#ifndef mozilla_dom_PendingAnimationTracker_h +#define mozilla_dom_PendingAnimationTracker_h + +#include "mozilla/dom/Animation.h" +#include "nsCycleCollectionParticipant.h" +#include "nsIDocument.h" +#include "nsTHashtable.h" + +class nsIFrame; + +namespace mozilla { + +class PendingAnimationTracker final +{ +public: + explicit PendingAnimationTracker(nsIDocument* aDocument) + : mDocument(aDocument) + { } + + NS_INLINE_DECL_CYCLE_COLLECTING_NATIVE_REFCOUNTING(PendingAnimationTracker) + NS_DECL_CYCLE_COLLECTION_NATIVE_CLASS(PendingAnimationTracker) + + void AddPlayPending(dom::Animation& aAnimation) + { + MOZ_ASSERT(!IsWaitingToPause(aAnimation), + "Animation is already waiting to pause"); + AddPending(aAnimation, mPlayPendingSet); + } + void RemovePlayPending(dom::Animation& aAnimation) + { + RemovePending(aAnimation, mPlayPendingSet); + } + bool IsWaitingToPlay(const dom::Animation& aAnimation) const + { + return IsWaiting(aAnimation, mPlayPendingSet); + } + + void AddPausePending(dom::Animation& aAnimation) + { + MOZ_ASSERT(!IsWaitingToPlay(aAnimation), + "Animation is already waiting to play"); + AddPending(aAnimation, mPausePendingSet); + } + void RemovePausePending(dom::Animation& aAnimation) + { + RemovePending(aAnimation, mPausePendingSet); + } + bool IsWaitingToPause(const dom::Animation& aAnimation) const + { + return IsWaiting(aAnimation, mPausePendingSet); + } + + void TriggerPendingAnimationsOnNextTick(const TimeStamp& aReadyTime); + void TriggerPendingAnimationsNow(); + bool HasPendingAnimations() const { + return mPlayPendingSet.Count() > 0 || mPausePendingSet.Count() > 0; + } + +private: + ~PendingAnimationTracker() { } + + void EnsurePaintIsScheduled(); + + typedef nsTHashtable<nsRefPtrHashKey<dom::Animation>> AnimationSet; + + void AddPending(dom::Animation& aAnimation, AnimationSet& aSet); + void RemovePending(dom::Animation& aAnimation, AnimationSet& aSet); + bool IsWaiting(const dom::Animation& aAnimation, + const AnimationSet& aSet) const; + + AnimationSet mPlayPendingSet; + AnimationSet mPausePendingSet; + nsCOMPtr<nsIDocument> mDocument; +}; + +} // namespace mozilla + +#endif // mozilla_dom_PendingAnimationTracker_h diff --git a/dom/animation/PseudoElementHashEntry.h b/dom/animation/PseudoElementHashEntry.h new file mode 100644 index 000000000..63f9c60c8 --- /dev/null +++ b/dom/animation/PseudoElementHashEntry.h @@ -0,0 +1,58 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* 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/. */ + +#ifndef mozilla_PseudoElementHashEntry_h +#define mozilla_PseudoElementHashEntry_h + +#include "mozilla/dom/Element.h" +#include "mozilla/AnimationTarget.h" +#include "mozilla/HashFunctions.h" +#include "PLDHashTable.h" + +namespace mozilla { + +// A hash entry that uses a RefPtr<dom::Element>, CSSPseudoElementType pair +class PseudoElementHashEntry : public PLDHashEntryHdr +{ +public: + typedef NonOwningAnimationTarget KeyType; + typedef const NonOwningAnimationTarget* KeyTypePointer; + + explicit PseudoElementHashEntry(KeyTypePointer aKey) + : mElement(aKey->mElement) + , mPseudoType(aKey->mPseudoType) { } + explicit PseudoElementHashEntry(const PseudoElementHashEntry& aCopy)=default; + + ~PseudoElementHashEntry() = default; + + KeyType GetKey() const { return { mElement, mPseudoType }; } + bool KeyEquals(KeyTypePointer aKey) const + { + return mElement == aKey->mElement && + mPseudoType == aKey->mPseudoType; + } + + static KeyTypePointer KeyToPointer(KeyType& aKey) { return &aKey; } + static PLDHashNumber HashKey(KeyTypePointer aKey) + { + if (!aKey) + return 0; + + // Convert the scoped enum into an integer while adding it to hash. + // Note: CSSPseudoElementTypeBase is uint8_t, so we convert it into + // uint8_t directly to avoid including the header. + return mozilla::HashGeneric(aKey->mElement, + static_cast<uint8_t>(aKey->mPseudoType)); + } + enum { ALLOW_MEMMOVE = true }; + + RefPtr<dom::Element> mElement; + CSSPseudoElementType mPseudoType; +}; + +} // namespace mozilla + +#endif // mozilla_PseudoElementHashEntry_h diff --git a/dom/animation/TimingParams.cpp b/dom/animation/TimingParams.cpp new file mode 100644 index 000000000..db61c8447 --- /dev/null +++ b/dom/animation/TimingParams.cpp @@ -0,0 +1,182 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* 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 "mozilla/TimingParams.h" + +#include "mozilla/AnimationUtils.h" +#include "mozilla/dom/AnimatableBinding.h" +#include "mozilla/dom/KeyframeAnimationOptionsBinding.h" +#include "mozilla/dom/KeyframeEffectBinding.h" +#include "nsCSSParser.h" // For nsCSSParser +#include "nsIDocument.h" +#include "nsRuleNode.h" + +namespace mozilla { + +template <class OptionsType> +static const dom::AnimationEffectTimingProperties& +GetTimingProperties(const OptionsType& aOptions); + +template <> +/* static */ const dom::AnimationEffectTimingProperties& +GetTimingProperties( + const dom::UnrestrictedDoubleOrKeyframeEffectOptions& aOptions) +{ + MOZ_ASSERT(aOptions.IsKeyframeEffectOptions()); + return aOptions.GetAsKeyframeEffectOptions(); +} + +template <> +/* static */ const dom::AnimationEffectTimingProperties& +GetTimingProperties( + const dom::UnrestrictedDoubleOrKeyframeAnimationOptions& aOptions) +{ + MOZ_ASSERT(aOptions.IsKeyframeAnimationOptions()); + return aOptions.GetAsKeyframeAnimationOptions(); +} + +template <class OptionsType> +static TimingParams +TimingParamsFromOptionsUnion(const OptionsType& aOptions, + nsIDocument* aDocument, + ErrorResult& aRv) +{ + TimingParams result; + if (aOptions.IsUnrestrictedDouble()) { + double durationInMs = aOptions.GetAsUnrestrictedDouble(); + if (durationInMs >= 0) { + result.mDuration.emplace( + StickyTimeDuration::FromMilliseconds(durationInMs)); + } else { + aRv.Throw(NS_ERROR_DOM_TYPE_ERR); + } + } else { + const dom::AnimationEffectTimingProperties& timing = + GetTimingProperties(aOptions); + + Maybe<StickyTimeDuration> duration = + TimingParams::ParseDuration(timing.mDuration, aRv); + if (aRv.Failed()) { + return result; + } + TimingParams::ValidateIterationStart(timing.mIterationStart, aRv); + if (aRv.Failed()) { + return result; + } + TimingParams::ValidateIterations(timing.mIterations, aRv); + if (aRv.Failed()) { + return result; + } + Maybe<ComputedTimingFunction> easing = + TimingParams::ParseEasing(timing.mEasing, aDocument, aRv); + if (aRv.Failed()) { + return result; + } + + result.mDuration = duration; + result.mDelay = TimeDuration::FromMilliseconds(timing.mDelay); + result.mEndDelay = TimeDuration::FromMilliseconds(timing.mEndDelay); + result.mIterations = timing.mIterations; + result.mIterationStart = timing.mIterationStart; + result.mDirection = timing.mDirection; + result.mFill = timing.mFill; + result.mFunction = easing; + } + return result; +} + +/* static */ TimingParams +TimingParams::FromOptionsUnion( + const dom::UnrestrictedDoubleOrKeyframeEffectOptions& aOptions, + nsIDocument* aDocument, + ErrorResult& aRv) +{ + return TimingParamsFromOptionsUnion(aOptions, aDocument, aRv); +} + +/* static */ TimingParams +TimingParams::FromOptionsUnion( + const dom::UnrestrictedDoubleOrKeyframeAnimationOptions& aOptions, + nsIDocument* aDocument, + ErrorResult& aRv) +{ + return TimingParamsFromOptionsUnion(aOptions, aDocument, aRv); +} + +/* static */ Maybe<ComputedTimingFunction> +TimingParams::ParseEasing(const nsAString& aEasing, + nsIDocument* aDocument, + ErrorResult& aRv) +{ + MOZ_ASSERT(aDocument); + + nsCSSValue value; + nsCSSParser parser; + parser.ParseLonghandProperty(eCSSProperty_animation_timing_function, + aEasing, + aDocument->GetDocumentURI(), + aDocument->GetDocumentURI(), + aDocument->NodePrincipal(), + value); + + switch (value.GetUnit()) { + case eCSSUnit_List: { + const nsCSSValueList* list = value.GetListValue(); + if (list->mNext) { + // don't support a list of timing functions + break; + } + switch (list->mValue.GetUnit()) { + case eCSSUnit_Enumerated: + // Return Nothing() if "linear" is passed in. + if (list->mValue.GetIntValue() == + NS_STYLE_TRANSITION_TIMING_FUNCTION_LINEAR) { + return Nothing(); + } + MOZ_FALLTHROUGH; + case eCSSUnit_Cubic_Bezier: + case eCSSUnit_Steps: { + nsTimingFunction timingFunction; + nsRuleNode::ComputeTimingFunction(list->mValue, timingFunction); + ComputedTimingFunction computedTimingFunction; + computedTimingFunction.Init(timingFunction); + return Some(computedTimingFunction); + } + default: + MOZ_ASSERT_UNREACHABLE("unexpected animation-timing-function list " + "item unit"); + break; + } + break; + } + case eCSSUnit_Inherit: + case eCSSUnit_Initial: + case eCSSUnit_Unset: + case eCSSUnit_TokenStream: + case eCSSUnit_Null: + break; + default: + MOZ_ASSERT_UNREACHABLE("unexpected animation-timing-function unit"); + break; + } + + aRv.ThrowTypeError<dom::MSG_INVALID_EASING_ERROR>(aEasing); + return Nothing(); +} + +bool +TimingParams::operator==(const TimingParams& aOther) const +{ + return mDuration == aOther.mDuration && + mDelay == aOther.mDelay && + mIterations == aOther.mIterations && + mIterationStart == aOther.mIterationStart && + mDirection == aOther.mDirection && + mFill == aOther.mFill && + mFunction == aOther.mFunction; +} + +} // namespace mozilla diff --git a/dom/animation/TimingParams.h b/dom/animation/TimingParams.h new file mode 100644 index 000000000..bfecee90c --- /dev/null +++ b/dom/animation/TimingParams.h @@ -0,0 +1,130 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* 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/. */ + +#ifndef mozilla_TimingParams_h +#define mozilla_TimingParams_h + +#include "nsStringFwd.h" +#include "mozilla/dom/Nullable.h" +#include "mozilla/dom/UnionTypes.h" // For OwningUnrestrictedDoubleOrString +#include "mozilla/ComputedTimingFunction.h" +#include "mozilla/Maybe.h" +#include "mozilla/StickyTimeDuration.h" +#include "mozilla/TimeStamp.h" // for TimeDuration + +// X11 has a #define for None +#ifdef None +#undef None +#endif +#include "mozilla/dom/AnimationEffectReadOnlyBinding.h" // for FillMode + // and PlaybackDirection + +class nsIDocument; + +namespace mozilla { + +namespace dom { +class UnrestrictedDoubleOrKeyframeEffectOptions; +class UnrestrictedDoubleOrKeyframeAnimationOptions; +} + +struct TimingParams +{ + TimingParams() = default; + + static TimingParams FromOptionsUnion( + const dom::UnrestrictedDoubleOrKeyframeEffectOptions& aOptions, + nsIDocument* aDocument, ErrorResult& aRv); + static TimingParams FromOptionsUnion( + const dom::UnrestrictedDoubleOrKeyframeAnimationOptions& aOptions, + nsIDocument* aDocument, ErrorResult& aRv); + + // Range-checks and validates an UnrestrictedDoubleOrString or + // OwningUnrestrictedDoubleOrString object and converts to a + // StickyTimeDuration value or Nothing() if aDuration is "auto". + // Caller must check aRv.Failed(). + template <class DoubleOrString> + static Maybe<StickyTimeDuration> ParseDuration(DoubleOrString& aDuration, + ErrorResult& aRv) + { + Maybe<StickyTimeDuration> result; + if (aDuration.IsUnrestrictedDouble()) { + double durationInMs = aDuration.GetAsUnrestrictedDouble(); + if (durationInMs >= 0) { + result.emplace(StickyTimeDuration::FromMilliseconds(durationInMs)); + } else { + aRv.ThrowTypeError<dom::MSG_ENFORCE_RANGE_OUT_OF_RANGE>( + NS_LITERAL_STRING("duration")); + } + } else if (!aDuration.GetAsString().EqualsLiteral("auto")) { + aRv.ThrowTypeError<dom::MSG_INVALID_DURATION_ERROR>( + aDuration.GetAsString()); + } + return result; + } + + static void ValidateIterationStart(double aIterationStart, + ErrorResult& aRv) + { + if (aIterationStart < 0) { + aRv.ThrowTypeError<dom::MSG_ENFORCE_RANGE_OUT_OF_RANGE>( + NS_LITERAL_STRING("iterationStart")); + } + } + + static void ValidateIterations(double aIterations, ErrorResult& aRv) + { + if (IsNaN(aIterations) || aIterations < 0) { + aRv.ThrowTypeError<dom::MSG_ENFORCE_RANGE_OUT_OF_RANGE>( + NS_LITERAL_STRING("iterations")); + } + } + + static Maybe<ComputedTimingFunction> ParseEasing(const nsAString& aEasing, + nsIDocument* aDocument, + ErrorResult& aRv); + + // mDuration.isNothing() represents the "auto" value + Maybe<StickyTimeDuration> mDuration; + TimeDuration mDelay; // Initializes to zero + TimeDuration mEndDelay; + double mIterations = 1.0; // Can be NaN, negative, +/-Infinity + double mIterationStart = 0.0; + dom::PlaybackDirection mDirection = dom::PlaybackDirection::Normal; + dom::FillMode mFill = dom::FillMode::Auto; + Maybe<ComputedTimingFunction> mFunction; + + // Return the duration of the active interval calculated by duration and + // iteration count. + StickyTimeDuration ActiveDuration() const + { + // If either the iteration duration or iteration count is zero, + // Web Animations says that the active duration is zero. This is to + // ensure that the result is defined when the other argument is Infinity. + static const StickyTimeDuration zeroDuration; + if (!mDuration || *mDuration == zeroDuration || mIterations == 0.0) { + return zeroDuration; + } + + return mDuration->MultDouble(mIterations); + } + + StickyTimeDuration EndTime() const + { + return std::max(mDelay + ActiveDuration() + mEndDelay, + StickyTimeDuration()); + } + + bool operator==(const TimingParams& aOther) const; + bool operator!=(const TimingParams& aOther) const + { + return !(*this == aOther); + } +}; + +} // namespace mozilla + +#endif // mozilla_TimingParams_h diff --git a/dom/animation/moz.build b/dom/animation/moz.build new file mode 100644 index 000000000..bd8c93707 --- /dev/null +++ b/dom/animation/moz.build @@ -0,0 +1,67 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +MOCHITEST_MANIFESTS += ['test/mochitest.ini'] +MOCHITEST_CHROME_MANIFESTS += ['test/chrome.ini'] + +EXPORTS.mozilla.dom += [ + 'Animation.h', + 'AnimationEffectReadOnly.h', + 'AnimationEffectTiming.h', + 'AnimationEffectTimingReadOnly.h', + 'AnimationTimeline.h', + 'CSSPseudoElement.h', + 'DocumentTimeline.h', + 'KeyframeEffect.h', + 'KeyframeEffectReadOnly.h', +] + +EXPORTS.mozilla += [ + 'AnimationComparator.h', + 'AnimationPerformanceWarning.h', + 'AnimationTarget.h', + 'AnimationUtils.h', + 'AnimValuesStyleRule.h', + 'ComputedTiming.h', + 'ComputedTimingFunction.h', + 'EffectCompositor.h', + 'EffectSet.h', + 'KeyframeEffectParams.h', + 'KeyframeUtils.h', + 'PendingAnimationTracker.h', + 'PseudoElementHashEntry.h', + 'TimingParams.h', +] + +UNIFIED_SOURCES += [ + 'Animation.cpp', + 'AnimationEffectReadOnly.cpp', + 'AnimationEffectTiming.cpp', + 'AnimationEffectTimingReadOnly.cpp', + 'AnimationPerformanceWarning.cpp', + 'AnimationTimeline.cpp', + 'AnimationUtils.cpp', + 'AnimValuesStyleRule.cpp', + 'ComputedTimingFunction.cpp', + 'CSSPseudoElement.cpp', + 'DocumentTimeline.cpp', + 'EffectCompositor.cpp', + 'EffectSet.cpp', + 'KeyframeEffect.cpp', + 'KeyframeEffectParams.cpp', + 'KeyframeEffectReadOnly.cpp', + 'KeyframeUtils.cpp', + 'PendingAnimationTracker.cpp', + 'TimingParams.cpp', +] + +LOCAL_INCLUDES += [ + '/dom/base', + '/layout/base', + '/layout/style', +] + +FINAL_LIBRARY = 'xul' diff --git a/dom/animation/test/chrome.ini b/dom/animation/test/chrome.ini new file mode 100644 index 000000000..9026bcbd2 --- /dev/null +++ b/dom/animation/test/chrome.ini @@ -0,0 +1,17 @@ +[DEFAULT] +support-files = + testcommon.js + ../../imptests/testharness.js + ../../imptests/testharnessreport.js + !/dom/animation/test/chrome/file_animate_xrays.html + +[chrome/test_animate_xrays.html] +# file_animate_xrays.html needs to go in mochitest.ini since it is served +# over HTTP +[chrome/test_animation_observers.html] +[chrome/test_animation_performance_warning.html] +[chrome/test_animation_properties.html] +[chrome/test_generated_content_getAnimations.html] +[chrome/test_observers_for_sync_api.html] +[chrome/test_restyles.html] +[chrome/test_running_on_compositor.html] diff --git a/dom/animation/test/chrome/file_animate_xrays.html b/dom/animation/test/chrome/file_animate_xrays.html new file mode 100644 index 000000000..8a68fc548 --- /dev/null +++ b/dom/animation/test/chrome/file_animate_xrays.html @@ -0,0 +1,19 @@ +<!doctype html> +<html> +<head> +<meta charset=utf-8> +<script> +Element.prototype.animate = function() { + throw 'Called animate() as defined in content document'; +} +// Bug 1211783: Use KeyframeEffect (not KeyframeEffectReadOnly) here +for (var obj of [KeyframeEffectReadOnly, Animation]) { + obj = function() { + throw 'Called overridden ' + String(obj) + ' constructor'; + }; +} +</script> +<body> +<div id="target"></div> +</body> +</html> diff --git a/dom/animation/test/chrome/test_animate_xrays.html b/dom/animation/test/chrome/test_animate_xrays.html new file mode 100644 index 000000000..56b981bf1 --- /dev/null +++ b/dom/animation/test/chrome/test_animate_xrays.html @@ -0,0 +1,31 @@ +<!doctype html> +<head> +<meta charset=utf-8> +<script type="application/javascript" src="../testharness.js"></script> +<script type="application/javascript" src="../testharnessreport.js"></script> +<script type="application/javascript" src="../testcommon.js"></script> +</head> +<body> +<a href="https://bugzilla.mozilla.org/show_bug.cgi?id=1045994" + target="_blank">Mozilla Bug 1045994</a> +<div id="log"></div> +<iframe id="iframe" + src="http://example.org/tests/dom/animation/test/chrome/file_animate_xrays.html"></iframe> +<script> +'use strict'; + +var win = document.getElementById('iframe').contentWindow; + +async_test(function(t) { + window.addEventListener('load', t.step_func(function() { + var target = win.document.getElementById('target'); + var anim = target.animate({ opacity: [ 0, 1 ] }, 100 * MS_PER_SEC); + // In the x-ray case, the frames object will be given an opaque wrapper + // so it won't be possible to fetch any frames from it. + assert_equals(anim.effect.getKeyframes().length, 0); + t.done(); + })); +}, 'Calling animate() across x-rays'); + +</script> +</body> diff --git a/dom/animation/test/chrome/test_animation_observers.html b/dom/animation/test/chrome/test_animation_observers.html new file mode 100644 index 000000000..237128e04 --- /dev/null +++ b/dom/animation/test/chrome/test_animation_observers.html @@ -0,0 +1,1177 @@ +<!DOCTYPE html> +<meta charset=utf-8> +<title>Test chrome-only MutationObserver animation notifications</title> +<script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> +<script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> +<script src="../testcommon.js"></script> +<link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +<style> +@keyframes anim { + to { transform: translate(100px); } +} +@keyframes anotherAnim { + to { transform: translate(0px); } +} +#target { + width: 100px; + height: 100px; + background-color: yellow; + line-height: 16px; +} +</style> +<div id=container><div id=target></div></div> +<script> +var div = document.getElementById("target"); +var gRecords = []; +var gObserver = new MutationObserver(function(newRecords) { + gRecords.push(...newRecords); +}); + +// Asynchronous testing framework based on layout/style/test/animation_utils.js. + +var gTests = []; +var gCurrentTestName; + +function addAsyncAnimTest(aName, aOptions, aTestGenerator) { + aTestGenerator.testName = aName; + aTestGenerator.options = aOptions || {}; + gTests.push(aTestGenerator); +} + +function runAsyncTest(aTestGenerator) { + return waitForFrame().then(function() { + var generator; + + function step(arg) { + var next; + try { + next = generator.next(arg); + } catch (e) { + return Promise.reject(e); + } + if (next.done) { + return Promise.resolve(next.value); + } else { + return Promise.resolve(next.value).then(step); + } + } + + var subtree = aTestGenerator.options.subtree; + + gCurrentTestName = aTestGenerator.testName; + if (subtree) { + gCurrentTestName += ":subtree"; + } + + gRecords = []; + gObserver.disconnect(); + gObserver.observe(aTestGenerator.options.observe, + { animations: true, subtree: subtree}); + + generator = aTestGenerator(); + return step(); + }); +}; + +function runAllAsyncTests() { + return gTests.reduce(function(sequence, test) { + return sequence.then(() => runAsyncTest(test)); + }, Promise.resolve()); +} + +// Wrap is and ok with versions that prepend the current sub-test name +// to the assertion description. +var old_is = is, old_ok = ok; +is = function(a, b, message) { + if (gCurrentTestName && message) { + message = `[${gCurrentTestName}] ${message}`; + } + old_is(a, b, message); +} +ok = function(a, message) { + if (gCurrentTestName && message) { + message = `[${gCurrentTestName}] ${message}`; + } + old_ok(a, message); +} + +// Adds an event listener and returns a Promise that is resolved when the +// event listener is called. +function await_event(aElement, aEventName) { + return new Promise(function(aResolve) { + function listener(aEvent) { + aElement.removeEventListener(aEventName, listener); + aResolve(); + } + aElement.addEventListener(aEventName, listener, false); + }); +} + +function assert_record_list(actual, expected, desc, index, listName) { + is(actual.length, expected.length, `${desc} - record[${index}].${listName} length`); + if (actual.length != expected.length) { + return; + } + for (var i = 0; i < actual.length; i++) { + ok(actual.indexOf(expected[i]) != -1, + `${desc} - record[${index}].${listName} contains expected Animation`); + } +} + +function assert_records(expected, desc) { + var records = gRecords; + gRecords = []; + is(records.length, expected.length, `${desc} - number of records`); + if (records.length != expected.length) { + return; + } + for (var i = 0; i < records.length; i++) { + assert_record_list(records[i].addedAnimations, expected[i].added, desc, i, "addedAnimations"); + assert_record_list(records[i].changedAnimations, expected[i].changed, desc, i, "changedAnimations"); + assert_record_list(records[i].removedAnimations, expected[i].removed, desc, i, "removedAnimations"); + } +} + +// -- Tests ------------------------------------------------------------------ + +// We run all tests first targeting the div and observing the div, then again +// targeting the div and observing its parent while using the subtree:true +// MutationObserver option. + +[ + { observe: div, target: div, subtree: false }, + { observe: div.parentNode, target: div, subtree: true }, +].forEach(function(aOptions) { + + var e = aOptions.target; + + // Test that starting a single transition that completes normally + // dispatches an added notification and then a removed notification. + addAsyncAnimTest("single_transition", aOptions, function*() { + // Start a transition. + e.style = "transition: background-color 100s; background-color: lime;"; + + // Register for the end of the transition. + var transitionEnd = await_event(e, "transitionend"); + + // The transition should cause the creation of a single Animation. + var animations = e.getAnimations(); + is(animations.length, 1, "getAnimations().length after transition start"); + + // Wait for the single MutationRecord for the Animation addition to + // be delivered. + yield waitForFrame(); + assert_records([{ added: animations, changed: [], removed: [] }], + "records after transition start"); + + // Advance until near the end of the transition, then wait for it to finish. + animations[0].currentTime = 99900; + yield transitionEnd; + + // After the transition has finished, the Animation should disappear. + is(e.getAnimations().length, 0, + "getAnimations().length after transition end"); + + // Wait for the change MutationRecord for seeking the Animation to be + // delivered, followed by the the removal MutationRecord. + yield waitForFrame(); + assert_records([{ added: [], changed: animations, removed: [] }, + { added: [], changed: [], removed: animations }], + "records after transition end"); + + e.style = ""; + }); + + // Test that starting a single transition that is cancelled by resetting + // the transition-property property dispatches an added notification and + // then a removed notification. + addAsyncAnimTest("single_transition_cancelled_property", aOptions, function*() { + // Start a long transition. + e.style = "transition: background-color 100s; background-color: lime;"; + + // The transition should cause the creation of a single Animation. + var animations = e.getAnimations(); + is(animations.length, 1, "getAnimations().length after transition start"); + + // Wait for the single MutationRecord for the Animation addition to + // be delivered. + yield waitForFrame(); + assert_records([{ added: animations, changed: [], removed: [] }], + "records after transition start"); + + // Cancel the transition by setting transition-property. + e.style.transitionProperty = "none"; + + // Wait for the single MutationRecord for the Animation removal to + // be delivered. + yield waitForFrame(); + assert_records([{ added: [], changed: [], removed: animations }], + "records after transition end"); + + e.style = ""; + }); + + // Test that starting a single transition that is cancelled by setting + // style to the currently animated value dispatches an added + // notification and then a removed notification. + addAsyncAnimTest("single_transition_cancelled_value", aOptions, function*() { + // Start a long transition with a predictable value. + e.style = "transition: background-color 100s steps(2, end) -51s; background-color: lime;"; + + // The transition should cause the creation of a single Animation. + var animations = e.getAnimations(); + is(animations.length, 1, "getAnimations().length after transition start"); + + // Wait for the single MutationRecord for the Animation addition to + // be delivered. + yield waitForFrame(); + assert_records([{ added: animations, changed: [], removed: [] }], + "records after transition start"); + + // Cancel the transition by setting the current animation value. + var value = "rgb(128, 255, 0)"; + is(getComputedStyle(e).backgroundColor, value, "half-way transition value"); + e.style.backgroundColor = value; + + // Wait for the single MutationRecord for the Animation removal to + // be delivered. + yield waitForFrame(); + assert_records([{ added: [], changed: [], removed: animations }], + "records after transition end"); + + e.style = ""; + }); + + // Test that starting a single transition that is cancelled by setting + // style to a non-interpolable value dispatches an added notification + // and then a removed notification. + addAsyncAnimTest("single_transition_cancelled_noninterpolable", aOptions, function*() { + // Start a long transition. + e.style = "transition: line-height 100s; line-height: 100px;"; + + // The transition should cause the creation of a single Animation. + var animations = e.getAnimations(); + is(animations.length, 1, "getAnimations().length after transition start"); + + // Wait for the single MutationRecord for the Animation addition to + // be delivered. + yield waitForFrame(); + assert_records([{ added: animations, changed: [], removed: [] }], + "records after transition start"); + + // Cancel the transition by setting line-height to a non-interpolable value. + e.style.lineHeight = "normal"; + + // Wait for the single MutationRecord for the Animation removal to + // be delivered. + yield waitForFrame(); + assert_records([{ added: [], changed: [], removed: animations }], + "records after transition end"); + + e.style = ""; + }); + + // Test that starting a single transition and then reversing it + // dispatches an added notification, then a simultaneous removed and + // added notification, then a removed notification once finished. + addAsyncAnimTest("single_transition_reversed", aOptions, function*() { + // Start a long transition. + e.style = "transition: background-color 100s step-start; background-color: lime;"; + + // The transition should cause the creation of a single Animation. + var animations = e.getAnimations(); + is(animations.length, 1, "getAnimations().length after transition start"); + + var firstAnimation = animations[0]; + + // Wait for the single MutationRecord for the Animation addition to + // be delivered. + yield waitForFrame(); + assert_records([{ added: [firstAnimation], changed: [], removed: [] }], + "records after transition start"); + + // Wait for the Animation to be playing, then seek well into + // the transition. + yield firstAnimation.ready; + firstAnimation.currentTime = 50 * MS_PER_SEC; + + // Reverse the transition by setting the background-color back to its + // original value. + e.style.backgroundColor = "yellow"; + + // The reversal should cause the creation of a new Animation. + animations = e.getAnimations(); + is(animations.length, 1, "getAnimations().length after transition reversal"); + + var secondAnimation = animations[0]; + + ok(firstAnimation != secondAnimation, + "second Animation should be different from the first"); + + // Wait for the change Mutation record from seeking the first animation + // to be delivered, followed by a subsequent MutationRecord for the removal + // of the original Animation and the addition of the new Animation. + yield waitForFrame(); + assert_records([{ added: [], changed: [firstAnimation], removed: [] }, + { added: [secondAnimation], changed: [], + removed: [firstAnimation] }], + "records after transition reversal"); + + // Cancel the transition. + e.style.transitionProperty = "none"; + + // Wait for the single MutationRecord for the Animation removal to + // be delivered. + yield waitForFrame(); + assert_records([{ added: [], changed: [], removed: [secondAnimation] }], + "records after transition end"); + + e.style = ""; + }); + + // Test that multiple transitions starting and ending on an element + // at the same time get batched up into a single MutationRecord. + addAsyncAnimTest("multiple_transitions", aOptions, function*() { + // Start three long transitions. + e.style = "transition-duration: 100s; " + + "transition-property: color, background-color, line-height; " + + "color: blue; background-color: lime; line-height: 24px;"; + + // The transitions should cause the creation of three Animations. + var animations = e.getAnimations(); + is(animations.length, 3, "getAnimations().length after transition starts"); + + // Wait for the single MutationRecord for the Animation additions to + // be delivered. + yield waitForFrame(); + assert_records([{ added: animations, changed: [], removed: [] }], + "records after transition starts"); + + // Wait for the Animations to get going. + yield animations[0].ready; + is(animations.filter(p => p.playState == "running").length, 3, + "number of running Animations"); + + // Seek well into each animation. + animations.forEach(p => p.currentTime = 50 * MS_PER_SEC); + + // Prepare the set of expected change MutationRecords, one for each + // animation that was seeked. + var seekRecords = animations.map( + p => ({ added: [], changed: [p], removed: [] }) + ); + + // Cancel one of the transitions by setting transition-property. + e.style.transitionProperty = "background-color, line-height"; + + var colorAnimation = animations.filter(p => p.playState != "running"); + var otherAnimations = animations.filter(p => p.playState == "running"); + + is(colorAnimation.length, 1, + "number of non-running Animations after cancelling one"); + is(otherAnimations.length, 2, + "number of running Animations after cancelling one"); + + // Wait for the MutationRecords to be delivered: one for each animation + // that was seeked, followed by one for the removal of the color animation. + yield waitForFrame(); + assert_records(seekRecords.concat( + { added: [], changed: [], removed: colorAnimation }), + "records after color transition end"); + + // Cancel the remaining transitions. + e.style.transitionProperty = "none"; + + // Wait for the MutationRecord for the other two Animation + // removals to be delivered. + yield waitForFrame(); + assert_records([{ added: [], changed: [], removed: otherAnimations }], + "records after other transition ends"); + + e.style = ""; + }); + + // Test that starting a single animation that completes normally + // dispatches an added notification and then a removed notification. + addAsyncAnimTest("single_animation", aOptions, function*() { + // Start an animation. + e.style = "animation: anim 100s;"; + + // Register for the end of the animation. + var animationEnd = await_event(e, "animationend"); + + // The animation should cause the creation of a single Animation. + var animations = e.getAnimations(); + is(animations.length, 1, "getAnimations().length after animation start"); + + // Wait for the single MutationRecord for the Animation addition to + // be delivered. + yield waitForFrame(); + assert_records([{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + // Advance until near the end of the animation, then wait for it to finish. + animations[0].currentTime = 99900; + yield animationEnd; + + // After the animation has finished, the Animation should disappear. + is(e.getAnimations().length, 0, + "getAnimations().length after animation end"); + + // Wait for the change MutationRecord from seeking the Animation to + // be delivered, followed by a further MutationRecord for the Animation + // removal. + yield waitForFrame(); + assert_records([{ added: [], changed: animations, removed: [] }, + { added: [], changed: [], removed: animations }], + "records after animation end"); + + e.style = ""; + }); + + // Test that starting a single animation that is cancelled by resetting + // the animation-name property dispatches an added notification and + // then a removed notification. + addAsyncAnimTest("single_animation_cancelled_name", aOptions, function*() { + // Start a long animation. + e.style = "animation: anim 100s;"; + + // The animation should cause the creation of a single Animation. + var animations = e.getAnimations(); + is(animations.length, 1, "getAnimations().length after animation start"); + + // Wait for the single MutationRecord for the Animation addition to + // be delivered. + yield waitForFrame(); + assert_records([{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + // Cancel the animation by setting animation-name. + e.style.animationName = "none"; + + // Wait for the single MutationRecord for the Animation removal to + // be delivered. + yield waitForFrame(); + assert_records([{ added: [], changed: [], removed: animations }], + "records after animation end"); + + e.style = ""; + }); + + // Test that starting a single animation that is cancelled by updating + // the animation-duration property dispatches an added notification and + // then a removed notification. + addAsyncAnimTest("single_animation_cancelled_duration", aOptions, function*() { + // Start a long animation. + e.style = "animation: anim 100s;"; + + // The animation should cause the creation of a single Animation. + var animations = e.getAnimations(); + is(animations.length, 1, "getAnimations().length after animation start"); + + // Wait for the single MutationRecord for the Animation addition to + // be delivered. + yield waitForFrame(); + assert_records([{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + // Advance the animation by a second. + animations[0].currentTime += 1 * MS_PER_SEC; + + // Cancel the animation by setting animation-duration to a value less + // than a second. + e.style.animationDuration = "0.1s"; + + // Wait for the change MutationRecord from seeking the Animation to + // be delivered, followed by a further MutationRecord for the Animation + // removal. + yield waitForFrame(); + assert_records([{ added: [], changed: animations, removed: [] }, + { added: [], changed: [], removed: animations }], + "records after animation end"); + + e.style = ""; + }); + + // Test that starting a single animation that is cancelled by updating + // the animation-delay property dispatches an added notification and + // then a removed notification. + addAsyncAnimTest("single_animation_cancelled_delay", aOptions, function*() { + // Start a long animation. + e.style = "animation: anim 100s;"; + + // The animation should cause the creation of a single Animation. + var animations = e.getAnimations(); + is(animations.length, 1, "getAnimations().length after animation start"); + + // Wait for the single MutationRecord for the Animation addition to + // be delivered. + yield waitForFrame(); + assert_records([{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + // Cancel the animation by setting animation-delay. + e.style.animationDelay = "-200s"; + + // Wait for the single MutationRecord for the Animation removal to + // be delivered. + yield waitForFrame(); + assert_records([{ added: [], changed: [], removed: animations }], + "records after animation end"); + + e.style = ""; + }); + + // Test that starting a single animation that is cancelled by updating + // the animation-fill-mode property dispatches an added notification and + // then a removed notification. + addAsyncAnimTest("single_animation_cancelled_fill", aOptions, function*() { + // Start a short, filled animation. + e.style = "animation: anim 100s forwards;"; + + // Register for the end of the animation. + var animationEnd = await_event(e, "animationend"); + + // The animation should cause the creation of a single Animation. + var animations = e.getAnimations(); + is(animations.length, 1, "getAnimations().length after animation start"); + + // Wait for the single MutationRecord for the Animation addition to + // be delivered. + yield waitForFrame(); + assert_records([{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + // Advance until near the end of the animation, then wait for it to finish. + animations[0].currentTime = 99900; + yield animationEnd; + + // The only MutationRecord at this point should be the change from + // seeking the Animation. + assert_records([{ added: [], changed: animations, removed: [] }], + "records after animation starts filling"); + + // Cancel the animation by setting animation-fill-mode. + e.style.animationFillMode = "none"; + + // Wait for the single MutationRecord for the Animation removal to + // be delivered. + yield waitForFrame(); + assert_records([{ added: [], changed: [], removed: animations }], + "records after animation end"); + + e.style = ""; + }); + + // Test that starting a single animation that is cancelled by updating + // the animation-iteration-count property dispatches an added notification + // and then a removed notification. + addAsyncAnimTest("single_animation_cancelled_iteration_count", + aOptions, function*() { + // Start a short, repeated animation. + e.style = "animation: anim 0.5s infinite;"; + + // The animation should cause the creation of a single Animation. + var animations = e.getAnimations(); + is(animations.length, 1, "getAnimations().length after animation start"); + + // Wait for the single MutationRecord for the Animation addition to + // be delivered. + yield waitForFrame(); + assert_records([{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + // Advance the animation until we are past the first iteration. + animations[0].currentTime += 1 * MS_PER_SEC; + + // The only MutationRecord at this point should be the change from + // seeking the Animation. + yield waitForFrame(); + assert_records([{ added: [], changed: animations, removed: [] }], + "records after seeking animations"); + + // Cancel the animation by setting animation-iteration-count. + e.style.animationIterationCount = "1"; + + // Wait for the single MutationRecord for the Animation removal to + // be delivered. + yield waitForFrame(); + assert_records([{ added: [], changed: [], removed: animations }], + "records after animation end"); + + e.style = ""; + }); + + // Test that updating an animation property dispatches a changed notification. + [ + { name: "duration", prop: "animationDuration", val: "200s" }, + { name: "timing", prop: "animationTimingFunction", val: "linear" }, + { name: "iteration", prop: "animationIterationCount", val: "2" }, + { name: "direction", prop: "animationDirection", val: "reverse" }, + { name: "state", prop: "animationPlayState", val: "paused" }, + { name: "delay", prop: "animationDelay", val: "-1s" }, + { name: "fill", prop: "animationFillMode", val: "both" }, + ].forEach(function(aChangeTest) { + addAsyncAnimTest(`single_animation_change_${aChangeTest.name}`, aOptions, function*() { + // Start a long animation. + e.style = "animation: anim 100s;"; + + // The animation should cause the creation of a single Animation. + var animations = e.getAnimations(); + is(animations.length, 1, "getAnimations().length after animation start"); + + // Wait for the single MutationRecord for the Animation addition to + // be delivered. + yield waitForFrame(); + assert_records([{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + // Change a property of the animation such that it keeps running. + e.style[aChangeTest.prop] = aChangeTest.val; + + // Wait for the single MutationRecord for the Animation change to + // be delivered. + yield waitForFrame(); + assert_records([{ added: [], changed: animations, removed: [] }], + "records after animation change"); + + // Cancel the animation. + e.style.animationName = "none"; + + // Wait for the addition, change and removal MutationRecords to be delivered. + yield waitForFrame(); + assert_records([{ added: [], changed: [], removed: animations }], + "records after animation end"); + + e.style = ""; + }); + }); + + // Test that calling finish() on a paused (but otherwise finished) animation + // dispatches a changed notification. + addAsyncAnimTest("finish_from_pause", aOptions, function*() { + // Start a long animation + e.style = "animation: anim 100s forwards"; + + // The animation should cause the creation of a single Animation. + var animations = e.getAnimations(); + is(animations.length, 1, "getAnimations().length after animation start"); + + // Wait for the single MutationRecord for the Animation addition to + // be delivered. + yield waitForFrame(); + assert_records([{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + // Wait until the animation is playing. + yield animations[0].ready; + + // Finish and pause. + animations[0].finish(); + animations[0].pause(); + + // Wait for the pause to complete. + yield animations[0].ready; + is(animations[0].playState, "paused", + "playState after finishing and pausing"); + + // We should have two MutationRecords for the Animation changes: + // one for the finish, one for the pause. + assert_records([{ added: [], changed: animations, removed: [] }, + { added: [], changed: animations, removed: [] }], + "records after finish() and pause()"); + + // Call finish() again. + animations[0].finish(); + is(animations[0].playState, "finished", + "playState after finishing from paused state"); + + // Wait for the single MutationRecord for the Animation change to + // be delivered. Even though the currentTime does not change, the + // playState will change. + yield waitForFrame(); + assert_records([{ added: [], changed: animations, removed: [] }], + "records after finish() and pause()"); + + // Cancel the animation. + e.style = ""; + + // Wait for the single removal notification. + yield waitForFrame(); + assert_records([{ added: [], changed: [], removed: animations }], + "records after animation end"); + }); + + // Test that calling finish() on a pause-pending (but otherwise finished) + // animation dispatches a changed notification. + addAsyncAnimTest("finish_from_pause_pending", aOptions, function*() { + // Start a long animation + e.style = "animation: anim 100s forwards"; + + // The animation should cause the creation of a single Animation. + var animations = e.getAnimations(); + is(animations.length, 1, "getAnimations().length after animation start"); + + // Wait for the single MutationRecord for the Animation addition to + // be delivered. + yield waitForFrame(); + assert_records([{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + // Wait until the animation is playing. + yield animations[0].ready; + + // Finish and pause. + animations[0].finish(); + animations[0].pause(); + is(animations[0].playState, "pending", + "playState after finishing and calling pause()"); + + // Call finish() again to abort the pause + animations[0].finish(); + is(animations[0].playState, "finished", + "playState after finishing and calling pause()"); + + // Wait for three MutationRecords for the Animation changes to + // be delivered: one for each finish(), pause(), finish() operation. + yield waitForFrame(); + assert_records([{ added: [], changed: animations, removed: [] }, + { added: [], changed: animations, removed: [] }, + { added: [], changed: animations, removed: [] }], + "records after finish(), pause(), finish()"); + + // Cancel the animation. + e.style = ""; + + // Wait for the single removal notification. + yield waitForFrame(); + assert_records([{ added: [], changed: [], removed: animations }], + "records after animation end"); + }); + + // Test that calling play() on a paused Animation dispatches a changed + // notification. + addAsyncAnimTest("play", aOptions, function*() { + // Start a long, paused animation + e.style = "animation: anim 100s paused"; + + // The animation should cause the creation of a single Animation. + var animations = e.getAnimations(); + is(animations.length, 1, "getAnimations().length after animation start"); + + // Wait for the single MutationRecord for the Animation addition to + // be delivered. + yield waitForFrame(); + assert_records([{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + // Wait until the animation is ready + yield animations[0].ready; + + // Play + animations[0].play(); + + // Wait for the single MutationRecord for the Animation change to + // be delivered. + yield animations[0].ready; + assert_records([{ added: [], changed: animations, removed: [] }], + "records after play()"); + + // Redundant play + animations[0].play(); + + // Wait to ensure no change is dispatched + yield waitForFrame(); + assert_records([], "records after redundant play()"); + + // Cancel the animation. + e.style = ""; + + // Wait for the single removal notification. + yield waitForFrame(); + assert_records([{ added: [], changed: [], removed: animations }], + "records after animation end"); + }); + + // Test that calling play() on a finished Animation that fills forwards + // dispatches a changed notification. + addAsyncAnimTest("play_filling_forwards", aOptions, function*() { + // Start a long animation with a forwards fill + e.style = "animation: anim 100s forwards"; + + // The animation should cause the creation of a single Animation. + var animations = e.getAnimations(); + is(animations.length, 1, "getAnimations().length after animation start"); + + // Wait for the single MutationRecord for the Animation addition to + // be delivered. + yield waitForFrame(); + assert_records([{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + // Wait until the animation is ready + yield animations[0].ready; + + // Seek to the end + animations[0].finish(); + + // Wait for the single MutationRecord for the Animation change to + // be delivered. + yield waitForFrame(); + assert_records([{ added: [], changed: animations, removed: [] }], + "records after finish()"); + + // Since we are filling forwards, calling play() should produce a + // change record since the animation remains relevant. + animations[0].play(); + + // Wait for the single MutationRecord for the Animation change to + // be delivered. + yield waitForFrame(); + assert_records([{ added: [], changed: animations, removed: [] }], + "records after play()"); + + // Cancel the animation. + e.style = ""; + + // Wait for the single removal notification. + yield waitForFrame(); + assert_records([{ added: [], changed: [], removed: animations }], + "records after animation end"); + }); + + // Test that calling play() on a finished Animation that does *not* fill + // forwards dispatches an addition notification. + addAsyncAnimTest("play_after_finish", aOptions, function*() { + // Start a long animation + e.style = "animation: anim 100s"; + + // The animation should cause the creation of a single Animation. + var animations = e.getAnimations(); + is(animations.length, 1, "getAnimations().length after animation start"); + + // Wait for the single MutationRecord for the Animation addition to + // be delivered. + yield waitForFrame(); + assert_records([{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + // Wait until the animation is ready + yield animations[0].ready; + + // Seek to the end + animations[0].finish(); + + // Wait for the single MutationRecord for the Animation removal to + // be delivered. + yield waitForFrame(); + assert_records([{ added: [], changed: [], removed: animations }], + "records after finish()"); + + // Since we are *not* filling forwards, calling play() is equivalent + // to creating a new animation since it becomes relevant again. + animations[0].play(); + + // Wait for the single MutationRecord for the Animation addition to + // be delivered. + yield waitForFrame(); + assert_records([{ added: animations, changed: [], removed: [] }], + "records after play()"); + + // Cancel the animation. + e.style = ""; + + // Wait for the single removal notification. + yield waitForFrame(); + assert_records([{ added: [], changed: [], removed: animations }], + "records after animation end"); + }); + + // Test that calling pause() on an Animation dispatches a changed + // notification. + addAsyncAnimTest("pause", aOptions, function*() { + // Start a long animation + e.style = "animation: anim 100s"; + + // The animation should cause the creation of a single Animation. + var animations = e.getAnimations(); + is(animations.length, 1, "getAnimations().length after animation start"); + + // Wait for the single MutationRecord for the Animation addition to + // be delivered. + yield waitForFrame(); + assert_records([{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + // Wait until the animation is ready + yield animations[0].ready; + + // Pause + animations[0].pause(); + + // Wait for the single MutationRecord for the Animation change to + // be delivered. + yield animations[0].ready; + assert_records([{ added: [], changed: animations, removed: [] }], + "records after pause()"); + + // Redundant pause + animations[0].pause(); + + // Wait to ensure no change is dispatched + yield animations[0].ready; + assert_records([], "records after redundant pause()"); + + // Cancel the animation. + e.style = ""; + + // Wait for the single removal notification. + yield waitForFrame(); + assert_records([{ added: [], changed: [], removed: animations }], + "records after animation end"); + }); + + // Test that calling pause() on an Animation that is pause-pending + // does not dispatch an additional changed notification. + addAsyncAnimTest("pause_while_pause_pending", aOptions, function*() { + // Start a long animation + e.style = "animation: anim 100s"; + + // The animation should cause the creation of a single Animation. + var animations = e.getAnimations(); + is(animations.length, 1, "getAnimations().length after animation start"); + + // Wait for the single MutationRecord for the Animation addition to + // be delivered. + yield waitForFrame(); + assert_records([{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + // Wait until the animation is ready + yield animations[0].ready; + + // Pause + animations[0].pause(); + + // We are now pause-pending, but pause again + animations[0].pause(); + + // We should only get a single change record + yield animations[0].ready; + assert_records([{ added: [], changed: animations, removed: [] }], + "records after pause()"); + + // Cancel the animation. + e.style = ""; + + // Wait for the single removal notification. + yield waitForFrame(); + assert_records([{ added: [], changed: [], removed: animations }], + "records after animation end"); + }); + + // Test that calling play() on an Animation that is pause-pending + // dispatches a changed notification. + addAsyncAnimTest("aborted_pause", aOptions, function*() { + // Start a long animation + e.style = "animation: anim 100s"; + + // The animation should cause the creation of a single Animation. + var animations = e.getAnimations(); + is(animations.length, 1, "getAnimations().length after animation start"); + + // Wait for the single MutationRecord for the Animation addition to + // be delivered. + yield waitForFrame(); + assert_records([{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + // Wait until the animation is ready + yield animations[0].ready; + + // Pause + animations[0].pause(); + + // We are now pause-pending. If we play() now, we will abort the pause + animations[0].play(); + + // We should get two change records + yield animations[0].ready; + assert_records([{ added: [], changed: animations, removed: [] }, + { added: [], changed: animations, removed: [] }], + "records after aborting a pause()"); + + // Cancel the animation. + e.style = ""; + + // Wait for the single removal notification. + yield waitForFrame(); + assert_records([{ added: [], changed: [], removed: animations }], + "records after animation end"); + }); + + // Test that a non-cancelling change to an animation followed immediately by a + // cancelling change will only send an animation removal notification. + addAsyncAnimTest("coalesce_change_cancel", aOptions, function*() { + // Start a long animation. + e.style = "animation: anim 100s;"; + + // The animation should cause the creation of a single Animation. + var animations = e.getAnimations(); + is(animations.length, 1, "getAnimations().length after animation start"); + + // Wait for the single MutationRecord for the Animation addition to + // be delivered. + yield waitForFrame(); + assert_records([{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + // Update the animation's delay such that it is still running. + e.style.animationDelay = "-1s"; + + // Then cancel the animation by updating its duration. + e.style.animationDuration = "0.5s"; + + // We should get a single removal notification. + yield waitForFrame(); + assert_records([{ added: [], changed: [], removed: animations }], + "records after animation end"); + + e.style = ""; + }); + +}); + +addAsyncAnimTest("tree_ordering", { observe: div, subtree: true }, function*() { + // Add style for pseudo elements + var extraStyle = document.createElement('style'); + document.head.appendChild(extraStyle); + var sheet = extraStyle.sheet; + var rules = { ".before::before": "animation: anim 100s;", + ".after::after" : "animation: anim 100s, anim 100s;" }; + for (var selector in rules) { + sheet.insertRule(selector + '{' + rules[selector] + '}', + sheet.cssRules.length); + } + + // Create a tree with two children: + // + // div + // (::before) + // (::after) + // / \ + // childA childB(::before) + var childA = document.createElement("div"); + var childB = document.createElement("div"); + + div.appendChild(childA); + div.appendChild(childB); + + // Start an animation on each (using order: childB, div, childA) + // + // We include multiple animations on some nodes so that we can test batching + // works as expected later in this test. + childB.style = "animation: anim 100s"; + div.style = "animation: anim 100s, anim 100s, anim 100s"; + childA.style = "animation: anim 100s, anim 100s"; + + // Start animations targeting to pseudo element of div and childB. + childB.classList.add("before"); + div.classList.add("after"); + div.classList.add("before"); + + // Check all animations we have in this document + var docAnims = document.getAnimations(); + is(docAnims.length, 10, "total animations"); + + var divAnimations = div.getAnimations(); + var childAAnimations = childA.getAnimations(); + var childBAnimations = childB.getAnimations(); + var divBeforeAnimations = + [ for (x of docAnims) if (x.effect.target.parentElement == div && + x.effect.target.type == "::before") x ]; + var divAfterAnimations = + [ for (x of docAnims) if (x.effect.target.parentElement == div && + x.effect.target.type == "::after") x ]; + var childBPseudoAnimations = + [ for (x of docAnims) if (x.effect.target.parentElement == childB) x ]; + + // The order in which we get the corresponding records is currently + // based on the order we visit these nodes when updating styles. + // + // That is because we don't do any document-level batching of animation + // mutation records when we flush styles. We may introduce that in the + // future but for now all we are interested in testing here is that the order + // these records are dispatched is consistent between runs. + // + // We currently expect to get records in order div::after, childA, childB, + // childB::before, div, div::before + yield waitForFrame(); + assert_records([{ added: divAfterAnimations, changed: [], removed: [] }, + { added: childAAnimations, changed: [], removed: [] }, + { added: childBAnimations, changed: [], removed: [] }, + { added: childBPseudoAnimations, changed: [], removed: [] }, + { added: divAnimations, changed: [], removed: [] }, + { added: divBeforeAnimations, changed: [], removed: [] }], + "records after simultaneous animation start"); + + // The one case where we *do* currently perform document-level (or actually + // timeline-level) batching is when animations are updated from a refresh + // driver tick. In particular, this means that when animations finish + // naturally the removed records should be dispatched according to the + // position of the elements in the tree. + + // First, flatten the set of animations. we put the animations targeting to + // pseudo elements last. (Actually, we don't care the order in the list.) + var animations = [ ...divAnimations, + ...childAAnimations, + ...childBAnimations, + ...divBeforeAnimations, + ...divAfterAnimations, + ...childBPseudoAnimations ]; + + // Fast-forward to *just* before the end of the animation. + animations.forEach(animation => animation.currentTime = 99999); + + // Prepare the set of expected change MutationRecords, one for each + // animation that was seeked. + var seekRecords = animations.map( + p => ({ added: [], changed: [p], removed: [] }) + ); + + yield await_event(div, "animationend"); + + // After the changed notifications, which will be dispatched in the order that + // the animations were seeked, we should get removal MutationRecords in order + // (div, div::before, div::after), childA, (childB, childB::before). + // Note: The animations targeting to the pseudo element are appended after + // the animations of its parent element. + divAnimations = [ ...divAnimations, + ...divBeforeAnimations, + ...divAfterAnimations ]; + childBAnimations = [ ...childBAnimations, ...childBPseudoAnimations ]; + assert_records(seekRecords.concat( + { added: [], changed: [], removed: divAnimations }, + { added: [], changed: [], removed: childAAnimations }, + { added: [], changed: [], removed: childBAnimations }), + "records after finishing"); + + // Clean up + div.classList.remove("before"); + div.classList.remove("after"); + div.style = ""; + childA.remove(); + childB.remove(); + extraStyle.remove(); +}); + +// Run the tests. +SimpleTest.requestLongerTimeout(2); +SimpleTest.waitForExplicitFinish(); + +runAllAsyncTests().then(function() { + SimpleTest.finish(); +}, function(aError) { + ok(false, "Something failed: " + aError); +}); +</script> diff --git a/dom/animation/test/chrome/test_animation_performance_warning.html b/dom/animation/test/chrome/test_animation_performance_warning.html new file mode 100644 index 000000000..a3bd63efc --- /dev/null +++ b/dom/animation/test/chrome/test_animation_performance_warning.html @@ -0,0 +1,957 @@ +<!doctype html> +<head> +<meta charset=utf-8> +<title>Bug 1196114 - Test metadata related to which animation properties + are running on the compositor</title> +<script type="application/javascript" src="../testharness.js"></script> +<script type="application/javascript" src="../testharnessreport.js"></script> +<script type="application/javascript" src="../testcommon.js"></script> +<style> +.compositable { + /* Element needs geometry to be eligible for layerization */ + width: 100px; + height: 100px; + background-color: white; +} +@keyframes fade { + from { opacity: 1 } + to { opacity: 0 } +} +</style> +</head> +<body> +<a href="https://bugzilla.mozilla.org/show_bug.cgi?id=1196114" + target="_blank">Mozilla Bug 1196114</a> +<div id="log"></div> +<script> +'use strict'; + +// This is used for obtaining localized strings. +var gStringBundle; + +W3CTest.runner.requestLongerTimeout(2); + +SpecialPowers.pushPrefEnv({ "set": [ + ["general.useragent.locale", "en-US"], + // Need to set devPixelsPerPx explicitly to gain + // consistent pixel values in warning messages + // regardless of platform DPIs. + ["layout.css.devPixelsPerPx", 1], + ] }, + start); + +function compare_property_state(a, b) { + if (a.property > b.property) { + return -1; + } else if (a.property < b.property) { + return 1; + } + if (a.runningOnCompositor != b.runningOnCompositor) { + return a.runningOnCompositor ? 1 : -1; + } + return a.warning > b.warning ? -1 : 1; +} + +function assert_animation_property_state_equals(actual, expected) { + assert_equals(actual.length, expected.length, 'Number of properties'); + + var sortedActual = actual.sort(compare_property_state); + var sortedExpected = expected.sort(compare_property_state); + + for (var i = 0; i < sortedActual.length; i++) { + assert_equals(sortedActual[i].property, + sortedExpected[i].property, + 'CSS property name should match'); + assert_equals(sortedActual[i].runningOnCompositor, + sortedExpected[i].runningOnCompositor, + 'runningOnCompositor property should match'); + if (sortedExpected[i].warning instanceof RegExp) { + assert_regexp_match(sortedActual[i].warning, + sortedExpected[i].warning, + 'warning message should match'); + } else if (sortedExpected[i].warning) { + assert_equals(sortedActual[i].warning, + gStringBundle.GetStringFromName(sortedExpected[i].warning), + 'warning message should match'); + } + } +} + +// Check that the animation is running on compositor and +// warning property is not set for the CSS property regardless +// expected values. +function assert_property_state_on_compositor(actual, expected) { + assert_equals(actual.length, expected.length); + + var sortedActual = actual.sort(compare_property_state); + var sortedExpected = expected.sort(compare_property_state); + + for (var i = 0; i < sortedActual.length; i++) { + assert_equals(sortedActual[i].property, + sortedExpected[i].property, + 'CSS property name should match'); + assert_true(sortedActual[i].runningOnCompositor, + 'runningOnCompositor property should be true on ' + + sortedActual[i].property); + assert_not_exists(sortedActual[i], 'warning', + 'warning property should not be set'); + } +} + +var gAnimationsTests = [ + { + desc: 'animations on compositor', + frames: { + opacity: [0, 1] + }, + expected: [ + { + property: 'opacity', + runningOnCompositor: true + } + ] + }, + { + desc: 'animations on main thread', + frames: { + backgroundColor: ['white', 'red'] + }, + expected: [ + { + property: 'background-color', + runningOnCompositor: false + } + ] + }, + { + desc: 'animations on both threads', + frames: { + backgroundColor: ['white', 'red'], + transform: ['translate(0px)', 'translate(100px)'] + }, + expected: [ + { + property: 'background-color', + runningOnCompositor: false + }, + { + property: 'transform', + runningOnCompositor: true + } + ] + }, + { + desc: 'two animation properties on compositor thread', + frames: { + opacity: [0, 1], + transform: ['translate(0px)', 'translate(100px)'] + }, + expected: [ + { + property: 'opacity', + runningOnCompositor: true + }, + { + property: 'transform', + runningOnCompositor: true + } + ] + }, + { + desc: 'opacity on compositor with animation of geometric properties', + frames: { + width: ['100px', '200px'], + opacity: [0, 1] + }, + expected: [ + { + property: 'width', + runningOnCompositor: false + }, + { + property: 'opacity', + runningOnCompositor: true + } + ] + }, +]; + +// Test cases that check results of adding/removing a 'width' property on the +// same animation object. +var gAnimationWithGeometricKeyframeTests = [ + { + desc: 'transform', + frames: { + transform: ['translate(0px)', 'translate(100px)'] + }, + expected: { + withoutGeometric: [ + { + property: 'transform', + runningOnCompositor: true + } + ], + withGeometric: [ + { + property: 'width', + runningOnCompositor: false + }, + { + property: 'transform', + runningOnCompositor: false, + warning: 'CompositorAnimationWarningTransformWithGeometricProperties' + } + ] + } + }, + { + desc: 'opacity and transform', + frames: { + opacity: [0, 1], + transform: ['translate(0px)', 'translate(100px)'] + }, + expected: { + withoutGeometric: [ + { + property: 'opacity', + runningOnCompositor: true + }, + { + property: 'transform', + runningOnCompositor: true + } + ], + withGeometric: [ + { + property: 'width', + runningOnCompositor: false + }, + { + property: 'opacity', + runningOnCompositor: true + }, + { + property: 'transform', + runningOnCompositor: false, + warning: 'CompositorAnimationWarningTransformWithGeometricProperties' + } + ] + } + }, +]; + +// Performance warning tests that set and clear a style property. +var gPerformanceWarningTestsStyle = [ + { + desc: 'preserve-3d transform', + frames: { + transform: ['translate(0px)', 'translate(100px)'] + }, + style: 'transform-style: preserve-3d', + expected: [ + { + property: 'transform', + runningOnCompositor: false, + warning: 'CompositorAnimationWarningTransformPreserve3D' + } + ] + }, + { + desc: 'transform with backface-visibility:hidden', + frames: { + transform: ['translate(0px)', 'translate(100px)'] + }, + style: 'backface-visibility: hidden;', + expected: [ + { + property: 'transform', + runningOnCompositor: false, + warning: 'CompositorAnimationWarningTransformBackfaceVisibilityHidden' + } + ] + }, + { + desc: 'opacity and transform with preserve-3d', + frames: { + opacity: [0, 1], + transform: ['translate(0px)', 'translate(100px)'] + }, + style: 'transform-style: preserve-3d', + expected: [ + { + property: 'opacity', + runningOnCompositor: true + }, + { + property: 'transform', + runningOnCompositor: false, + warning: 'CompositorAnimationWarningTransformPreserve3D' + } + ] + }, + { + desc: 'opacity and transform with backface-visibility:hidden', + frames: { + opacity: [0, 1], + transform: ['translate(0px)', 'translate(100px)'] + }, + style: 'backface-visibility: hidden;', + expected: [ + { + property: 'opacity', + runningOnCompositor: true + }, + { + property: 'transform', + runningOnCompositor: false, + warning: 'CompositorAnimationWarningTransformBackfaceVisibilityHidden' + } + ] + }, +]; + +// Performance warning tests that set and clear the id property +var gPerformanceWarningTestsId= [ + { + desc: 'moz-element referencing a transform', + frames: { + transform: ['translate(0px)', 'translate(100px)'] + }, + id: 'transformed', + createelement: 'width:100px; height:100px; background: -moz-element(#transformed)', + expected: [ + { + property: 'transform', + runningOnCompositor: false, + warning: 'CompositorAnimationWarningHasRenderingObserver' + } + ] + }, +]; + +var gMultipleAsyncAnimationsTests = [ + { + desc: 'opacity and transform with preserve-3d', + style: 'transform-style: preserve-3d', + animations: [ + { + frames: { + transform: ['translate(0px)', 'translate(100px)'] + }, + expected: [ + { + property: 'transform', + runningOnCompositor: false, + warning: 'CompositorAnimationWarningTransformPreserve3D' + } + ] + }, + { + frames: { + opacity: [0, 1] + }, + expected: [ + { + property: 'opacity', + runningOnCompositor: true, + } + ] + } + ], + }, + { + desc: 'opacity and transform with backface-visibility:hidden', + style: 'backface-visibility: hidden;', + animations: [ + { + frames: { + transform: ['translate(0px)', 'translate(100px)'] + }, + expected: [ + { + property: 'transform', + runningOnCompositor: false, + warning: 'CompositorAnimationWarningTransformBackfaceVisibilityHidden' + } + ] + }, + { + frames: { + opacity: [0, 1] + }, + expected: [ + { + property: 'opacity', + runningOnCompositor: true, + } + ] + } + ], + }, +]; + +// Test cases that check results of adding/removing a 'width' keyframe on the +// same animation object, where multiple animation objects belong to the same +// element. +// The 'width' property is added to animations[1]. +var gMultipleAsyncAnimationsWithGeometricKeyframeTests = [ + { + desc: 'transform and opacity with geometric keyframes', + animations: [ + { + frames: { + transform: ['translate(0px)', 'translate(100px)'] + }, + expected: { + withoutGeometric: [ + { + property: 'transform', + runningOnCompositor: true + } + ], + withGeometric: [ + { + property: 'transform', + runningOnCompositor: false, + warning: 'CompositorAnimationWarningTransformWithGeometricProperties' + } + ] + } + }, + { + frames: { + opacity: [0, 1] + }, + expected: { + withoutGeometric: [ + { + property: 'opacity', + runningOnCompositor: true, + } + ], + withGeometric: [ + { + property: 'width', + runningOnCompositor: false, + }, + { + property: 'opacity', + runningOnCompositor: true, + } + ] + } + } + ], + }, + { + desc: 'opacity and transform with geometric keyframes', + animations: [ + { + frames: { + opacity: [0, 1] + }, + expected: { + withoutGeometric: [ + { + property: 'opacity', + runningOnCompositor: true, + } + ], + withGeometric: [ + { + property: 'opacity', + runningOnCompositor: true, + } + ] + } + }, + { + frames: { + transform: ['translate(0px)', 'translate(100px)'] + }, + expected: { + withoutGeometric: [ + { + property: 'transform', + runningOnCompositor: true + } + ], + withGeometric: [ + { + property: 'width', + runningOnCompositor: false, + }, + { + property: 'transform', + runningOnCompositor: false, + warning: 'CompositorAnimationWarningTransformWithGeometricProperties' + } + ] + } + } + ] + }, +]; + +// Test cases that check results of adding/removing 'width' animation on the +// same element which has async animations. +var gMultipleAsyncAnimationsWithGeometricAnimationTests = [ + { + desc: 'transform', + animations: [ + { + frames: { + transform: ['translate(0px)', 'translate(100px)'] + }, + expected: [ + { + property: 'transform', + runningOnCompositor: false, + warning: 'CompositorAnimationWarningTransformWithGeometricProperties' + } + ] + }, + ] + }, + { + desc: 'opacity', + animations: [ + { + frames: { + opacity: [0, 1] + }, + expected: [ + { + property: 'opacity', + runningOnCompositor: true + } + ] + }, + ] + }, + { + desc: 'opacity and transform', + animations: [ + { + frames: { + transform: ['translate(0px)', 'translate(100px)'] + }, + expected: [ + { + property: 'transform', + runningOnCompositor: false, + warning: 'CompositorAnimationWarningTransformWithGeometricProperties' + } + ] + }, + { + frames: { + opacity: [0, 1] + }, + expected: [ + { + property: 'opacity', + runningOnCompositor: true, + } + ] + } + ], + }, +]; + +var gAnimationsOnTooSmallElementTests = [ + { + desc: 'opacity on too small element', + frames: { + opacity: [0, 1] + }, + style: { style: 'width: 8px; height: 8px; background-color: red;' + + // We need to set transform here to try creating an + // individual frame for this opacity element. + // Without this, this small element is created on the same + // nsIFrame of mochitest iframe, i.e. the document which are + // running this test, as a result the layer corresponding + // to the frame is sent to compositor. + 'transform: translateX(100px);' }, + expected: [ + { + property: 'opacity', + runningOnCompositor: false, + warning: /Animation cannot be run on the compositor because frame size \(8, 8\) is smaller than \(16, 16\)/ + } + ] + }, + { + desc: 'transform on too small element', + frames: { + transform: ['translate(0px)', 'translate(100px)'] + }, + style: { style: 'width: 8px; height: 8px; background-color: red;' }, + expected: [ + { + property: 'transform', + runningOnCompositor: false, + warning: /Animation cannot be run on the compositor because frame size \(8, 8\) is smaller than \(16, 16\)/ + } + ] + }, +]; + +function start() { + var bundleService = SpecialPowers.Cc['@mozilla.org/intl/stringbundle;1'] + .getService(SpecialPowers.Ci.nsIStringBundleService); + gStringBundle = bundleService + .createBundle("chrome://global/locale/layout_errors.properties"); + + gAnimationsTests.forEach(function(subtest) { + promise_test(function(t) { + var animation = addDivAndAnimate(t, + { class: 'compositable' }, + subtest.frames, 100 * MS_PER_SEC); + return animation.ready.then(function() { + assert_animation_property_state_equals( + animation.effect.getProperties(), + subtest.expected); + }); + }, subtest.desc); + }); + + gAnimationWithGeometricKeyframeTests.forEach(function(subtest) { + promise_test(function(t) { + var animation = addDivAndAnimate(t, + { class: 'compositable' }, + subtest.frames, 100 * MS_PER_SEC); + return animation.ready.then(function() { + // First, a transform animation is running on compositor. + assert_animation_property_state_equals( + animation.effect.getProperties(), + subtest.expected.withoutGeometric); + }).then(function() { + // Add a 'width' property. + var keyframes = animation.effect.getKeyframes(); + + keyframes[0].width = '100px'; + keyframes[1].width = '200px'; + + animation.effect.setKeyframes(keyframes); + return waitForFrame(); + }).then(function() { + // Now the transform animation is not running on compositor because of + // the 'width' property. + assert_animation_property_state_equals( + animation.effect.getProperties(), + subtest.expected.withGeometric); + }).then(function() { + // Remove the 'width' property. + var keyframes = animation.effect.getKeyframes(); + + delete keyframes[0].width; + delete keyframes[1].width; + + animation.effect.setKeyframes(keyframes); + return waitForFrame(); + }).then(function() { + // Finally, the transform animation is running on compositor. + assert_animation_property_state_equals( + animation.effect.getProperties(), + subtest.expected.withoutGeometric); + }); + }, 'An animation has: ' + subtest.desc); + }); + + gPerformanceWarningTestsStyle.forEach(function(subtest) { + promise_test(function(t) { + var animation = addDivAndAnimate(t, + { class: 'compositable' }, + subtest.frames, 100 * MS_PER_SEC); + return animation.ready.then(function() { + assert_property_state_on_compositor( + animation.effect.getProperties(), + subtest.expected); + animation.effect.target.style = subtest.style; + return waitForFrame(); + }).then(function() { + assert_animation_property_state_equals( + animation.effect.getProperties(), + subtest.expected); + animation.effect.target.style = ''; + return waitForFrame(); + }).then(function() { + assert_property_state_on_compositor( + animation.effect.getProperties(), + subtest.expected); + }); + }, subtest.desc); + }); + + gPerformanceWarningTestsId.forEach(function(subtest) { + promise_test(function(t) { + if (subtest.createelement) { + addDiv(t, { style: subtest.createelement }); + } + + var animation = addDivAndAnimate(t, + { class: 'compositable' }, + subtest.frames, 100 * MS_PER_SEC); + return animation.ready.then(function() { + assert_property_state_on_compositor( + animation.effect.getProperties(), + subtest.expected); + animation.effect.target.id = subtest.id; + return waitForFrame(); + }).then(function() { + assert_animation_property_state_equals( + animation.effect.getProperties(), + subtest.expected); + animation.effect.target.id = ''; + return waitForFrame(); + }).then(function() { + assert_property_state_on_compositor( + animation.effect.getProperties(), + subtest.expected); + }); + }, subtest.desc); + }); + + gMultipleAsyncAnimationsTests.forEach(function(subtest) { + promise_test(function(t) { + var div = addDiv(t, { class: 'compositable' }); + var animations = subtest.animations.map(function(anim) { + var animation = div.animate(anim.frames, 100 * MS_PER_SEC); + + // Bind expected values to animation object. + animation.expected = anim.expected; + return animation; + }); + return waitForAllAnimations(animations).then(function() { + animations.forEach(function(anim) { + assert_property_state_on_compositor( + anim.effect.getProperties(), + anim.expected); + }); + div.style = subtest.style; + return waitForFrame(); + }).then(function() { + animations.forEach(function(anim) { + assert_animation_property_state_equals( + anim.effect.getProperties(), + anim.expected); + }); + div.style = ''; + return waitForFrame(); + }).then(function() { + animations.forEach(function(anim) { + assert_property_state_on_compositor( + anim.effect.getProperties(), + anim.expected); + }); + }); + }, 'Multiple animations: ' + subtest.desc); + }); + + gMultipleAsyncAnimationsWithGeometricKeyframeTests.forEach(function(subtest) { + promise_test(function(t) { + var div = addDiv(t, { class: 'compositable' }); + var animations = subtest.animations.map(function(anim) { + var animation = div.animate(anim.frames, 100 * MS_PER_SEC); + + // Bind expected values to animation object. + animation.expected = anim.expected; + return animation; + }); + return waitForAllAnimations(animations).then(function() { + // First, all animations are running on compositor. + animations.forEach(function(anim) { + assert_animation_property_state_equals( + anim.effect.getProperties(), + anim.expected.withoutGeometric); + }); + }).then(function() { + // Add a 'width' property to animations[1]. + var keyframes = animations[1].effect.getKeyframes(); + + keyframes[0].width = '100px'; + keyframes[1].width = '200px'; + + animations[1].effect.setKeyframes(keyframes); + return waitForFrame(); + }).then(function() { + // Now the transform animation is not running on compositor because of + // the 'width' property. + animations.forEach(function(anim) { + assert_animation_property_state_equals( + anim.effect.getProperties(), + anim.expected.withGeometric); + }); + }).then(function() { + // Remove the 'width' property from animations[1]. + var keyframes = animations[1].effect.getKeyframes(); + + delete keyframes[0].width; + delete keyframes[1].width; + + animations[1].effect.setKeyframes(keyframes); + return waitForFrame(); + }).then(function() { + // Finally, all animations are running on compositor. + animations.forEach(function(anim) { + assert_animation_property_state_equals( + anim.effect.getProperties(), + anim.expected.withoutGeometric); + }); + }); + }, 'Multiple animations with geometric property: ' + subtest.desc); + }); + + gMultipleAsyncAnimationsWithGeometricAnimationTests.forEach(function(subtest) { + promise_test(function(t) { + var div = addDiv(t, { class: 'compositable' }); + var animations = subtest.animations.map(function(anim) { + var animation = div.animate(anim.frames, 100 * MS_PER_SEC); + + // Bind expected values to animation object. + animation.expected = anim.expected; + return animation; + }); + + var widthAnimation; + + return waitForAllAnimations(animations).then(function() { + animations.forEach(function(anim) { + assert_property_state_on_compositor( + anim.effect.getProperties(), + anim.expected); + }); + }).then(function() { + // Append 'width' animation on the same element. + widthAnimation = div.animate({ width: ['100px', '200px'] }, + 100 * MS_PER_SEC); + return waitForFrame(); + }).then(function() { + // Now transform animations are not running on compositor because of + // the 'width' animation. + animations.forEach(function(anim) { + assert_animation_property_state_equals( + anim.effect.getProperties(), + anim.expected); + }); + // Remove the 'width' animation. + widthAnimation.cancel(); + return waitForFrame(); + }).then(function() { + // Now all animations are running on compositor. + animations.forEach(function(anim) { + assert_property_state_on_compositor( + anim.effect.getProperties(), + anim.expected); + }); + }); + }, 'Multiple async animations and geometric animation: ' + subtest.desc); + }); + + gAnimationsOnTooSmallElementTests.forEach(function(subtest) { + promise_test(function(t) { + var div = addDiv(t, subtest.style); + var animation = div.animate(subtest.frames, 100 * MS_PER_SEC); + return animation.ready.then(function() { + assert_animation_property_state_equals( + animation.effect.getProperties(), + subtest.expected); + }); + }, subtest.desc); + }); + + promise_test(function(t) { + var animation = addDivAndAnimate(t, + { class: 'compositable' }, + { transform: [ 'translate(0px)', + 'translate(100px)'] }, + 100 * MS_PER_SEC); + return animation.ready.then(function() { + assert_animation_property_state_equals( + animation.effect.getProperties(), + [ { property: 'transform', runningOnCompositor: true } ]); + animation.effect.target.style = 'width: 10000px; height: 10000px'; + return waitForFrame(); + }).then(function() { + // viewport depends on test environment. + var expectedWarning = new RegExp( + "Animation cannot be run on the compositor because the frame size " + + "\\(10000, 10000\\) is bigger than the viewport \\(\\d+, \\d+\\) " + + "or the visual rectangle \\(10000, 10000\\) is larger than the " + + "maximum allowed value \\(\\d+\\)"); + assert_animation_property_state_equals( + animation.effect.getProperties(), + [ { + property: 'transform', + runningOnCompositor: false, + warning: expectedWarning + } ]); + animation.effect.target.style = 'width: 100px; height: 100px'; + return waitForFrame(); + }).then(function() { + // FIXME: Bug 1253164: the animation should get back on compositor. + assert_animation_property_state_equals( + animation.effect.getProperties(), + [ { property: 'transform', runningOnCompositor: false } ]); + }); + }, 'transform on too big element'); + + promise_test(function(t) { + var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('width', '100'); + svg.setAttribute('height', '100'); + var rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); + rect.setAttribute('width', '100'); + rect.setAttribute('height', '100'); + rect.setAttribute('fill', 'red'); + svg.appendChild(rect); + document.body.appendChild(svg); + t.add_cleanup(function() { + svg.remove(); + }); + + var animation = svg.animate( + { transform: ['translate(0px)', 'translate(100px)'] }, 100 * MS_PER_SEC); + return animation.ready.then(function() { + assert_animation_property_state_equals( + animation.effect.getProperties(), + [ { property: 'transform', runningOnCompositor: true } ]); + svg.setAttribute('transform', 'translate(10, 20)'); + return waitForFrame(); + }).then(function() { + assert_animation_property_state_equals( + animation.effect.getProperties(), + [ { + property: 'transform', + runningOnCompositor: false, + warning: 'CompositorAnimationWarningTransformSVG' + } ]); + svg.removeAttribute('transform'); + return waitForFrame(); + }).then(function() { + assert_animation_property_state_equals( + animation.effect.getProperties(), + [ { property: 'transform', runningOnCompositor: true } ]); + }); + }, 'transform of nsIFrame with SVG transform'); + + promise_test(function(t) { + var div = addDiv(t, { class: 'compositable', + style: 'animation: fade 100s' }); + var cssAnimation = div.getAnimations()[0]; + var scriptAnimation = div.animate({ opacity: [ 1, 0 ] }, 100 * MS_PER_SEC); + return scriptAnimation.ready.then(function() { + assert_animation_property_state_equals( + cssAnimation.effect.getProperties(), + [ { property: 'opacity', runningOnCompositor: true } ]); + assert_animation_property_state_equals( + scriptAnimation.effect.getProperties(), + [ { property: 'opacity', runningOnCompositor: true } ]); + }); + }, 'overridden animation'); +} + +</script> + +</body> diff --git a/dom/animation/test/chrome/test_animation_properties.html b/dom/animation/test/chrome/test_animation_properties.html new file mode 100644 index 000000000..534901306 --- /dev/null +++ b/dom/animation/test/chrome/test_animation_properties.html @@ -0,0 +1,993 @@ +<!doctype html> +<head> +<meta charset=utf-8> +<title>Bug 1254419 - Test the values returned by + KeyframeEffectReadOnly.getProperties()</title> +<script type="application/javascript" src="../testharness.js"></script> +<script type="application/javascript" src="../testharnessreport.js"></script> +<script type="application/javascript" src="../testcommon.js"></script> +</head> +<body> +<a href="https://bugzilla.mozilla.org/show_bug.cgi?id=1254419" + target="_blank">Mozilla Bug 1254419</a> +<div id="log"></div> +<style> + +:root { + --var-100px: 100px; + --var-100px-200px: 100px 200px; +} +div { + font-size: 10px; /* For calculating em-based units */ +} +</style> +<script> +'use strict'; + +function assert_properties_equal(actual, expected) { + assert_equals(actual.length, expected.length); + + var compareProperties = (a, b) => + a.property == b.property ? 0 : (a.property < b.property ? -1 : 1); + + var sortedActual = actual.sort(compareProperties); + var sortedExpected = expected.sort(compareProperties); + + // We want to serialize the values in the following form: + // + // { offset: 0, easing: linear, composite: replace, value: 5px }, ... + // + // So that we can just compare strings and, in the failure case, + // easily see where the differences lie. + var serializeMember = value => { + return typeof value === 'undefined' ? '<not set>' : value; + } + var serializeValues = values => + values.map(value => + '{ ' + + [ 'offset', 'value', 'easing', 'composite' ].map( + member => `${member}: ${serializeMember(value[member])}` + ).join(', ') + + ' }') + .join(', '); + + for (var i = 0; i < sortedActual.length; i++) { + assert_equals(sortedActual[i].property, + sortedExpected[i].property, + 'CSS property name should match'); + assert_equals(serializeValues(sortedActual[i].values), + serializeValues(sortedExpected[i].values), + `Values arrays do not match for ` + + `${sortedActual[i].property} property`); + } +} + +// Shorthand for constructing a value object +function value(offset, value, composite, easing) { + return { offset: offset, value: value, easing: easing, composite: composite }; +} + +var gTests = [ + + // --------------------------------------------------------------------- + // + // Tests for property-indexed specifications + // + // --------------------------------------------------------------------- + + { desc: 'a one-property two-value property-indexed specification', + frames: { left: ['10px', '20px'] }, + expected: [ { property: 'left', + values: [ value(0, '10px', 'replace', 'linear'), + value(1, '20px', 'replace') ] } ] + }, + { desc: 'a one-shorthand-property two-value property-indexed' + + ' specification', + frames: { margin: ['10px', '10px 20px 30px 40px'] }, + expected: [ { property: 'margin-top', + values: [ value(0, '10px', 'replace', 'linear'), + value(1, '10px', 'replace') ] }, + { property: 'margin-right', + values: [ value(0, '10px', 'replace', 'linear'), + value(1, '20px', 'replace') ] }, + { property: 'margin-bottom', + values: [ value(0, '10px', 'replace', 'linear'), + value(1, '30px', 'replace') ] }, + { property: 'margin-left', + values: [ value(0, '10px', 'replace', 'linear'), + value(1, '40px', 'replace') ] } ] + }, + { desc: 'a two-property (one shorthand and one of its longhand' + + ' components) two-value property-indexed specification', + frames: { marginTop: ['50px', '60px'], + margin: ['10px', '10px 20px 30px 40px'] }, + expected: [ { property: 'margin-top', + values: [ value(0, '50px', 'replace', 'linear'), + value(1, '60px', 'replace') ] }, + { property: 'margin-right', + values: [ value(0, '10px', 'replace', 'linear'), + value(1, '20px', 'replace') ] }, + { property: 'margin-bottom', + values: [ value(0, '10px', 'replace', 'linear'), + value(1, '30px', 'replace') ] }, + { property: 'margin-left', + values: [ value(0, '10px', 'replace', 'linear'), + value(1, '40px', 'replace') ] } ] + }, + { desc: 'a two-property property-indexed specification with different' + + ' numbers of values', + frames: { left: ['10px', '20px', '30px'], + top: ['40px', '50px'] }, + expected: [ { property: 'left', + values: [ value(0, '10px', 'replace', 'linear'), + value(0.5, '20px', 'replace', 'linear'), + value(1, '30px', 'replace') ] }, + { property: 'top', + values: [ value(0, '40px', 'replace', 'linear'), + value(1, '50px', 'replace') ] } ] + }, + { desc: 'a property-indexed specification with an invalid value', + frames: { left: ['10px', '20px', '30px', '40px', '50px'], + top: ['15px', '25px', 'invalid', '45px', '55px'] }, + expected: [ { property: 'left', + values: [ value(0, '10px', 'replace', 'linear'), + value(0.25, '20px', 'replace', 'linear'), + value(0.5, '30px', 'replace', 'linear'), + value(0.75, '40px', 'replace', 'linear'), + value(1, '50px', 'replace') ] }, + { property: 'top', + values: [ value(0, '15px', 'replace', 'linear'), + value(0.25, '25px', 'replace', 'linear'), + value(0.75, '45px', 'replace', 'linear'), + value(1, '55px', 'replace') ] } ] + }, + { desc: 'a one-property two-value property-indexed specification that' + + ' needs to stringify its values', + frames: { opacity: [0, 1] }, + expected: [ { property: 'opacity', + values: [ value(0, '0', 'replace', 'linear'), + value(1, '1', 'replace') ] } ] + }, + { desc: 'a property-indexed keyframe where a lesser shorthand precedes' + + ' a greater shorthand', + frames: { borderLeft: [ '1px solid rgb(1, 2, 3)', + '2px solid rgb(4, 5, 6)' ], + border: [ '3px dotted rgb(7, 8, 9)', + '4px dashed rgb(10, 11, 12)' ] }, + expected: [ { property: 'border-bottom-color', + values: [ value(0, 'rgb(7, 8, 9)', 'replace', 'linear'), + value(1, 'rgb(10, 11, 12)', 'replace') ] }, + { property: 'border-left-color', + values: [ value(0, 'rgb(1, 2, 3)', 'replace', 'linear'), + value(1, 'rgb(4, 5, 6)', 'replace') ] }, + { property: 'border-right-color', + values: [ value(0, 'rgb(7, 8, 9)', 'replace', 'linear'), + value(1, 'rgb(10, 11, 12)', 'replace') ] }, + { property: 'border-top-color', + values: [ value(0, 'rgb(7, 8, 9)', 'replace', 'linear'), + value(1, 'rgb(10, 11, 12)', 'replace') ] }, + { property: 'border-bottom-width', + values: [ value(0, '3px', 'replace', 'linear'), + value(1, '4px', 'replace') ] }, + { property: 'border-left-width', + values: [ value(0, '1px', 'replace', 'linear'), + value(1, '2px', 'replace') ] }, + { property: 'border-right-width', + values: [ value(0, '3px', 'replace', 'linear'), + value(1, '4px', 'replace') ] }, + { property: 'border-top-width', + values: [ value(0, '3px', 'replace', 'linear'), + value(1, '4px', 'replace') ] }, + { property: 'border-bottom-style', + values: [ value(0, 'dotted', 'replace', 'linear'), + value(1, 'dashed', 'replace') ] }, + { property: 'border-left-style', + values: [ value(0, 'solid', 'replace', 'linear'), + value(1, 'solid', 'replace') ] }, + { property: 'border-right-style', + values: [ value(0, 'dotted', 'replace', 'linear'), + value(1, 'dashed', 'replace') ] }, + { property: 'border-top-style', + values: [ value(0, 'dotted', 'replace', 'linear'), + value(1, 'dashed', 'replace') ] }, + { property: 'border-image-outset', + values: [ value(0, '0 0 0 0', 'replace', 'linear'), + value(1, '0 0 0 0', 'replace') ] }, + { property: 'border-image-repeat', + values: [ value(0, 'stretch stretch', 'replace', 'linear'), + value(1, 'stretch stretch', 'replace') ] }, + { property: 'border-image-slice', + values: [ value(0, '100% 100% 100% 100%', + 'replace', 'linear'), + value(1, '100% 100% 100% 100%', 'replace') ] }, + { property: 'border-image-source', + values: [ value(0, 'none', 'replace', 'linear'), + value(1, 'none', 'replace') ] }, + { property: 'border-image-width', + values: [ value(0, '1 1 1 1', 'replace', 'linear'), + value(1, '1 1 1 1', 'replace') ] }, + { property: '-moz-border-bottom-colors', + values: [ value(0, 'none', 'replace', 'linear'), + value(1, 'none', 'replace') ] }, + { property: '-moz-border-left-colors', + values: [ value(0, 'none', 'replace', 'linear'), + value(1, 'none', 'replace') ] }, + { property: '-moz-border-right-colors', + values: [ value(0, 'none', 'replace', 'linear'), + value(1, 'none', 'replace') ] }, + { property: '-moz-border-top-colors', + values: [ value(0, 'none', 'replace', 'linear'), + value(1, 'none', 'replace') ] } ] + }, + { desc: 'a property-indexed keyframe where a greater shorthand precedes' + + ' a lesser shorthand', + frames: { border: [ '3px dotted rgb(7, 8, 9)', + '4px dashed rgb(10, 11, 12)' ], + borderLeft: [ '1px solid rgb(1, 2, 3)', + '2px solid rgb(4, 5, 6)' ] }, + expected: [ { property: 'border-bottom-color', + values: [ value(0, 'rgb(7, 8, 9)', 'replace', 'linear'), + value(1, 'rgb(10, 11, 12)', 'replace') ] }, + { property: 'border-left-color', + values: [ value(0, 'rgb(1, 2, 3)', 'replace', 'linear'), + value(1, 'rgb(4, 5, 6)', 'replace') ] }, + { property: 'border-right-color', + values: [ value(0, 'rgb(7, 8, 9)', 'replace', 'linear'), + value(1, 'rgb(10, 11, 12)', 'replace') ] }, + { property: 'border-top-color', + values: [ value(0, 'rgb(7, 8, 9)', 'replace', 'linear'), + value(1, 'rgb(10, 11, 12)', 'replace') ] }, + { property: 'border-bottom-width', + values: [ value(0, '3px', 'replace', 'linear'), + value(1, '4px', 'replace') ] }, + { property: 'border-left-width', + values: [ value(0, '1px', 'replace', 'linear'), + value(1, '2px', 'replace') ] }, + { property: 'border-right-width', + values: [ value(0, '3px', 'replace', 'linear'), + value(1, '4px', 'replace') ] }, + { property: 'border-top-width', + values: [ value(0, '3px', 'replace', 'linear'), + value(1, '4px', 'replace') ] }, + { property: 'border-bottom-style', + values: [ value(0, 'dotted', 'replace', 'linear'), + value(1, 'dashed', 'replace') ] }, + { property: 'border-left-style', + values: [ value(0, 'solid', 'replace', 'linear'), + value(1, 'solid', 'replace') ] }, + { property: 'border-right-style', + values: [ value(0, 'dotted', 'replace', 'linear'), + value(1, 'dashed', 'replace') ] }, + { property: 'border-top-style', + values: [ value(0, 'dotted', 'replace', 'linear'), + value(1, 'dashed', 'replace') ] }, + { property: 'border-image-outset', + values: [ value(0, '0 0 0 0', 'replace', 'linear'), + value(1, '0 0 0 0', 'replace') ] }, + { property: 'border-image-repeat', + values: [ value(0, 'stretch stretch', 'replace', 'linear'), + value(1, 'stretch stretch', 'replace') ] }, + { property: 'border-image-slice', + values: [ value(0, '100% 100% 100% 100%', + 'replace', 'linear'), + value(1, '100% 100% 100% 100%', 'replace') ] }, + { property: 'border-image-source', + values: [ value(0, 'none', 'replace', 'linear'), + value(1, 'none', 'replace') ] }, + { property: 'border-image-width', + values: [ value(0, '1 1 1 1', 'replace', 'linear'), + value(1, '1 1 1 1', 'replace') ] }, + { property: '-moz-border-bottom-colors', + values: [ value(0, 'none', 'replace', 'linear'), + value(1, 'none', 'replace') ] }, + { property: '-moz-border-left-colors', + values: [ value(0, 'none', 'replace', 'linear'), + value(1, 'none', 'replace') ] }, + { property: '-moz-border-right-colors', + values: [ value(0, 'none', 'replace', 'linear'), + value(1, 'none', 'replace') ] }, + { property: '-moz-border-top-colors', + values: [ value(0, 'none', 'replace', 'linear'), + value(1, 'none', 'replace') ] } ] + }, + + // --------------------------------------------------------------------- + // + // Tests for keyframe sequences + // + // --------------------------------------------------------------------- + + { desc: 'a keyframe sequence specification with repeated values at' + + ' offset 0/1 with different easings', + frames: [ { offset: 0.0, left: '100px', easing: 'ease' }, + { offset: 0.0, left: '200px', easing: 'ease' }, + { offset: 0.5, left: '300px', easing: 'linear' }, + { offset: 1.0, left: '400px', easing: 'ease-out' }, + { offset: 1.0, left: '500px', easing: 'step-end' } ], + expected: [ { property: 'left', + values: [ value(0, '100px', 'replace'), + value(0, '200px', 'replace', 'ease'), + value(0.5, '300px', 'replace', 'linear'), + value(1, '400px', 'replace'), + value(1, '500px', 'replace') ] } ] + }, + { desc: 'a one-property two-keyframe sequence', + frames: [ { offset: 0, left: '10px' }, + { offset: 1, left: '20px' } ], + expected: [ { property: 'left', + values: [ value(0, '10px', 'replace', 'linear'), + value(1, '20px', 'replace') ] } ] + }, + { desc: 'a two-property two-keyframe sequence', + frames: [ { offset: 0, left: '10px', top: '30px' }, + { offset: 1, left: '20px', top: '40px' } ], + expected: [ { property: 'left', + values: [ value(0, '10px', 'replace', 'linear'), + value(1, '20px', 'replace') ] }, + { property: 'top', + values: [ value(0, '30px', 'replace', 'linear'), + value(1, '40px', 'replace') ] } ] + }, + { desc: 'a one shorthand property two-keyframe sequence', + frames: [ { offset: 0, margin: '10px' }, + { offset: 1, margin: '20px 30px 40px 50px' } ], + expected: [ { property: 'margin-top', + values: [ value(0, '10px', 'replace', 'linear'), + value(1, '20px', 'replace') ] }, + { property: 'margin-right', + values: [ value(0, '10px', 'replace', 'linear'), + value(1, '30px', 'replace') ] }, + { property: 'margin-bottom', + values: [ value(0, '10px', 'replace', 'linear'), + value(1, '40px', 'replace') ] }, + { property: 'margin-left', + values: [ value(0, '10px', 'replace', 'linear'), + value(1, '50px', 'replace') ] } ] + }, + { desc: 'a two-property (a shorthand and one of its component longhands)' + + ' two-keyframe sequence', + frames: [ { offset: 0, margin: '10px', marginTop: '20px' }, + { offset: 1, marginTop: '70px', + margin: '30px 40px 50px 60px' } ], + expected: [ { property: 'margin-top', + values: [ value(0, '20px', 'replace', 'linear'), + value(1, '70px', 'replace') ] }, + { property: 'margin-right', + values: [ value(0, '10px', 'replace', 'linear'), + value(1, '40px', 'replace') ] }, + { property: 'margin-bottom', + values: [ value(0, '10px', 'replace', 'linear'), + value(1, '50px', 'replace') ] }, + { property: 'margin-left', + values: [ value(0, '10px', 'replace', 'linear'), + value(1, '60px', 'replace') ] } ] + }, + { desc: 'a keyframe sequence with duplicate values for a given interior' + + ' offset', + frames: [ { offset: 0.0, left: '10px' }, + { offset: 0.5, left: '20px' }, + { offset: 0.5, left: '30px' }, + { offset: 0.5, left: '40px' }, + { offset: 1.0, left: '50px' } ], + expected: [ { property: 'left', + values: [ value(0, '10px', 'replace', 'linear'), + value(0.5, '20px', 'replace'), + value(0.5, '40px', 'replace', 'linear'), + value(1, '50px', 'replace') ] } ] + }, + { desc: 'a keyframe sequence with duplicate values for offsets 0 and 1', + frames: [ { offset: 0, left: '10px' }, + { offset: 0, left: '20px' }, + { offset: 0, left: '30px' }, + { offset: 1, left: '40px' }, + { offset: 1, left: '50px' }, + { offset: 1, left: '60px' } ], + expected: [ { property: 'left', + values: [ value(0, '10px', 'replace'), + value(0, '30px', 'replace', 'linear'), + value(1, '40px', 'replace'), + value(1, '60px', 'replace') ] } ] + }, + { desc: 'a two-property four-keyframe sequence', + frames: [ { offset: 0, left: '10px' }, + { offset: 0, top: '20px' }, + { offset: 1, top: '30px' }, + { offset: 1, left: '40px' } ], + expected: [ { property: 'left', + values: [ value(0, '10px', 'replace', 'linear'), + value(1, '40px', 'replace') ] }, + { property: 'top', + values: [ value(0, '20px', 'replace', 'linear'), + value(1, '30px', 'replace') ] } ] + }, + { desc: 'a one-property keyframe sequence with some omitted offsets', + frames: [ { offset: 0.00, left: '10px' }, + { offset: 0.25, left: '20px' }, + { left: '30px' }, + { left: '40px' }, + { offset: 1.00, left: '50px' } ], + expected: [ { property: 'left', + values: [ value(0, '10px', 'replace', 'linear'), + value(0.25, '20px', 'replace', 'linear'), + value(0.5, '30px', 'replace', 'linear'), + value(0.75, '40px', 'replace', 'linear'), + value(1, '50px', 'replace') ] } ] + }, + { desc: 'a two-property keyframe sequence with some omitted offsets', + frames: [ { offset: 0.00, left: '10px', top: '20px' }, + { offset: 0.25, left: '30px' }, + { left: '40px' }, + { left: '50px', top: '60px' }, + { offset: 1.00, left: '70px', top: '80px' } ], + expected: [ { property: 'left', + values: [ value(0, '10px', 'replace', 'linear'), + value(0.25, '30px', 'replace', 'linear'), + value(0.5, '40px', 'replace', 'linear'), + value(0.75, '50px', 'replace', 'linear'), + value(1, '70px', 'replace') ] }, + { property: 'top', + values: [ value(0, '20px', 'replace', 'linear'), + value(0.75, '60px', 'replace', 'linear'), + value(1, '80px', 'replace') ] } ] + }, + { desc: 'a one-property keyframe sequence with all omitted offsets', + frames: [ { left: '10px' }, + { left: '20px' }, + { left: '30px' }, + { left: '40px' }, + { left: '50px' } ], + expected: [ { property: 'left', + values: [ value(0, '10px', 'replace', 'linear'), + value(0.25, '20px', 'replace', 'linear'), + value(0.5, '30px', 'replace', 'linear'), + value(0.75, '40px', 'replace', 'linear'), + value(1, '50px', 'replace') ] } ] + }, + { desc: 'a keyframe sequence with different easing values, but the' + + ' same easing value for a given offset', + frames: [ { offset: 0.0, easing: 'ease', left: '10px'}, + { offset: 0.0, easing: 'ease', top: '20px'}, + { offset: 0.5, easing: 'linear', left: '30px' }, + { offset: 0.5, easing: 'linear', top: '40px' }, + { offset: 1.0, easing: 'step-end', left: '50px' }, + { offset: 1.0, easing: 'step-end', top: '60px' } ], + expected: [ { property: 'left', + values: [ value(0, '10px', 'replace', 'ease'), + value(0.5, '30px', 'replace', 'linear'), + value(1, '50px', 'replace') ] }, + { property: 'top', + values: [ value(0, '20px', 'replace', 'ease'), + value(0.5, '40px', 'replace', 'linear'), + value(1, '60px', 'replace') ] } ] + }, + { desc: 'a one-property two-keyframe sequence that needs to' + + ' stringify its values', + frames: [ { offset: 0, opacity: 0 }, + { offset: 1, opacity: 1 } ], + expected: [ { property: 'opacity', + values: [ value(0, '0', 'replace', 'linear'), + value(1, '1', 'replace') ] } ] + }, + { desc: 'a keyframe sequence where shorthand precedes longhand', + frames: [ { offset: 0, margin: '10px', marginRight: '20px' }, + { offset: 1, margin: '30px' } ], + expected: [ { property: 'margin-top', + values: [ value(0, '10px', 'replace', 'linear'), + value(1, '30px', 'replace') ] }, + { property: 'margin-right', + values: [ value(0, '20px', 'replace', 'linear'), + value(1, '30px', 'replace') ] }, + { property: 'margin-bottom', + values: [ value(0, '10px', 'replace', 'linear'), + value(1, '30px', 'replace') ] }, + { property: 'margin-left', + values: [ value(0, '10px', 'replace', 'linear'), + value(1, '30px', 'replace') ] } ] + }, + { desc: 'a keyframe sequence where longhand precedes shorthand', + frames: [ { offset: 0, marginRight: '20px', margin: '10px' }, + { offset: 1, margin: '30px' } ], + expected: [ { property: 'margin-top', + values: [ value(0, '10px', 'replace', 'linear'), + value(1, '30px', 'replace') ] }, + { property: 'margin-right', + values: [ value(0, '20px', 'replace', 'linear'), + value(1, '30px', 'replace') ] }, + { property: 'margin-bottom', + values: [ value(0, '10px', 'replace', 'linear'), + value(1, '30px', 'replace') ] }, + { property: 'margin-left', + values: [ value(0, '10px', 'replace', 'linear'), + value(1, '30px', 'replace') ] } ] + }, + { desc: 'a keyframe sequence where lesser shorthand precedes greater' + + ' shorthand', + frames: [ { offset: 0, borderLeft: '1px solid rgb(1, 2, 3)', + border: '2px dotted rgb(4, 5, 6)' }, + { offset: 1, border: '3px dashed rgb(7, 8, 9)' } ], + expected: [ { property: 'border-bottom-color', + values: [ value(0, 'rgb(4, 5, 6)', 'replace', 'linear'), + value(1, 'rgb(7, 8, 9)', 'replace') ] }, + { property: 'border-left-color', + values: [ value(0, 'rgb(1, 2, 3)', 'replace', 'linear'), + value(1, 'rgb(7, 8, 9)', 'replace') ] }, + { property: 'border-right-color', + values: [ value(0, 'rgb(4, 5, 6)', 'replace', 'linear'), + value(1, 'rgb(7, 8, 9)', 'replace') ] }, + { property: 'border-top-color', + values: [ value(0, 'rgb(4, 5, 6)', 'replace', 'linear'), + value(1, 'rgb(7, 8, 9)', 'replace') ] }, + { property: 'border-bottom-width', + values: [ value(0, '2px', 'replace', 'linear'), + value(1, '3px', 'replace') ] }, + { property: 'border-left-width', + values: [ value(0, '1px', 'replace', 'linear'), + value(1, '3px', 'replace') ] }, + { property: 'border-right-width', + values: [ value(0, '2px', 'replace', 'linear'), + value(1, '3px', 'replace') ] }, + { property: 'border-top-width', + values: [ value(0, '2px', 'replace', 'linear'), + value(1, '3px', 'replace') ] }, + { property: 'border-bottom-style', + values: [ value(0, 'dotted', 'replace', 'linear'), + value(1, 'dashed', 'replace') ] }, + { property: 'border-left-style', + values: [ value(0, 'solid', 'replace', 'linear'), + value(1, 'dashed', 'replace') ] }, + { property: 'border-right-style', + values: [ value(0, 'dotted', 'replace', 'linear'), + value(1, 'dashed', 'replace') ] }, + { property: 'border-top-style', + values: [ value(0, 'dotted', 'replace', 'linear'), + value(1, 'dashed', 'replace') ] }, + { property: 'border-image-outset', + values: [ value(0, '0 0 0 0', 'replace', 'linear'), + value(1, '0 0 0 0', 'replace') ] }, + { property: 'border-image-repeat', + values: [ value(0, 'stretch stretch', 'replace', 'linear'), + value(1, 'stretch stretch', 'replace') ] }, + { property: 'border-image-slice', + values: [ value(0, '100% 100% 100% 100%', + 'replace', 'linear'), + value(1, '100% 100% 100% 100%', 'replace') ] }, + { property: 'border-image-source', + values: [ value(0, 'none', 'replace', 'linear'), + value(1, 'none', 'replace') ] }, + { property: 'border-image-width', + values: [ value(0, '1 1 1 1', 'replace', 'linear'), + value(1, '1 1 1 1', 'replace') ] }, + { property: '-moz-border-bottom-colors', + values: [ value(0, 'none', 'replace', 'linear'), + value(1, 'none', 'replace') ] }, + { property: '-moz-border-left-colors', + values: [ value(0, 'none', 'replace', 'linear'), + value(1, 'none', 'replace') ] }, + { property: '-moz-border-right-colors', + values: [ value(0, 'none', 'replace', 'linear'), + value(1, 'none', 'replace') ] }, + { property: '-moz-border-top-colors', + values: [ value(0, 'none', 'replace', 'linear'), + value(1, 'none', 'replace') ] } ] + }, + { desc: 'a keyframe sequence where greater shorthand precedes' + + ' lesser shorthand', + frames: [ { offset: 0, border: '2px dotted rgb(4, 5, 6)', + borderLeft: '1px solid rgb(1, 2, 3)' }, + { offset: 1, border: '3px dashed rgb(7, 8, 9)' } ], + expected: [ { property: 'border-bottom-color', + values: [ value(0, 'rgb(4, 5, 6)', 'replace', 'linear'), + value(1, 'rgb(7, 8, 9)', 'replace') ] }, + { property: 'border-left-color', + values: [ value(0, 'rgb(1, 2, 3)', 'replace', 'linear'), + value(1, 'rgb(7, 8, 9)', 'replace') ] }, + { property: 'border-right-color', + values: [ value(0, 'rgb(4, 5, 6)', 'replace', 'linear'), + value(1, 'rgb(7, 8, 9)', 'replace') ] }, + { property: 'border-top-color', + values: [ value(0, 'rgb(4, 5, 6)', 'replace', 'linear'), + value(1, 'rgb(7, 8, 9)', 'replace') ] }, + { property: 'border-bottom-width', + values: [ value(0, '2px', 'replace', 'linear'), + value(1, '3px', 'replace') ] }, + { property: 'border-left-width', + values: [ value(0, '1px', 'replace', 'linear'), + value(1, '3px', 'replace') ] }, + { property: 'border-right-width', + values: [ value(0, '2px', 'replace', 'linear'), + value(1, '3px', 'replace') ] }, + { property: 'border-top-width', + values: [ value(0, '2px', 'replace', 'linear'), + value(1, '3px', 'replace') ] }, + { property: 'border-bottom-style', + values: [ value(0, 'dotted', 'replace', 'linear'), + value(1, 'dashed', 'replace') ] }, + { property: 'border-left-style', + values: [ value(0, 'solid', 'replace', 'linear'), + value(1, 'dashed', 'replace') ] }, + { property: 'border-right-style', + values: [ value(0, 'dotted', 'replace', 'linear'), + value(1, 'dashed', 'replace') ] }, + { property: 'border-top-style', + values: [ value(0, 'dotted', 'replace', 'linear'), + value(1, 'dashed', 'replace') ] }, + { property: 'border-image-outset', + values: [ value(0, '0 0 0 0', 'replace', 'linear'), + value(1, '0 0 0 0', 'replace') ] }, + { property: 'border-image-repeat', + values: [ value(0, 'stretch stretch', 'replace', 'linear'), + value(1, 'stretch stretch', 'replace') ] }, + { property: 'border-image-slice', + values: [ value(0, '100% 100% 100% 100%', + 'replace', 'linear'), + value(1, '100% 100% 100% 100%', 'replace') ] }, + { property: 'border-image-source', + values: [ value(0, 'none', 'replace', 'linear'), + value(1, 'none', 'replace') ] }, + { property: 'border-image-width', + values: [ value(0, '1 1 1 1', 'replace', 'linear'), + value(1, '1 1 1 1', 'replace') ] }, + { property: '-moz-border-bottom-colors', + values: [ value(0, 'none', 'replace', 'linear'), + value(1, 'none', 'replace') ] }, + { property: '-moz-border-left-colors', + values: [ value(0, 'none', 'replace', 'linear'), + value(1, 'none', 'replace') ] }, + { property: '-moz-border-right-colors', + values: [ value(0, 'none', 'replace', 'linear'), + value(1, 'none', 'replace') ] }, + { property: '-moz-border-top-colors', + values: [ value(0, 'none', 'replace', 'linear'), + value(1, 'none', 'replace') ] } ] + }, + + // --------------------------------------------------------------------- + // + // Tests for unit conversion + // + // --------------------------------------------------------------------- + + { desc: 'em units are resolved to px values', + frames: { left: ['10em', '20em'] }, + expected: [ { property: 'left', + values: [ value(0, '100px', 'replace', 'linear'), + value(1, '200px', 'replace') ] } ] + }, + { desc: 'calc() expressions are resolved to the equivalent units', + frames: { left: ['calc(10em + 10px)', 'calc(10em + 10%)'] }, + expected: [ { property: 'left', + values: [ value(0, 'calc(110px)', 'replace', 'linear'), + value(1, 'calc(100px + 10%)', 'replace') ] } ] + }, + + // --------------------------------------------------------------------- + // + // Tests for CSS variable handling conversion + // + // --------------------------------------------------------------------- + + { desc: 'CSS variables are resolved to their corresponding values', + frames: { left: ['10px', 'var(--var-100px)'] }, + expected: [ { property: 'left', + values: [ value(0, '10px', 'replace', 'linear'), + value(1, '100px', 'replace') ] } ] + }, + { desc: 'CSS variables in calc() expressions are resolved', + frames: { left: ['10px', 'calc(var(--var-100px) / 2 - 10%)'] }, + expected: [ { property: 'left', + values: [ value(0, '10px', 'replace', 'linear'), + value(1, 'calc(50px + -10%)', 'replace') ] } ] + }, + { desc: 'CSS variables in shorthands are resolved to their corresponding' + + ' values', + frames: { margin: ['10px', 'var(--var-100px-200px)'] }, + expected: [ { property: 'margin-top', + values: [ value(0, '10px', 'replace', 'linear'), + value(1, '100px', 'replace') ] }, + { property: 'margin-right', + values: [ value(0, '10px', 'replace', 'linear'), + value(1, '200px', 'replace') ] }, + { property: 'margin-bottom', + values: [ value(0, '10px', 'replace', 'linear'), + value(1, '100px', 'replace') ] }, + { property: 'margin-left', + values: [ value(0, '10px', 'replace', 'linear'), + value(1, '200px', 'replace') ] } ] + }, + + // --------------------------------------------------------------------- + // + // Tests for properties that parse correctly but which we fail to + // convert to computed values. + // + // --------------------------------------------------------------------- + + { desc: 'a property that can\'t be resolved to computed values in' + + ' initial keyframe', + frames: [ { margin: '5px', simulateComputeValuesFailure: true }, + { margin: '5px' } ], + expected: [ ] + }, + { desc: 'a property that can\'t be resolved to computed values in' + + ' initial keyframe where we have enough values to create' + + ' a final segment', + frames: [ { margin: '5px', simulateComputeValuesFailure: true }, + { margin: '5px' }, + { margin: '5px' } ], + expected: [ ] + }, + { desc: 'a property that can\'t be resolved to computed values in' + + ' initial overlapping keyframes (first in series of two)', + frames: [ { margin: '5px', offset: 0, + simulateComputeValuesFailure: true }, + { margin: '5px', offset: 0 }, + { margin: '5px' } ], + expected: [ { property: 'margin-top', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-right', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-bottom', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-left', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] } ] + }, + { desc: 'a property that can\'t be resolved to computed values in' + + ' initial overlapping keyframes (second in series of two)', + frames: [ { margin: '5px', offset: 0 }, + { margin: '5px', offset: 0, + simulateComputeValuesFailure: true }, + { margin: '5px' } ], + expected: [ { property: 'margin-top', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-right', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-bottom', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-left', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] } ] + }, + { desc: 'a property that can\'t be resolved to computed values in' + + ' initial overlapping keyframes (second in series of three)', + frames: [ { margin: '5px', offset: 0 }, + { margin: '5px', offset: 0, + simulateComputeValuesFailure: true }, + { margin: '5px', offset: 0 }, + { margin: '5px' } ], + expected: [ { property: 'margin-top', + values: [ value(0, '5px', 'replace'), + value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-right', + values: [ value(0, '5px', 'replace'), + value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-bottom', + values: [ value(0, '5px', 'replace'), + value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-left', + values: [ value(0, '5px', 'replace'), + value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] } ] + }, + { desc: 'a property that can\'t be resolved to computed values in' + + ' final keyframe', + frames: [ { margin: '5px' }, + { margin: '5px', simulateComputeValuesFailure: true } ], + expected: [ ] + }, + { desc: 'a property that can\'t be resolved to computed values in' + + ' final keyframe where it forms the last segment in the series', + frames: [ { margin: '5px' }, + { margin: '5px', + marginLeft: '5px', + marginRight: '5px', + marginBottom: '5px', + // margin-top sorts last and only it will be missing since + // the other longhand components are specified + simulateComputeValuesFailure: true } ], + expected: [ { property: 'margin-bottom', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-left', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-right', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] } ] + }, + { desc: 'a property that can\'t be resolved to computed values in' + + ' final keyframe where we have enough values to create' + + ' an initial segment', + frames: [ { margin: '5px' }, + { margin: '5px' }, + { margin: '5px', simulateComputeValuesFailure: true } ], + expected: [ ] + }, + { desc: 'a property that can\'t be resolved to computed values in' + + ' final overlapping keyframes (first in series of two)', + frames: [ { margin: '5px' }, + { margin: '5px', offset: 1, + simulateComputeValuesFailure: true }, + { margin: '5px', offset: 1 } ], + expected: [ { property: 'margin-top', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-right', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-bottom', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-left', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] } ] + }, + { desc: 'a property that can\'t be resolved to computed values in' + + ' final overlapping keyframes (second in series of two)', + frames: [ { margin: '5px' }, + { margin: '5px', offset: 1 }, + { margin: '5px', offset: 1, + simulateComputeValuesFailure: true } ], + expected: [ { property: 'margin-top', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-right', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-bottom', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-left', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] } ] + }, + { desc: 'a property that can\'t be resolved to computed values in' + + ' final overlapping keyframes (second in series of three)', + frames: [ { margin: '5px' }, + { margin: '5px', offset: 1 }, + { margin: '5px', offset: 1, + simulateComputeValuesFailure: true }, + { margin: '5px', offset: 1 } ], + expected: [ { property: 'margin-top', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace'), + value(1, '5px', 'replace') ] }, + { property: 'margin-right', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace'), + value(1, '5px', 'replace') ] }, + { property: 'margin-bottom', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace'), + value(1, '5px', 'replace') ] }, + { property: 'margin-left', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace'), + value(1, '5px', 'replace') ] } ] + }, + { desc: 'a property that can\'t be resolved to computed values in' + + ' intermediate keyframe', + frames: [ { margin: '5px' }, + { margin: '5px', simulateComputeValuesFailure: true }, + { margin: '5px' } ], + expected: [ { property: 'margin-top', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-right', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-bottom', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-left', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] } ] + }, + { desc: 'a property that can\'t be resolved to computed values in' + + ' initial keyframe along with other values', + // simulateComputeValuesFailure only applies to shorthands so we can set + // it on the same keyframe and it will only apply to |margin| and not + // |left|. + frames: [ { margin: '77%', left: '10px', + simulateComputeValuesFailure: true }, + { margin: '5px', left: '20px' } ], + expected: [ { property: 'left', + values: [ value(0, '10px', 'replace', 'linear'), + value(1, '20px', 'replace') ] } ], + }, + { desc: 'a property that can\'t be resolved to computed values in' + + ' initial keyframe along with other values where those' + + ' values sort after the property with missing values', + frames: [ { margin: '77%', right: '10px', + simulateComputeValuesFailure: true }, + { margin: '5px', right: '20px' } ], + expected: [ { property: 'right', + values: [ value(0, '10px', 'replace', 'linear'), + value(1, '20px', 'replace') ] } ], + }, + { desc: 'a property that can\'t be resolved to computed values in' + + ' final keyframe along with other values', + frames: [ { margin: '5px', left: '10px' }, + { margin: '5px', left: '20px', + simulateComputeValuesFailure: true } ], + expected: [ { property: 'left', + values: [ value(0, '10px', 'replace', 'linear'), + value(1, '20px', 'replace') ] } ], + }, + { desc: 'a property that can\'t be resolved to computed values in' + + ' final keyframe along with other values where those' + + ' values sort after the property with missing values', + frames: [ { margin: '5px', right: '10px' }, + { margin: '5px', right: '20px', + simulateComputeValuesFailure: true } ], + expected: [ { property: 'right', + values: [ value(0, '10px', 'replace', 'linear'), + value(1, '20px', 'replace') ] } ], + }, + { desc: 'a property that can\'t be resolved to computed values in' + + ' an intermediate keyframe along with other values', + frames: [ { margin: '5px', left: '10px' }, + { margin: '5px', left: '20px', + simulateComputeValuesFailure: true }, + { margin: '5px', left: '30px' } ], + expected: [ { property: 'margin-top', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-right', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-bottom', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-left', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'left', + values: [ value(0, '10px', 'replace', 'linear'), + value(0.5, '20px', 'replace', 'linear'), + value(1, '30px', 'replace') ] } ] + }, + { desc: 'a property that can\'t be resolved to computed values in' + + ' an intermediate keyframe by itself', + frames: [ { margin: '5px', left: '10px' }, + { margin: '5px', + simulateComputeValuesFailure: true }, + { margin: '5px', left: '30px' } ], + expected: [ { property: 'margin-top', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-right', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-bottom', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-left', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'left', + values: [ value(0, '10px', 'replace', 'linear'), + value(1, '30px', 'replace') ] } ] + }, +]; + +gTests.forEach(function(subtest) { + test(function(t) { + var div = addDiv(t); + var animation = div.animate(subtest.frames, 100 * MS_PER_SEC); + assert_properties_equal(animation.effect.getProperties(), + subtest.expected); + }, subtest.desc); +}); + +</script> +</body> diff --git a/dom/animation/test/chrome/test_generated_content_getAnimations.html b/dom/animation/test/chrome/test_generated_content_getAnimations.html new file mode 100644 index 000000000..04e0f9bd0 --- /dev/null +++ b/dom/animation/test/chrome/test_generated_content_getAnimations.html @@ -0,0 +1,83 @@ +<!DOCTYPE html> +<head> +<meta charset=utf-8> +<title>Test getAnimations() for generated-content elements</title> +<script type="application/javascript" src="../testharness.js"></script> +<script type="application/javascript" src="../testharnessreport.js"></script> +<script type="application/javascript" src="../testcommon.js"></script> +<style> +@keyframes anim { } +@keyframes anim2 { } +.before::before { + content: ''; + animation: anim 100s; +} +.after::after { + content: ''; + animation: anim 100s, anim2 100s; +} +</style> +</head> +<body> +<div id='root' class='before after'> + <div class='before'></div> + <div></div> +</div> +<script> +'use strict'; + +const {Cc, Ci, Cu} = SpecialPowers; + +function getWalker(node) { + var walker = Cc["@mozilla.org/inspector/deep-tree-walker;1"]. + createInstance(Ci.inIDeepTreeWalker); + walker.showAnonymousContent = true; + walker.init(node.ownerDocument, Ci.nsIDOMNodeFilter.SHOW_ALL); + walker.currentNode = node; + return walker; +} + +test(function(t) { + var root = document.getElementById('root'); + // Flush first to make sure the generated-content elements are ready + // in the tree. + flushComputedStyle(root); + var before = getWalker(root).firstChild(); + var after = getWalker(root).lastChild(); + + // Sanity Checks + assert_equals(document.getAnimations().length, 4, + 'All animations in this document'); + assert_equals(before.tagName, '_moz_generated_content_before', + 'First child is ::before element'); + assert_equals(after.tagName, '_moz_generated_content_after', + 'Last child is ::after element'); + + // Test Element.getAnimations() for generated-content elements + assert_equals(before.getAnimations().length, 1, + 'Animations of ::before generated-content element'); + assert_equals(after.getAnimations().length, 2, + 'Animations of ::after generated-content element'); +}, 'Element.getAnimations() used on generated-content elements'); + +test(function(t) { + var root = document.getElementById('root'); + flushComputedStyle(root); + var walker = getWalker(root); + + var animations = []; + var element = walker.currentNode; + while (element) { + if (element.getAnimations) { + animations = [...animations, ...element.getAnimations()]; + } + element = walker.nextNode(); + } + + assert_equals(animations.length, document.getAnimations().length, + 'The number of animations got by DeepTreeWalker and ' + + 'document.getAnimations() should be the same'); +}, 'Element.getAnimations() used by traversing DeepTreeWalker'); + +</script> +</body> diff --git a/dom/animation/test/chrome/test_observers_for_sync_api.html b/dom/animation/test/chrome/test_observers_for_sync_api.html new file mode 100644 index 000000000..20c3f3670 --- /dev/null +++ b/dom/animation/test/chrome/test_observers_for_sync_api.html @@ -0,0 +1,854 @@ +<!DOCTYPE html> +<meta charset=utf-8> +<title> +Test chrome-only MutationObserver animation notifications for sync APIs +</title> +<script type="application/javascript" src="../testharness.js"></script> +<script type="application/javascript" src="../testharnessreport.js"></script> +<script type="application/javascript" src="../testcommon.js"></script> +<div id="log"></div> +<style> +@keyframes anim { + to { transform: translate(100px); } +} +@keyframes anotherAnim { + to { transform: translate(0px); } +} +</style> +<script> + +function assert_record_list(actual, expected, desc, index, listName) { + assert_equals(actual.length, expected.length, + `${desc} - record[${index}].${listName} length`); + if (actual.length != expected.length) { + return; + } + for (var i = 0; i < actual.length; i++) { + assert_not_equals(actual.indexOf(expected[i]), -1, + `${desc} - record[${index}].${listName} contains expected Animation`); + } +} + +function assert_equals_records(actual, expected, desc) { + assert_equals(actual.length, expected.length, `${desc} - number of records`); + if (actual.length != expected.length) { + return; + } + for (var i = 0; i < actual.length; i++) { + assert_record_list(actual[i].addedAnimations, + expected[i].added, desc, i, "addedAnimations"); + assert_record_list(actual[i].changedAnimations, + expected[i].changed, desc, i, "changedAnimations"); + assert_record_list(actual[i].removedAnimations, + expected[i].removed, desc, i, "removedAnimations"); + } +} + +// Create a pseudo element +function createPseudo(test, element, type) { + addStyle(test, { '@keyframes anim': '', + ['.pseudo::' + type]: 'animation: anim 10s;' }); + element.classList.add('pseudo'); + var anims = document.getAnimations(); + assert_true(anims.length >= 1); + var anim = anims[anims.length - 1]; + assert_equals(anim.effect.target.parentElement, element); + assert_equals(anim.effect.target.type, '::' + type); + anim.cancel(); + return anim.effect.target; +} + +[ { subtree: false }, + { subtree: true } +].forEach(aOptions => { + test(t => { + var div = addDiv(t); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var anim = div.animate({ opacity: [ 0, 1 ] }, 200 * MS_PER_SEC); + + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation is added"); + + anim.effect.timing.duration = 100 * MS_PER_SEC; + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [anim], removed: [] }], + "records after duration is changed"); + + anim.effect.timing.duration = 100 * MS_PER_SEC; + assert_equals_records(observer.takeRecords(), + [], "records after assigning same value"); + + anim.currentTime = anim.effect.timing.duration * 2; + anim.finish(); + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: [anim] }], + "records after animation end"); + + anim.effect.timing.duration = anim.effect.timing.duration * 3; + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation restarted"); + + anim.effect.timing.duration = "auto"; + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: [anim] }], + "records after duration set \"auto\""); + + anim.effect.timing.duration = "auto"; + assert_equals_records(observer.takeRecords(), + [], "records after assigning same value \"auto\""); + }, "change_duration_and_currenttime"); + + test(t => { + var div = addDiv(t); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var anim = div.animate({ opacity: [ 0, 1 ] }, 100 * MS_PER_SEC); + + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation is added"); + + anim.effect.timing.endDelay = 10 * MS_PER_SEC; + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [anim], removed: [] }], + "records after endDelay is changed"); + + anim.effect.timing.endDelay = 10 * MS_PER_SEC; + assert_equals_records(observer.takeRecords(), + [], "records after assigning same value"); + + anim.currentTime = 109 * MS_PER_SEC; + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: [anim] }], + "records after currentTime during endDelay"); + + anim.effect.timing.endDelay = -110 * MS_PER_SEC; + assert_equals_records(observer.takeRecords(), + [], "records after assigning negative value"); + }, "change_enddelay_and_currenttime"); + + test(t => { + var div = addDiv(t); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var anim = div.animate({ opacity: [ 0, 1 ] }, + { duration: 100 * MS_PER_SEC, + endDelay: -100 * MS_PER_SEC }); + assert_equals_records(observer.takeRecords(), + [], "records after animation is added"); + }, "zero_end_time"); + + test(t => { + var div = addDiv(t); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var anim = div.animate({ opacity: [ 0, 1 ] }, 100 * MS_PER_SEC); + + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation is added"); + + anim.effect.timing.iterations = 2; + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [anim], removed: [] }], + "records after iterations is changed"); + + anim.effect.timing.iterations = 2; + assert_equals_records(observer.takeRecords(), + [], "records after assigning same value"); + + anim.effect.timing.iterations = 0; + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: [anim] }], + "records after animation end"); + + anim.effect.timing.iterations = Infinity; + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation restarted"); + }, "change_iterations"); + + test(t => { + var div = addDiv(t); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var anim = div.animate({ opacity: [ 0, 1 ] }, 100 * MS_PER_SEC); + + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation is added"); + + anim.effect.timing.delay = 100; + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [anim], removed: [] }], + "records after delay is changed"); + + anim.effect.timing.delay = 100; + assert_equals_records(observer.takeRecords(), + [], "records after assigning same value"); + + anim.effect.timing.delay = -100 * MS_PER_SEC; + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: [anim] }], + "records after animation end"); + + anim.effect.timing.delay = 0; + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation restarted"); + }, "change_delay"); + + test(t => { + var div = addDiv(t); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var anim = div.animate({ opacity: [ 0, 1 ] }, + { duration: 100 * MS_PER_SEC, + easing: "steps(2, start)" }); + + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation is added"); + + anim.effect.timing.easing = "steps(2, end)"; + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [anim], removed: [] }], + "records after easing is changed"); + + anim.effect.timing.easing = "steps(2, end)"; + assert_equals_records(observer.takeRecords(), + [], "records after assigning same value"); + }, "change_easing"); + + test(t => { + var div = addDiv(t); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var anim = div.animate({ opacity: [ 0, 1 ] }, + { duration: 100, delay: -100 }); + assert_equals_records(observer.takeRecords(), + [], "records after assigning negative value"); + }, "negative_delay_in_constructor"); + + test(t => { + var div = addDiv(t); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var effect = new KeyframeEffectReadOnly(null, + { opacity: [ 0, 1 ] }, + { duration: 100 * MS_PER_SEC }); + var anim = new Animation(effect, document.timeline); + anim.play(); + assert_equals_records(observer.takeRecords(), + [], "no records after animation is added"); + }, "create_animation_without_target"); + + test(t => { + var div = addDiv(t); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var anim = div.animate({ opacity: [ 0, 1 ] }, + { duration: 100 * MS_PER_SEC }); + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation is added"); + + anim.effect.target = div; + assert_equals_records(observer.takeRecords(), + [], "no records after setting the same target"); + + anim.effect.target = null; + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: [anim] }], + "records after setting null"); + + anim.effect.target = null; + assert_equals_records(observer.takeRecords(), + [], "records after setting redundant null"); + }, "set_redundant_animation_target"); + + test(t => { + var div = addDiv(t); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var anim = div.animate({ opacity: [ 0, 1 ] }, + { duration: 100 * MS_PER_SEC }); + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation is added"); + + anim.effect = null; + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: [anim] }], + "records after animation is removed"); + }, "set_null_animation_effect"); + + test(t => { + var div = addDiv(t); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var anim = new Animation(); + anim.play(); + anim.effect = new KeyframeEffect(div, { opacity: [ 0, 1 ] }, + 100 * MS_PER_SEC); + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation is added"); + }, "set_effect_on_null_effect_animation"); + + test(t => { + var div = addDiv(t); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var anim = div.animate({ marginLeft: [ "0px", "100px" ] }, + 100 * MS_PER_SEC); + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation is added"); + + anim.effect = new KeyframeEffect(div, { opacity: [ 0, 1 ] }, + 100 * MS_PER_SEC); + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [anim], removed: [] }], + "records after replace effects"); + }, "replace_effect_targeting_on_the_same_element"); + + test(t => { + var div = addDiv(t); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var anim = div.animate({ marginLeft: [ "0px", "100px" ] }, + 100 * MS_PER_SEC); + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation is added"); + + anim.currentTime = 60 * MS_PER_SEC; + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [anim], removed: [] }], + "records after animation is changed"); + + anim.effect = new KeyframeEffect(div, { opacity: [ 0, 1 ] }, + 50 * MS_PER_SEC); + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: [anim] }], + "records after replacing effects"); + }, "replace_effect_targeting_on_the_same_element_not_in_effect"); + + test(t => { + var div = addDiv(t); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var anim = div.animate([ { marginLeft: "0px" }, + { marginLeft: "-20px" }, + { marginLeft: "100px" }, + { marginLeft: "50px" } ], + { duration: 100 * MS_PER_SEC }); + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation is added"); + + anim.effect.spacing = "paced(margin-left)"; + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [anim], removed: [] }], + "records after animation is changed"); + }, "set_spacing"); + + test(t => { + var div = addDiv(t); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var anim = div.animate([ { marginLeft: "0px" }, + { marginLeft: "-20px" }, + { marginLeft: "100px" }, + { marginLeft: "50px" } ], + { duration: 100 * MS_PER_SEC, + spacing: "paced(margin-left)" }); + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation is added"); + + anim.effect.spacing = "paced(animation-duration)"; + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [anim], removed: [] }], + "records after setting a non-animatable paced property"); + }, "set_spacing_on_a_non-animatable_property"); + + test(t => { + var div = addDiv(t); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var anim = div.animate([ { marginLeft: "0px" }, + { marginLeft: "-20px" }, + { marginLeft: "100px" }, + { marginLeft: "50px" } ], + { duration: 100 * MS_PER_SEC, + spacing: "paced(margin-left)" }); + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation is added"); + + anim.effect.spacing = "paced(margin-left)"; + assert_equals_records(observer.takeRecords(), + [], "no record after setting the same spacing"); + }, "set_the_same_spacing"); + + // Test that starting a single animation that is cancelled by calling + // cancel() dispatches an added notification and then a removed + // notification. + test(t => { + var div = addDiv(t, { style: "animation: anim 100s forwards" }); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var animations = div.getAnimations(); + assert_equals(animations.length, 1, + "getAnimations().length after animation start"); + + assert_equals_records(observer.takeRecords(), + [{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + animations[0].cancel(); + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: animations }], + "records after animation end"); + + // Re-trigger the animation. + animations[0].play(); + + // Single MutationRecord for the Animation (re-)addition. + assert_equals_records(observer.takeRecords(), + [{ added: animations, changed: [], removed: [] }], + "records after animation start"); + }, "single_animation_cancelled_api"); + + // Test that updating a property on the Animation object dispatches a changed + // notification. + [ + { prop: "playbackRate", val: 0.5 }, + { prop: "startTime", val: 50 * MS_PER_SEC }, + { prop: "currentTime", val: 50 * MS_PER_SEC }, + ].forEach(function(aChangeTest) { + test(t => { + // We use a forwards fill mode so that even if the change we make causes + // the animation to become finished, it will still be "relevant" so we + // won't mark it as removed. + var div = addDiv(t, { style: "animation: anim 100s forwards" }); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var animations = div.getAnimations(); + assert_equals(animations.length, 1, + "getAnimations().length after animation start"); + + assert_equals_records(observer.takeRecords(), + [{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + // Update the property. + animations[0][aChangeTest.prop] = aChangeTest.val; + + // Make a redundant change. + animations[0][aChangeTest.prop] = animations[0][aChangeTest.prop]; + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: animations, removed: [] }], + "records after animation property change"); + }, `single_animation_api_change_${aChangeTest.prop}`); + }); + + // Test that making a redundant change to currentTime while an Animation + // is pause-pending still generates a change MutationRecord since setting + // the currentTime to any value in this state aborts the pending pause. + test(t => { + var div = addDiv(t, { style: "animation: anim 100s" }); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var animations = div.getAnimations(); + assert_equals(animations.length, 1, + "getAnimations().length after animation start"); + + assert_equals_records(observer.takeRecords(), + [{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + animations[0].pause(); + + // We are now pause-pending. Even if we make a redundant change to the + // currentTime, we should still get a change record because setting the + // currentTime while pause-pending has the effect of cancelling a pause. + animations[0].currentTime = animations[0].currentTime; + + // Two MutationRecords for the Animation changes: one for pausing, one + // for aborting the pause. + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: animations, removed: [] }, + { added: [], changed: animations, removed: [] }], + "records after pausing then seeking"); + }, "change_currentTime_while_pause_pending"); + + // Test that calling finish() on a forwards-filling Animation dispatches + // a changed notification. + test(t => { + var div = addDiv(t, { style: "animation: anim 100s forwards" }); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var animations = div.getAnimations(); + assert_equals(animations.length, 1, + "getAnimations().length after animation start"); + + assert_equals_records(observer.takeRecords(), + [{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + animations[0].finish(); + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: animations, removed: [] }], + "records after finish()"); + + // Redundant finish. + animations[0].finish(); + + // Ensure no change records. + assert_equals_records(observer.takeRecords(), + [], "records after redundant finish()"); + }, "finish_with_forwards_fill"); + + // Test that calling finish() on an Animation that does not fill forwards, + // dispatches a removal notification. + test(t => { + var div = addDiv(t, { style: "animation: anim 100s" }); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var animations = div.getAnimations(); + assert_equals(animations.length, 1, + "getAnimations().length after animation start"); + + assert_equals_records(observer.takeRecords(), + [{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + animations[0].finish(); + + // Single MutationRecord for the Animation removal. + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: animations }], + "records after finishing"); + }, "finish_without_fill"); + + // Test that calling finish() on a forwards-filling Animation dispatches + test(t => { + var div = addDiv(t, { style: "animation: anim 100s" }); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var animation = div.getAnimations()[0]; + assert_equals_records(observer.takeRecords(), + [{ added: [animation], changed: [], removed: []}], + "records after creation"); + animation.id = "new id"; + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [animation], removed: []}], + "records after id is changed"); + + animation.id = "new id"; + assert_equals_records(observer.takeRecords(), + [], "records after assigning same value with id"); + }, "change_id"); + + // Test that calling reverse() dispatches a changed notification. + test(t => { + var div = addDiv(t, { style: "animation: anim 100s both" }); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var animations = div.getAnimations(); + assert_equals(animations.length, 1, + "getAnimations().length after animation start"); + + assert_equals_records(observer.takeRecords(), + [{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + animations[0].reverse(); + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: animations, removed: [] }], + "records after calling reverse()"); + }, "reverse"); + + // Test that calling reverse() does *not* dispatch a changed notification + // when playbackRate == 0. + test(t => { + var div = addDiv(t, { style: "animation: anim 100s both" }); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var animations = div.getAnimations(); + assert_equals(animations.length, 1, + "getAnimations().length after animation start"); + + assert_equals_records(observer.takeRecords(), + [{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + // Seek to the middle and set playbackRate to zero. + animations[0].currentTime = 50 * MS_PER_SEC; + animations[0].playbackRate = 0; + + // Two MutationRecords, one for each change. + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: animations, removed: [] }, + { added: [], changed: animations, removed: [] }], + "records after seeking and setting playbackRate"); + + animations[0].reverse(); + + // We should get no notifications. + assert_equals_records(observer.takeRecords(), + [], "records after calling reverse()"); + }, "reverse_with_zero_playbackRate"); + + // Test that attempting to start an animation that should already be finished + // does not send any notifications. + test(t => { + // Start an animation that should already be finished. + var div = addDiv(t, { style: "animation: anim 1s -2s;" }); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + // The animation should cause no Animations to be created. + var animations = div.getAnimations(); + assert_equals(animations.length, 0, + "getAnimations().length after animation start"); + + // And we should get no notifications. + assert_equals_records(observer.takeRecords(), + [], "records after attempted animation start"); + }, "already_finished"); + + test(t => { + var div = addDiv(t, { style: "animation: anim 100s, anotherAnim 100s" }); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var animations = div.getAnimations(); + + assert_equals_records(observer.takeRecords(), + [{ added: animations, changed: [], removed: []}], + "records after creation"); + + div.style.animation = "anotherAnim 100s, anim 100s"; + animations = div.getAnimations(); + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: animations, removed: []}], + "records after the order is changed"); + + div.style.animation = "anotherAnim 100s, anim 100s"; + + assert_equals_records(observer.takeRecords(), + [], "no records after applying the same order"); + }, "animtion_order_change"); + +}); + +test(t => { + var div = addDiv(t); + var observer = setupSynchronousObserver(t, div, true); + + var child = document.createElement("div"); + div.appendChild(child); + + var anim1 = div.animate({ marginLeft: [ "0px", "50px" ] }, + 100 * MS_PER_SEC); + var anim2 = child.animate({ marginLeft: [ "0px", "100px" ] }, + 50 * MS_PER_SEC); + assert_equals_records(observer.takeRecords(), + [{ added: [anim1], changed: [], removed: [] }, + { added: [anim2], changed: [], removed: [] }], + "records after animation is added"); + + // After setting a new effect, we remove the current animation, anim1, + // because it is no longer attached to |div|, and then remove the previous + // animation, anim2. Finally, add back the anim1 which is in effect on + // |child| now. In addition, we sort them by tree order and they are + // batched. + anim1.effect = anim2.effect; + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: [anim1] }, // div + { added: [anim1], changed: [], removed: [anim2] }], // child + "records after animation effects are changed"); +}, "set_effect_with_previous_animation"); + +test(t => { + var div = addDiv(t); + var observer = setupSynchronousObserver(t, document, true); + + var anim = div.animate({ opacity: [ 0, 1 ] }, + { duration: 100 * MS_PER_SEC }); + + var newTarget = document.createElement("div"); + + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation is added"); + + anim.effect.target = null; + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: [anim] }], + "records after setting null"); + + anim.effect.target = div; + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after setting a target"); + + anim.effect.target = addDiv(t); + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: [anim] }, + { added: [anim], changed: [], removed: [] }], + "records after setting a different target"); +}, "set_animation_target"); + +test(t => { + var div = addDiv(t); + var pseudoTarget = createPseudo(t, div, 'before'); + var observer = setupSynchronousObserver(t, div, true); + + var anim = pseudoTarget.animate({ opacity: [ 0, 1 ] }, 200 * MS_PER_SEC); + + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation is added"); + + anim.effect.timing.duration = 100 * MS_PER_SEC; + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [anim], removed: [] }], + "records after duration is changed"); + + anim.effect.timing.duration = 100 * MS_PER_SEC; + assert_equals_records(observer.takeRecords(), + [], "records after assigning same value"); + + anim.currentTime = anim.effect.timing.duration * 2; + anim.finish(); + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: [anim] }], + "records after animation end"); + + anim.effect.timing.duration = anim.effect.timing.duration * 3; + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation restarted"); + + anim.effect.timing.duration = "auto"; + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: [anim] }], + "records after duration set \"auto\""); + + anim.effect.timing.duration = "auto"; + assert_equals_records(observer.takeRecords(), + [], "records after assigning same value \"auto\""); +}, "change_duration_and_currenttime_on_pseudo_elements"); + +test(t => { + var div = addDiv(t); + var pseudoTarget = createPseudo(t, div, 'before'); + var observer = setupSynchronousObserver(t, div, false); + + var anim = div.animate({ opacity: [ 0, 1 ] }, + { duration: 100 * MS_PER_SEC }); + var pAnim = pseudoTarget.animate({ opacity: [ 0, 1 ] }, + { duration: 100 * MS_PER_SEC }); + + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation is added"); + + anim.finish(); + pAnim.finish(); + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: [anim] }], + "records after animation is finished"); +}, "exclude_animations_targeting_pseudo_elements"); + +</script> diff --git a/dom/animation/test/chrome/test_restyles.html b/dom/animation/test/chrome/test_restyles.html new file mode 100644 index 000000000..e59967c19 --- /dev/null +++ b/dom/animation/test/chrome/test_restyles.html @@ -0,0 +1,815 @@ +<!doctype html> +<head> +<meta charset=utf-8> +<title>Tests restyles caused by animations</title> +<script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> +<script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> +<script src="chrome://mochikit/content/tests/SimpleTest/SpawnTask.js"></script> +<script src="chrome://mochikit/content/tests/SimpleTest/paint_listener.js"></script> +<script src="../testcommon.js"></script> +<link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +<style> +@keyframes opacity { + from { opacity: 1; } + to { opacity: 0; } +} +@keyframes background-color { + from { background-color: red; } + to { background-color: blue; } +} +@keyframes rotate { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} +div { + /* Element needs geometry to be eligible for layerization */ + width: 100px; + height: 100px; + background-color: white; +} +</style> +</head> +<body> +<script> +'use strict'; + +function observeStyling(frameCount, onFrame) { + var docShell = window.QueryInterface(SpecialPowers.Ci.nsIInterfaceRequestor) + .getInterface(SpecialPowers.Ci.nsIWebNavigation) + .QueryInterface(SpecialPowers.Ci.nsIDocShell); + + docShell.recordProfileTimelineMarkers = true; + docShell.popProfileTimelineMarkers(); + + return new Promise(function(resolve) { + return waitForAnimationFrames(frameCount, onFrame).then(function() { + var markers = docShell.popProfileTimelineMarkers(); + docShell.recordProfileTimelineMarkers = false; + var stylingMarkers = markers.filter(function(marker, index) { + return marker.name == 'Styles' && + (marker.restyleHint == 'eRestyle_CSSAnimations' || + marker.restyleHint == 'eRestyle_CSSTransitions'); + }); + resolve(stylingMarkers); + }); + }); +} + +function ensureElementRemoval(aElement) { + return new Promise(function(resolve) { + aElement.remove(); + waitForAllPaintsFlushed(resolve); + }); +} + +SimpleTest.waitForExplicitFinish(); + +var omtaEnabled = isOMTAEnabled(); + +var isAndroid = !!navigator.userAgent.includes("Android"); + +function add_task_if_omta_enabled(test) { + if (!omtaEnabled) { + info(test.name + " is skipped because OMTA is disabled"); + return; + } + add_task(test); +} + +// We need to wait for all paints before running tests to avoid contaminations +// from styling of this document itself. +waitForAllPaints(function() { + add_task(function* restyling_for_main_thread_animations() { + var div = addDiv(null, { style: 'animation: background-color 100s' }); + var animation = div.getAnimations()[0]; + + yield animation.ready; + ok(!animation.isRunningOnCompositor); + + var markers = yield observeStyling(5); + is(markers.length, 5, + 'CSS animations running on the main-thread should update style ' + + 'on the main thread'); + yield ensureElementRemoval(div); + }); + + add_task_if_omta_enabled(function* no_restyling_for_compositor_animations() { + var div = addDiv(null, { style: 'animation: opacity 100s' }); + var animation = div.getAnimations()[0]; + + yield animation.ready; + ok(animation.isRunningOnCompositor); + + var markers = yield observeStyling(5); + is(markers.length, 0, + 'CSS animations running on the compositor should not update style ' + + 'on the main thread'); + yield ensureElementRemoval(div); + }); + + add_task_if_omta_enabled(function* no_restyling_for_compositor_transitions() { + var div = addDiv(null, { style: 'transition: opacity 100s; opacity: 0' }); + getComputedStyle(div).opacity; + div.style.opacity = 1; + + var animation = div.getAnimations()[0]; + + yield animation.ready; + ok(animation.isRunningOnCompositor); + + var markers = yield observeStyling(5); + is(markers.length, 0, + 'CSS transitions running on the compositor should not update style ' + + 'on the main thread'); + yield ensureElementRemoval(div); + }); + + add_task_if_omta_enabled(function* no_restyling_when_animation_duration_is_changed() { + var div = addDiv(null, { style: 'animation: opacity 100s' }); + var animation = div.getAnimations()[0]; + + yield animation.ready; + ok(animation.isRunningOnCompositor); + + div.animationDuration = '200s'; + + var markers = yield observeStyling(5); + is(markers.length, 0, + 'Animations running on the compositor should not update style ' + + 'on the main thread'); + yield ensureElementRemoval(div); + }); + + add_task_if_omta_enabled(function* only_one_restyling_after_finish_is_called() { + var div = addDiv(null, { style: 'animation: opacity 100s' }); + var animation = div.getAnimations()[0]; + + yield animation.ready; + ok(animation.isRunningOnCompositor); + + animation.finish(); + + var markers = yield observeStyling(5); + is(markers.length, 1, + 'Animations running on the compositor should only update style ' + + 'once after finish() is called'); + yield ensureElementRemoval(div); + }); + + add_task(function* no_restyling_mouse_movement_on_finished_transition() { + var div = addDiv(null, { style: 'transition: opacity 1ms; opacity: 0' }); + getComputedStyle(div).opacity; + div.style.opacity = 1; + + var animation = div.getAnimations()[0]; + var initialRect = div.getBoundingClientRect(); + + yield animation.finished; + + var mouseX = initialRect.left + initialRect.width / 2; + var mouseY = initialRect.top + initialRect.height / 2; + var markers = yield observeStyling(5, function() { + // We can't use synthesizeMouse here since synthesizeMouse causes + // layout flush. + synthesizeMouseAtPoint(mouseX++, mouseY++, + { type: 'mousemove' }, window); + }); + + is(markers.length, 0, + 'Bug 1219236: Finished transitions should never cause restyles ' + + 'when mouse is moved on the animations'); + yield ensureElementRemoval(div); + }); + + add_task(function* no_restyling_mouse_movement_on_finished_animation() { + var div = addDiv(null, { style: 'animation: opacity 1ms' }); + var animation = div.getAnimations()[0]; + + var initialRect = div.getBoundingClientRect(); + + yield animation.finished; + + var mouseX = initialRect.left + initialRect.width / 2; + var mouseY = initialRect.top + initialRect.height / 2; + var markers = yield observeStyling(5, function() { + // We can't use synthesizeMouse here since synthesizeMouse causes + // layout flush. + synthesizeMouseAtPoint(mouseX++, mouseY++, + { type: 'mousemove' }, window); + }); + + is(markers.length, 0, + 'Bug 1219236: Finished animations should never cause restyles ' + + 'when mouse is moved on the animations'); + yield ensureElementRemoval(div); + }); + + add_task_if_omta_enabled(function* no_restyling_compositor_animations_out_of_view_element() { + if (!SpecialPowers.getBoolPref('dom.animations.offscreen-throttling')) { + return; + } + + var div = addDiv(null, + { style: 'animation: opacity 100s; transform: translateY(-400px);' }); + var animation = div.getAnimations()[0]; + + yield animation.ready; + ok(!animation.isRunningOnCompositor); + + var markers = yield observeStyling(5); + + is(markers.length, 0, + 'Animations running on the compositor in an out-of-view element ' + + 'should never cause restyles'); + yield ensureElementRemoval(div); + }); + + add_task(function* no_restyling_main_thread_animations_out_of_view_element() { + if (!SpecialPowers.getBoolPref('dom.animations.offscreen-throttling')) { + return; + } + + var div = addDiv(null, + { style: 'animation: background-color 100s; transform: translateY(-400px);' }); + var animation = div.getAnimations()[0]; + + yield animation.ready; + var markers = yield observeStyling(5); + + is(markers.length, 0, + 'Animations running on the main-thread in an out-of-view element ' + + 'should never cause restyles'); + yield ensureElementRemoval(div); + }); + + add_task_if_omta_enabled(function* no_restyling_compositor_animations_in_scrolled_out_element() { + if (!SpecialPowers.getBoolPref('dom.animations.offscreen-throttling')) { + return; + } + + /* + On Android the opacity animation runs on the compositor even if it is + scrolled out of view. We will fix this in bug 1247800. + */ + if (isAndroid) { + return; + } + var parentElement = addDiv(null, + { style: 'overflow-y: scroll; height: 20px;' }); + var div = addDiv(null, + { style: 'animation: opacity 100s; position: relative; top: 100px;' }); + parentElement.appendChild(div); + var animation = div.getAnimations()[0]; + + yield animation.ready; + + var markers = yield observeStyling(5); + + is(markers.length, 0, + 'Animations running on the compositor for elements ' + + 'which are scrolled out should never cause restyles'); + + yield ensureElementRemoval(parentElement); + }); + + add_task(function* no_restyling_main_thread_animations_in_scrolled_out_element() { + if (!SpecialPowers.getBoolPref('dom.animations.offscreen-throttling')) { + return; + } + + /* + On Android throttled animations are left behind on the main thread in some + frames, We will fix this in bug 1247800. + */ + if (isAndroid) { + return; + } + + var parentElement = addDiv(null, + { style: 'overflow-y: scroll; height: 20px;' }); + var div = addDiv(null, + { style: 'animation: background-color 100s; position: relative; top: 100px;' }); + parentElement.appendChild(div); + var animation = div.getAnimations()[0]; + + yield animation.ready; + var markers = yield observeStyling(5); + + is(markers.length, 0, + 'Animations running on the main-thread for elements ' + + 'which are scrolled out should never cause restyles'); + + yield ensureElementRemoval(parentElement); + }); + + add_task(function* no_restyling_main_thread_animations_in_nested_scrolled_out_element() { + if (!SpecialPowers.getBoolPref('dom.animations.offscreen-throttling')) { + return; + } + + /* + On Android throttled animations are left behind on the main thread in some + frames, We will fix this in bug 1247800. + */ + if (isAndroid) { + return; + } + + var grandParent = addDiv(null, + { style: 'overflow-y: scroll; height: 20px;' }); + var parentElement = addDiv(null, + { style: 'overflow-y: scroll; height: 100px;' }); + var div = addDiv(null, + { style: 'animation: background-color 100s; position: relative; top: 100px;' }); + grandParent.appendChild(parentElement); + parentElement.appendChild(div); + var animation = div.getAnimations()[0]; + + yield animation.ready; + var markers = yield observeStyling(5); + + is(markers.length, 0, + 'Animations running on the main-thread which are in nested elements ' + + 'which are scrolled out should never cause restyles'); + + yield ensureElementRemoval(grandParent); + }); + + add_task_if_omta_enabled(function* no_restyling_compositor_animations_in_visiblily_hidden_element() { + var div = addDiv(null, + { style: 'animation: opacity 100s; visibility: hidden' }); + var animation = div.getAnimations()[0]; + + yield animation.ready; + ok(!animation.isRunningOnCompositor); + + var markers = yield observeStyling(5); + + todo_is(markers.length, 0, + 'Bug 1237454: Animations running on the compositor in ' + + 'visibility hidden element should never cause restyles'); + yield ensureElementRemoval(div); + }); + + add_task(function* restyling_main_thread_animations_moved_in_view_by_scrolling() { + if (!SpecialPowers.getBoolPref('dom.animations.offscreen-throttling')) { + return; + } + + /* + On Android throttled animations are left behind on the main thread in some + frames, We will fix this in bug 1247800. + */ + if (isAndroid) { + return; + } + + var parentElement = addDiv(null, + { style: 'overflow-y: scroll; height: 20px;' }); + var div = addDiv(null, + { style: 'animation: background-color 100s; position: relative; top: 100px;' }); + parentElement.appendChild(div); + var animation = div.getAnimations()[0]; + + var parentRect = parentElement.getBoundingClientRect(); + var centerX = parentRect.left + parentRect.width / 2; + var centerY = parentRect.top + parentRect.height / 2; + + yield animation.ready; + + var markers = yield observeStyling(1, function() { + // We can't use synthesizeWheel here since synthesizeWheel causes + // layout flush. + synthesizeWheelAtPoint(centerX, centerY, + { deltaMode: WheelEvent.DOM_DELTA_PIXEL, + deltaY: 100 }); + }); + + is(markers.length, 1, + 'Animations running on the main-thread which were in scrolled out ' + + 'elements should update restyling soon after the element moved in ' + + 'view by scrolling'); + + yield ensureElementRemoval(parentElement); + }); + + add_task(function* restyling_main_thread_animations_moved_in_view_by_scrolling() { + if (!SpecialPowers.getBoolPref('dom.animations.offscreen-throttling')) { + return; + } + + var grandParent = addDiv(null, + { style: 'overflow-y: scroll; height: 20px;' }); + var parentElement = addDiv(null, + { style: 'overflow-y: scroll; height: 200px;' }); + var div = addDiv(null, + { style: 'animation: background-color 100s; position: relative; top: 100px;' }); + grandParent.appendChild(parentElement); + parentElement.appendChild(div); + var animation = div.getAnimations()[0]; + + var parentRect = grandParent.getBoundingClientRect(); + var centerX = parentRect.left + parentRect.width / 2; + var centerY = parentRect.top + parentRect.height / 2; + + yield animation.ready; + + var markers = yield observeStyling(1, function() { + // We can't use synthesizeWheel here since synthesizeWheel causes + // layout flush. + synthesizeWheelAtPoint(centerX, centerY, + { deltaMode: WheelEvent.DOM_DELTA_PIXEL, + deltaY: 100 }); + }); + + // FIXME: We should reduce a redundant restyle here. + ok(markers.length >= 1, + 'Animations running on the main-thread which were in nested scrolled ' + + 'out elements should update restyle soon after the element moved ' + + 'in view by scrolling'); + + yield ensureElementRemoval(grandParent); + }); + + add_task(function* restyling_main_thread_animations_move_out_of_view_by_scrolling() { + if (!SpecialPowers.getBoolPref('dom.animations.offscreen-throttling')) { + return; + } + + /* + On Android throttled animations are left behind on the main thread in some + frames, We will fix this in bug 1247800. + */ + if (isAndroid) { + return; + } + + var parentElement = addDiv(null, + { style: 'overflow-y: scroll; height: 200px;' }); + var div = addDiv(null, + { style: 'animation: background-color 100s;' }); + var pad = addDiv(null, + { style: 'height: 400px;' }); + parentElement.appendChild(div); + parentElement.appendChild(pad); + var animation = div.getAnimations()[0]; + + var parentRect = parentElement.getBoundingClientRect(); + var centerX = parentRect.left + parentRect.width / 2; + var centerY = parentRect.top + parentRect.height / 2; + + yield animation.ready; + + // We can't use synthesizeWheel here since synthesizeWheel causes + // layout flush. + synthesizeWheelAtPoint(centerX, centerY, + { deltaMode: WheelEvent.DOM_DELTA_PIXEL, + deltaY: 200 }); + + var markers = yield observeStyling(5); + + // FIXME: We should reduce a redundant restyle here. + ok(markers.length >= 0, + 'Animations running on the main-thread which are in scrolled out ' + + 'elements should throttle restyling'); + + yield ensureElementRemoval(parentElement); + }); + + add_task(function* restyling_main_thread_animations_moved_in_view_by_resizing() { + if (!SpecialPowers.getBoolPref('dom.animations.offscreen-throttling')) { + return; + } + + var parentElement = addDiv(null, + { style: 'overflow-y: scroll; height: 20px;' }); + var div = addDiv(null, + { style: 'animation: background-color 100s; position: relative; top: 100px;' }); + parentElement.appendChild(div); + var animation = div.getAnimations()[0]; + + yield animation.ready; + + var markers = yield observeStyling(1, function() { + parentElement.style.height = '100px'; + }); + + is(markers.length, 1, + 'Animations running on the main-thread which was in scrolled out ' + + 'elements should update restyling soon after the element moved in ' + + 'view by resizing'); + + yield ensureElementRemoval(parentElement); + }); + + add_task(function* no_restyling_main_thread_animations_in_visiblily_hidden_element() { + var div = addDiv(null, + { style: 'animation: background-color 100s; visibility: hidden' }); + var animation = div.getAnimations()[0]; + + yield animation.ready; + var markers = yield observeStyling(5); + + todo_is(markers.length, 0, + 'Bug 1237454: Animations running on the main-thread in ' + + 'visibility hidden element should never cause restyles'); + yield ensureElementRemoval(div); + }); + + add_task_if_omta_enabled(function* no_restyling_compositor_animations_after_pause_is_called() { + var div = addDiv(null, { style: 'animation: opacity 100s' }); + var animation = div.getAnimations()[0]; + + yield animation.ready; + ok(animation.isRunningOnCompositor); + + animation.pause(); + + yield animation.ready; + + var markers = yield observeStyling(5); + is(markers.length, 0, + 'Bug 1232563: Paused animations running on the compositor should ' + + 'never cause restyles once after pause() is called'); + yield ensureElementRemoval(div); + }); + + add_task(function* no_restyling_main_thread_animations_after_pause_is_called() { + var div = addDiv(null, { style: 'animation: background-color 100s' }); + var animation = div.getAnimations()[0]; + + yield animation.ready; + + animation.pause(); + + yield animation.ready; + + var markers = yield observeStyling(5); + is(markers.length, 0, + 'Bug 1232563: Paused animations running on the main-thread should ' + + 'never cause restyles after pause() is called'); + yield ensureElementRemoval(div); + }); + + add_task_if_omta_enabled(function* only_one_restyling_when_current_time_is_set_to_middle_of_duration() { + var div = addDiv(null, { style: 'animation: opacity 100s' }); + var animation = div.getAnimations()[0]; + + yield animation.ready; + + animation.currentTime = 50 * MS_PER_SEC; + + var markers = yield observeStyling(5); + is(markers.length, 1, + 'Bug 1235478: Animations running on the compositor should only once ' + + 'update style when currentTime is set to middle of duration time'); + yield ensureElementRemoval(div); + }); + + add_task_if_omta_enabled(function* change_duration_and_currenttime() { + var div = addDiv(null); + var animation = div.animate({ opacity: [ 0, 1 ] }, 100 * MS_PER_SEC); + + yield animation.ready; + ok(animation.isRunningOnCompositor); + + // Set currentTime to a time longer than duration. + animation.currentTime = 500 * MS_PER_SEC; + + // Now the animation immediately get back from compositor. + ok(!animation.isRunningOnCompositor); + + // Extend the duration. + animation.effect.timing.duration = 800 * MS_PER_SEC; + var markers = yield observeStyling(5); + is(markers.length, 1, + 'Animations running on the compositor should update style ' + + 'when timing.duration is made longer than the current time'); + + yield ensureElementRemoval(div); + }); + + add_task(function* script_animation_on_display_none_element() { + var div = addDiv(null); + var animation = div.animate({ backgroundColor: [ 'red', 'blue' ] }, + 100 * MS_PER_SEC); + + yield animation.ready; + + div.style.display = 'none'; + + // We need to wait a frame to apply display:none style. + yield waitForFrame(); + + is(animation.playState, 'running', + 'Script animations keep running even when the target element has ' + + '"display: none" style'); + + ok(!animation.isRunningOnCompositor, + 'Script animations on "display:none" element should not run on the ' + + 'compositor'); + + var markers = yield observeStyling(5); + is(markers.length, 0, + 'Script animations on "display: none" element should not update styles'); + + div.style.display = ''; + + // We need to wait a frame to unapply display:none style. + yield waitForFrame(); + + var markers = yield observeStyling(5); + is(markers.length, 5, + 'Script animations restored from "display: none" state should update ' + + 'styles'); + + yield ensureElementRemoval(div); + }); + + add_task_if_omta_enabled(function* compositable_script_animation_on_display_none_element() { + var div = addDiv(null); + var animation = div.animate({ opacity: [ 0, 1 ] }, 100 * MS_PER_SEC); + + yield animation.ready; + + div.style.display = 'none'; + + // We need to wait a frame to apply display:none style. + yield waitForFrame(); + + is(animation.playState, 'running', + 'Opacity script animations keep running even when the target element ' + + 'has "display: none" style'); + + ok(!animation.isRunningOnCompositor, + 'Opacity script animations on "display:none" element should not ' + + 'run on the compositor'); + + var markers = yield observeStyling(5); + is(markers.length, 0, + 'Opacity script animations on "display: none" element should not ' + + 'update styles'); + + div.style.display = ''; + + // We need to wait a frame to unapply display:none style. + yield waitForFrame(); + + ok(animation.isRunningOnCompositor, + 'Opacity script animations restored from "display: none" should be ' + + 'run on the compositor'); + + yield ensureElementRemoval(div); + }); + + add_task(function* restyling_for_empty_keyframes() { + var div = addDiv(null); + var animation = div.animate({ }, 100 * MS_PER_SEC); + + yield animation.ready; + var markers = yield observeStyling(5); + + is(markers.length, 0, + 'Animations with no keyframes should not cause restyles'); + + animation.effect.setKeyframes({ backgroundColor: ['red', 'blue'] }); + markers = yield observeStyling(5); + + is(markers.length, 5, + 'Setting valid keyframes should cause regular animation restyles to ' + + 'occur'); + + animation.effect.setKeyframes({ }); + markers = yield observeStyling(5); + + is(markers.length, 1, + 'Setting an empty set of keyframes should trigger a single restyle ' + + 'to remove the previous animated style'); + + yield ensureElementRemoval(div); + }); + + add_task_if_omta_enabled(function* no_restyling_when_animation_style_when_re_setting_same_animation_property() { + var div = addDiv(null, { style: 'animation: opacity 100s' }); + var animation = div.getAnimations()[0]; + yield animation.ready; + ok(animation.isRunningOnCompositor); + // Apply the same animation style + div.style.animation = 'opacity 100s'; + var markers = yield observeStyling(5); + is(markers.length, 0, + 'Applying same animation style ' + + 'should never cause restyles'); + yield ensureElementRemoval(div); + }); + + add_task(function* necessary_update_should_be_invoked() { + var div = addDiv(null, { style: 'animation: background-color 100s' }); + var animation = div.getAnimations()[0]; + yield animation.ready; + yield waitForAnimationFrames(5); + // Apply another animation style + div.style.animation = 'background-color 110s'; + var animation = div.getAnimations()[0]; + var markers = yield observeStyling(5); + is(markers.length, 5, + 'Applying animation style with different duration ' + + 'should cause restyles on every frame.'); + yield ensureElementRemoval(div); + }); + + add_task_if_omta_enabled( + function* changing_cascading_result_for_main_thread_animation() { + var div = addDiv(null, { style: 'background-color: blue' }); + var animation = div.animate({ opacity: [0, 1], + backgroundColor: ['green', 'red'] }, + 100 * MS_PER_SEC); + yield animation.ready; + ok(animation.isRunningOnCompositor, + 'The opacity animation is running on the compositor'); + // Make the background-color style as !important to cause an update + // to the cascade. + // Bug 1300982: The background-color animation should be no longer + // running on the main thread. + div.style.setProperty('background-color', '1', 'important'); + var markers = yield observeStyling(5); + todo_is(markers.length, 0, + 'Changing cascading result for the property running on the main ' + + 'thread does not cause synchronization layer of opacity animation ' + + 'running on the compositor'); + yield ensureElementRemoval(div); + } + ); + + add_task(function* restyling_for_animation_on_orphaned_element() { + var div = addDiv(null); + var animation = div.animate({ marginLeft: [ '0px', '100px' ] }, + 100 * MS_PER_SEC); + + yield animation.ready; + + div.remove(); + + var markers = yield observeStyling(5); + is(markers.length, 0, + 'Animation on orphaned element should not cause restyles'); + + document.body.appendChild(div); + + markers = yield observeStyling(1); + // We are observing restyles in rAF callback which is processed before + // restyling process in each frame, so in the first frame there should be + // no observed restyle since we don't process restyle while the element + // is not attached to the document. + is(markers.length, 0, + 'We observe no restyle in the first frame right after re-atatching ' + + 'to the document'); + markers = yield observeStyling(5); + is(markers.length, 5, + 'Animation on re-attached to the document begins to update style'); + + yield ensureElementRemoval(div); + }); + + add_task_if_omta_enabled( + // Tests that if we remove an element from the document whose animation + // cascade needs recalculating, that it is correctly updated when it is + // re-attached to the document. + function* restyling_for_opacity_animation_on_re_attached_element() { + var div = addDiv(null, { style: 'opacity: 1 ! important' }); + var animation = div.animate({ opacity: [0, 1] }, 100 * MS_PER_SEC); + + yield animation.ready; + ok(!animation.isRunningOnCompositor, + 'The opacity animation overridden by an !important rule is NOT ' + + 'running on the compositor'); + + // Drop the !important rule to update the cascade. + div.style.setProperty('opacity', '1', ''); + + div.remove(); + + var markers = yield observeStyling(5); + is(markers.length, 0, + 'Opacity animation on orphaned element should not cause restyles'); + + document.body.appendChild(div); + + // Need a frame to give the animation a chance to be sent to the + // compositor. + yield waitForFrame(); + + ok(animation.isRunningOnCompositor, + 'The opacity animation which is no longer overridden by the ' + + '!important rule begins running on the compositor even if the ' + + '!important rule had been dropped before the target element was ' + + 'removed'); + + yield ensureElementRemoval(div); + } + ); + +}); + +</script> +</body> diff --git a/dom/animation/test/chrome/test_running_on_compositor.html b/dom/animation/test/chrome/test_running_on_compositor.html new file mode 100644 index 000000000..cd6c679b8 --- /dev/null +++ b/dom/animation/test/chrome/test_running_on_compositor.html @@ -0,0 +1,966 @@ +<!doctype html> +<head> +<meta charset=utf-8> +<title>Bug 1045994 - Add a chrome-only property to inspect if an animation is + running on the compositor or not</title> +<script type="application/javascript" src="../testharness.js"></script> +<script type="application/javascript" src="../testharnessreport.js"></script> +<script type="application/javascript" src="../testcommon.js"></script> +<style> +@keyframes anim { + to { transform: translate(100px) } +} +@keyframes transform-starts-with-none { + 0% { transform: none } + 99% { transform: none } + 100% { transform: translate(100px) } +} +@keyframes opacity { + to { opacity: 0 } +} +@keyframes background_and_translate { + to { background-color: red; transform: translate(100px); } +} +@keyframes background { + to { background-color: red; } +} +@keyframes rotate { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} +@keyframes rotate-and-opacity { + from { transform: rotate(0deg); opacity: 1;} + to { transform: rotate(360deg); opacity: 0;} +} +div { + /* Element needs geometry to be eligible for layerization */ + width: 100px; + height: 100px; + background-color: white; +} +</style> +</head> +<body> +<a href="https://bugzilla.mozilla.org/show_bug.cgi?id=1045994" + target="_blank">Mozilla Bug 1045994</a> +<div id="log"></div> +<script> +'use strict'; + +/** Test for bug 1045994 - Add a chrome-only property to inspect if an + animation is running on the compositor or not **/ + +var omtaEnabled = isOMTAEnabled(); + +function assert_animation_is_running_on_compositor(animation, desc) { + assert_equals(animation.isRunningOnCompositor, omtaEnabled, + desc + ' at ' + animation.currentTime + 'ms'); +} + +function assert_animation_is_not_running_on_compositor(animation, desc) { + assert_equals(animation.isRunningOnCompositor, false, + desc + ' at ' + animation.currentTime + 'ms'); +} + +promise_test(function(t) { + // FIXME: When we implement Element.animate, use that here instead of CSS + // so that we remove any dependency on the CSS mapping. + var div = addDiv(t, { style: 'animation: anim 100s' }); + var animation = div.getAnimations()[0]; + + return animation.ready.then(function() { + assert_animation_is_running_on_compositor(animation, + 'Animation reports that it is running on the compositor' + + ' during playback'); + + div.style.animationPlayState = 'paused'; + + return animation.ready; + }).then(function() { + assert_animation_is_not_running_on_compositor(animation, + 'Animation reports that it is NOT running on the compositor' + + ' when paused'); + }); +}, ''); + +promise_test(function(t) { + var div = addDiv(t, { style: 'animation: background 100s' }); + var animation = div.getAnimations()[0]; + + return animation.ready.then(function() { + assert_animation_is_not_running_on_compositor(animation, + 'Animation reports that it is NOT running on the compositor' + + ' for animation of "background"'); + }); +}, 'isRunningOnCompositor is false for animation of "background"'); + +promise_test(function(t) { + var div = addDiv(t, { style: 'animation: background_and_translate 100s' }); + var animation = div.getAnimations()[0]; + + return animation.ready.then(function() { + assert_animation_is_running_on_compositor(animation, + 'Animation reports that it is running on the compositor' + + ' when the animation has two properties, where one can run' + + ' on the compositor, the other cannot'); + }); +}, 'isRunningOnCompositor is true if the animation has at least one ' + + 'property can run on compositor'); + +promise_test(function(t) { + var div = addDiv(t, { style: 'animation: anim 100s' }); + var animation = div.getAnimations()[0]; + + return animation.ready.then(function() { + animation.pause(); + return animation.ready; + }).then(function() { + assert_animation_is_not_running_on_compositor(animation, + 'Animation reports that it is NOT running on the compositor' + + ' when animation.pause() is called'); + }); +}, 'isRunningOnCompositor is false when the animation.pause() is called'); + +promise_test(function(t) { + var div = addDiv(t, { style: 'animation: anim 100s' }); + var animation = div.getAnimations()[0]; + + return animation.ready.then(function() { + animation.finish(); + assert_animation_is_not_running_on_compositor(animation, + 'Animation reports that it is NOT running on the compositor' + + ' immediately after animation.finish() is called'); + // Check that we don't set the flag back again on the next tick. + return waitForFrame(); + }).then(function() { + assert_animation_is_not_running_on_compositor(animation, + 'Animation reports that it is NOT running on the compositor' + + ' on the next tick after animation.finish() is called'); + }); +}, 'isRunningOnCompositor is false when the animation.finish() is called'); + +promise_test(function(t) { + var div = addDiv(t, { style: 'animation: anim 100s' }); + var animation = div.getAnimations()[0]; + + return animation.ready.then(function() { + animation.currentTime = 100 * MS_PER_SEC; + assert_animation_is_not_running_on_compositor(animation, + 'Animation reports that it is NOT running on the compositor' + + ' immediately after manually seeking the animation to the end'); + // Check that we don't set the flag back again on the next tick. + return waitForFrame(); + }).then(function() { + assert_animation_is_not_running_on_compositor(animation, + 'Animation reports that it is NOT running on the compositor' + + ' on the next tick after manually seeking the animation to the end'); + }); +}, 'isRunningOnCompositor is false when manually seeking the animation to ' + + 'the end'); + +promise_test(function(t) { + var div = addDiv(t, { style: 'animation: anim 100s' }); + var animation = div.getAnimations()[0]; + + return animation.ready.then(function() { + animation.cancel(); + assert_animation_is_not_running_on_compositor(animation, + 'Animation reports that it is NOT running on the compositor' + + ' immediately after animation.cancel() is called'); + // Check that we don't set the flag back again on the next tick. + return waitForFrame(); + }).then(function() { + assert_animation_is_not_running_on_compositor(animation, + 'Animation reports that it is NOT running on the compositor' + + ' on the next tick after animation.cancel() is called'); + }); +}, 'isRunningOnCompositor is false when animation.cancel() is called'); + +// This is to test that we don't simply clobber the flag when ticking +// animations and then set it again during painting. +promise_test(function(t) { + var div = addDiv(t, { style: 'animation: anim 100s' }); + var animation = div.getAnimations()[0]; + + return animation.ready.then(function() { + return new Promise(function(resolve) { + window.requestAnimationFrame(function() { + t.step(function() { + assert_animation_is_running_on_compositor(animation, + 'Animation reports that it is running on the compositor' + + ' in requestAnimationFrame callback'); + }); + + resolve(); + }); + }); + }); +}, 'isRunningOnCompositor is true in requestAnimationFrame callback'); + +promise_test(function(t) { + var div = addDiv(t, { style: 'animation: anim 100s' }); + var animation = div.getAnimations()[0]; + + return animation.ready.then(function() { + return new Promise(function(resolve) { + var observer = new MutationObserver(function(records) { + var changedAnimation; + + records.forEach(function(record) { + changedAnimation = + record.changedAnimations.find(function(changedAnim) { + return changedAnim == animation; + }); + }); + + t.step(function() { + assert_true(!!changedAnimation, 'The animation should be recorded ' + + 'as one of the changedAnimations'); + + assert_animation_is_running_on_compositor(animation, + 'Animation reports that it is running on the compositor' + + ' in MutationObserver callback'); + }); + + resolve(); + }); + observer.observe(div, { animations: true, subtree: false }); + t.add_cleanup(function() { + observer.disconnect(); + }); + div.style.animationDuration = "200s"; + }); + }); +}, 'isRunningOnCompositor is true in MutationObserver callback'); + +// This is to test that we don't temporarily clear the flag when forcing +// an unthrottled sample. +promise_test(function(t) { + return new Promise(function(resolve) { + // Needs scrollbars to cause overflow. + SpecialPowers.pushPrefEnv({ set: [["ui.showHideScrollbars", 1]] }, + resolve); + }).then(function() { + var div = addDiv(t, { style: 'animation: rotate 100s' }); + var animation = div.getAnimations()[0]; + + return animation.ready.then(function() { + return new Promise(function(resolve) { + var timeAtStart = window.performance.now(); + function handleFrame() { + t.step(function() { + assert_animation_is_running_on_compositor(animation, + 'Animation reports that it is running on the compositor' + + ' in requestAnimationFrame callback'); + }); + + // we have to wait at least 200ms because this animation is + // unthrottled on every 200ms. + // See http://hg.mozilla.org/mozilla-central/file/cafb1c90f794/layout/style/AnimationCommon.cpp#l863 + if (window.performance.now() - timeAtStart > 200) { + resolve(); + return; + } + window.requestAnimationFrame(handleFrame); + } + window.requestAnimationFrame(handleFrame); + }); + }); + }); +}, 'isRunningOnCompositor remains true in requestAnimationFrameCallback for ' + + 'overflow animation'); + +promise_test(function(t) { + var div = addDiv(t, { style: 'transition: opacity 100s; opacity: 1' }); + + getComputedStyle(div).opacity; + + div.style.opacity = 0; + var animation = div.getAnimations()[0]; + + return animation.ready.then(function() { + assert_animation_is_running_on_compositor(animation, + 'Transition reports that it is running on the compositor' + + ' during playback for opacity transition'); + }); +}, 'isRunningOnCompositor for transitions'); + +promise_test(function(t) { + var div = addDiv(t, { style: 'animation: rotate-and-opacity 100s; ' + + 'backface-visibility: hidden; ' + + 'transform: none !important;' }); + var animation = div.getAnimations()[0]; + + return animation.ready.then(function() { + assert_animation_is_running_on_compositor(animation, + 'If an animation has a property that can run on the compositor and a ' + + 'property that cannot (due to Gecko limitations) but where the latter' + + 'property is overridden in the CSS cascade, the animation should ' + + 'still report that it is running on the compositor'); + }); +}, 'isRunningOnCompositor is true when a property that would otherwise block ' + + 'running on the compositor is overridden in the CSS cascade'); + +promise_test(function(t) { + var animation = addDivAndAnimate(t, + {}, + { opacity: [ 0, 1 ] }, 200 * MS_PER_SEC); + + return animation.ready.then(function() { + assert_animation_is_running_on_compositor(animation, + 'Animation reports that it is running on the compositor'); + + animation.currentTime = 150 * MS_PER_SEC; + animation.effect.timing.duration = 100 * MS_PER_SEC; + + assert_animation_is_not_running_on_compositor(animation, + 'Animation reports that it is NOT running on the compositor' + + ' when the animation is set a shorter duration than current time'); + }); +}, 'animation is immediately removed from compositor' + + 'when timing.duration is made shorter than the current time'); + +promise_test(function(t) { + var animation = addDivAndAnimate(t, + {}, + { opacity: [ 0, 1 ] }, 100 * MS_PER_SEC); + + return animation.ready.then(function() { + assert_animation_is_running_on_compositor(animation, + 'Animation reports that it is running on the compositor'); + + animation.currentTime = 500 * MS_PER_SEC; + + assert_animation_is_not_running_on_compositor(animation, + 'Animation reports that it is NOT running on the compositor' + + ' when finished'); + + animation.effect.timing.duration = 1000 * MS_PER_SEC; + return waitForFrame(); + }).then(function() { + assert_animation_is_running_on_compositor(animation, + 'Animation reports that it is running on the compositor' + + ' when restarted'); + }); +}, 'animation is added to compositor' + + ' when timing.duration is made longer than the current time'); + +promise_test(function(t) { + var animation = addDivAndAnimate(t, + {}, + { opacity: [ 0, 1 ] }, 100 * MS_PER_SEC); + + return animation.ready.then(function() { + assert_animation_is_running_on_compositor(animation, + 'Animation reports that it is running on the compositor'); + + animation.effect.timing.endDelay = 100 * MS_PER_SEC; + + assert_animation_is_running_on_compositor(animation, + 'Animation reports that it is running on the compositor' + + ' when endDelay is changed'); + + animation.currentTime = 110 * MS_PER_SEC; + return waitForFrame(); + }).then(function() { + assert_animation_is_not_running_on_compositor(animation, + 'Animation reports that it is NOT running on the compositor' + + ' when currentTime is during endDelay'); + }); +}, 'animation is removed from compositor' + + ' when current time is made longer than the duration even during endDelay'); + +promise_test(function(t) { + var animation = addDivAndAnimate(t, + {}, + { opacity: [ 0, 1 ] }, 100 * MS_PER_SEC); + + return animation.ready.then(function() { + assert_animation_is_running_on_compositor(animation, + 'Animation reports that it is running on the compositor'); + + animation.effect.timing.endDelay = -200 * MS_PER_SEC; + return waitForFrame(); + }).then(function() { + assert_animation_is_not_running_on_compositor(animation, + 'Animation reports that it is NOT running on the compositor' + + ' when endTime is negative value'); + }); +}, 'animation is removed from compositor' + + ' when endTime is negative value'); + +promise_test(function(t) { + var animation = addDivAndAnimate(t, + {}, + { opacity: [ 0, 1 ] }, 200 * MS_PER_SEC); + + return animation.ready.then(function() { + assert_animation_is_running_on_compositor(animation, + 'Animation reports that it is running on the compositor'); + + animation.effect.timing.endDelay = -100 * MS_PER_SEC; + return waitForFrame(); + }).then(function() { + assert_animation_is_running_on_compositor(animation, + 'Animation reports that it is running on the compositor' + + ' when endTime is positive and endDelay is negative'); + animation.currentTime = 110 * MS_PER_SEC; + return waitForFrame(); + }).then(function() { + assert_animation_is_not_running_on_compositor(animation, + 'Animation reports that it is NOT running on the compositor' + + ' when currentTime is after endTime'); + }); +}, 'animation is NOT running on compositor' + + ' when endTime is positive and endDelay is negative'); + +promise_test(function(t) { + var effect = new KeyframeEffect(null, + { opacity: [ 0, 1 ] }, + 100 * MS_PER_SEC); + var animation = new Animation(effect, document.timeline); + animation.play(); + + var div = addDiv(t); + + return animation.ready.then(function() { + assert_animation_is_not_running_on_compositor(animation, + 'Animation with null target reports that it is not running ' + + 'on the compositor'); + + animation.effect.target = div; + return waitForFrame(); + }).then(function() { + assert_animation_is_running_on_compositor(animation, + 'Animation reports that it is running on the compositor ' + + 'after setting a valid target'); + }); +}, 'animation is added to the compositor when setting a valid target'); + +promise_test(function(t) { + var div = addDiv(t); + var animation = div.animate({ opacity: [ 0, 1 ] }, 100 * MS_PER_SEC); + + return animation.ready.then(function() { + assert_animation_is_running_on_compositor(animation, + 'Animation reports that it is running on the compositor'); + + animation.effect.target = null; + assert_animation_is_not_running_on_compositor(animation, + 'Animation reports that it is NOT running on the ' + + 'compositor after setting null target'); + }); +}, 'animation is removed from the compositor when setting null target'); + +promise_test(function(t) { + var div = addDiv(t); + var animation = div.animate({ opacity: [ 0, 1 ] }, + { duration: 100 * MS_PER_SEC, + delay: 100 * MS_PER_SEC, + fill: 'backwards' }); + + return animation.ready.then(function() { + assert_animation_is_running_on_compositor(animation, + 'Animation with fill:backwards in delay phase reports ' + + 'that it is running on the compositor'); + + animation.currentTime = 100 * MS_PER_SEC; + return waitForFrame(); + }).then(function() { + assert_animation_is_running_on_compositor(animation, + 'Animation with fill:backwards in delay phase reports ' + + 'that it is running on the compositor after delay phase'); + }); +}, 'animation with fill:backwards in delay phase is running on the ' + + ' main-thread while it is in delay phase'); + +promise_test(function(t) { + var div = addDiv(t); + var animation = div.animate([{ opacity: 1, offset: 0 }, + { opacity: 1, offset: 0.99 }, + { opacity: 0, offset: 1 }], 100 * MS_PER_SEC); + + var another = addDiv(t); + + return animation.ready.then(function() { + assert_animation_is_running_on_compositor(animation, + 'Opacity animation on a 100% opacity keyframe reports ' + + 'that it is running on the compositor from the begining'); + + animation.effect.target = another; + return waitForFrame(); + }).then(function() { + assert_animation_is_running_on_compositor(animation, + 'Opacity animation on a 100% opacity keyframe keeps ' + + 'running on the compositor after changing the target ' + + 'element'); + }); +}, '100% opacity animations with keeps running on the ' + + 'compositor after changing the target element'); + +promise_test(function(t) { + var div = addDiv(t); + var animation = div.animate({ color: ['red', 'black'] }, 100 * MS_PER_SEC); + + return animation.ready.then(function() { + assert_animation_is_not_running_on_compositor(animation, + 'Color animation reports that it is not running on the ' + + 'compositor'); + + animation.effect.setKeyframes([{ opacity: 1, offset: 0 }, + { opacity: 1, offset: 0.99 }, + { opacity: 0, offset: 1 }]); + return waitForFrame(); + }).then(function() { + assert_animation_is_running_on_compositor(animation, + '100% opacity animation set by using setKeyframes reports ' + + 'that it is running on the compositor'); + }); +}, '100% opacity animation set up by converting an existing animation with ' + + 'cannot be run on the compositor, is running on the compositor'); + +promise_test(function(t) { + var div = addDiv(t); + var animation = div.animate({ color: ['red', 'black'] }, 100 * MS_PER_SEC); + var effect = new KeyframeEffect(div, + [{ opacity: 1, offset: 0 }, + { opacity: 1, offset: 0.99 }, + { opacity: 0, offset: 1 }], + 100 * MS_PER_SEC); + + return animation.ready.then(function() { + assert_animation_is_not_running_on_compositor(animation, + 'Color animation reports that it is not running on the ' + + 'compositor'); + + animation.effect = effect; + return waitForFrame(); + }).then(function() { + assert_animation_is_running_on_compositor(animation, + '100% opacity animation set up by changing effects reports ' + + 'that it is running on the compositor'); + }); +}, '100% opacity animation set up by changing the effects on an existing ' + + 'animation which cannot be run on the compositor, is running on the ' + + 'compositor'); + +promise_test(function(t) { + var div = addDiv(t, { style: "opacity: 1 ! important" }); + + var animation = div.animate({ opacity: [0, 1] }, 100 * MS_PER_SEC); + + return animation.ready.then(function() { + assert_animation_is_not_running_on_compositor(animation, + 'Opacity animation on an element which has 100% opacity style with ' + + '!important flag reports that it is not running on the compositor'); + // Clear important flag from the opacity style on the target element. + div.style.setProperty("opacity", "1", ""); + return waitForFrame(); + }).then(function() { + assert_animation_is_running_on_compositor(animation, + 'Opacity animation reports that it is running on the compositor after ' + + 'clearing the !important flag'); + }); +}, 'Clearing *important* opacity style on the target element sends the ' + + 'animation to the compositor'); + +promise_test(function(t) { + var div = addDiv(t); + var lowerAnimation = div.animate({ opacity: [1, 0] }, 100 * MS_PER_SEC); + var higherAnimation = div.animate({ opacity: [0, 1] }, 100 * MS_PER_SEC); + + return Promise.all([lowerAnimation.ready, higherAnimation.ready]).then(function() { + assert_animation_is_running_on_compositor(higherAnimation, + 'A higher-priority opacity animation on an element ' + + 'reports that it is running on the compositor'); + assert_animation_is_running_on_compositor(lowerAnimation, + 'A lower-priority opacity animation on the same ' + + 'element also reports that it is running on the compositor'); + }); +}, 'Opacity animations on the same element run on the compositor'); + +promise_test(function(t) { + var div = addDiv(t, { style: 'transition: opacity 100s; opacity: 1' }); + + getComputedStyle(div).opacity; + + div.style.opacity = 0; + getComputedStyle(div).opacity; + + var transition = div.getAnimations()[0]; + var animation = div.animate({ opacity: [0, 1] }, 100 * MS_PER_SEC); + + return Promise.all([transition.ready, animation.ready]).then(function() { + assert_animation_is_running_on_compositor(animation, + 'An opacity animation on an element reports that' + + 'that it is running on the compositor'); + assert_animation_is_running_on_compositor(transition, + 'An opacity transition on the same element reports that ' + + 'it is running on the compositor'); + }); +}, 'Both of transition and script animation on the same element run on the ' + + 'compositor'); + +promise_test(function(t) { + var div = addDiv(t); + var importantOpacityElement = addDiv(t, { style: "opacity: 1 ! important" }); + + var animation = div.animate({ opacity: [1, 0] }, 100 * MS_PER_SEC); + + return animation.ready.then(function() { + assert_animation_is_running_on_compositor(animation, + 'Opacity animation on an element reports ' + + 'that it is running on the compositor'); + + animation.effect.target = null; + return waitForFrame(); + }).then(function() { + assert_animation_is_not_running_on_compositor(animation, + 'Animation is no longer running on the compositor after ' + + 'removing from the element'); + animation.effect.target = importantOpacityElement; + return waitForFrame(); + }).then(function() { + assert_animation_is_not_running_on_compositor(animation, + 'Animation is NOT running on the compositor even after ' + + 'being applied to a different element which has an ' + + '!important opacity declaration'); + }); +}, 'Animation continues not running on the compositor after being ' + + 'applied to an element which has an important declaration and ' + + 'having previously been temporarily associated with no target element'); + +promise_test(function(t) { + var div = addDiv(t); + var another = addDiv(t); + + var lowerAnimation = div.animate({ opacity: [1, 0] }, 100 * MS_PER_SEC); + var higherAnimation = another.animate({ opacity: [0, 1] }, 100 * MS_PER_SEC); + + return Promise.all([lowerAnimation.ready, higherAnimation.ready]).then(function() { + assert_animation_is_running_on_compositor(lowerAnimation, + 'An opacity animation on an element reports that ' + + 'it is running on the compositor'); + assert_animation_is_running_on_compositor(higherAnimation, + 'Opacity animation on a different element reports ' + + 'that it is running on the compositor'); + + lowerAnimation.effect.target = null; + return waitForFrame(); + }).then(function() { + assert_animation_is_not_running_on_compositor(lowerAnimation, + 'Animation is no longer running on the compositor after ' + + 'being removed from the element'); + lowerAnimation.effect.target = another; + return waitForFrame(); + }).then(function() { + assert_animation_is_running_on_compositor(lowerAnimation, + 'A lower-priority animation begins running ' + + 'on the compositor after being applied to an element ' + + 'which has a higher-priority animation'); + assert_animation_is_running_on_compositor(higherAnimation, + 'A higher-priority animation continues to run on the ' + + 'compositor even after a lower-priority animation is ' + + 'applied to the same element'); + }); +}, 'Animation begins running on the compositor after being applied ' + + 'to an element which has a higher-priority animation and after ' + + 'being temporarily associated with no target element'); + +promise_test(function(t) { + var div = addDiv(t); + var another = addDiv(t); + + var lowerAnimation = div.animate({ opacity: [1, 0] }, 100 * MS_PER_SEC); + var higherAnimation = another.animate({ opacity: [0, 1] }, 100 * MS_PER_SEC); + + return Promise.all([lowerAnimation.ready, higherAnimation.ready]).then(function() { + assert_animation_is_running_on_compositor(lowerAnimation, + 'An opacity animation on an element reports that ' + + 'it is running on the compositor'); + assert_animation_is_running_on_compositor(higherAnimation, + 'Opacity animation on a different element reports ' + + 'that it is running on the compositor'); + + higherAnimation.effect.target = null; + return waitForFrame(); + }).then(function() { + assert_animation_is_not_running_on_compositor(higherAnimation, + 'Animation is no longer running on the compositor after ' + + 'being removed from the element'); + higherAnimation.effect.target = div; + return waitForFrame(); + }).then(function() { + assert_animation_is_running_on_compositor(lowerAnimation, + 'Animation continues running on the compositor after ' + + 'a higher-priority animation applied to the same element'); + assert_animation_is_running_on_compositor(higherAnimation, + 'A higher-priority animation begins to running on the ' + + 'compositor after being applied to an element which has ' + + 'a lower-priority-animation'); + }); +}, 'Animation begins running on the compositor after being applied ' + + 'to an element which has a lower-priority animation once after ' + + 'disassociating with an element'); + +var delayPhaseTests = [ + { + desc: 'script animation of opacity', + setupAnimation: function(t) { + return addDiv(t).animate( + { opacity: [0, 1] }, + { delay: 100 * MS_PER_SEC, duration: 100 * MS_PER_SEC }); + }, + }, + { + desc: 'script animation of transform', + setupAnimation: function(t) { + return addDiv(t).animate( + { transform: ['translateX(0px)', 'translateX(100px)'] }, + { delay: 100 * MS_PER_SEC, duration: 100 * MS_PER_SEC }); + }, + }, + { + desc: 'CSS animation of opacity', + setupAnimation: function(t) { + return addDiv(t, { style: 'animation: opacity 100s 100s' }) + .getAnimations()[0]; + }, + }, + { + desc: 'CSS animation of transform', + setupAnimation: function(t) { + return addDiv(t, { style: 'animation: anim 100s 100s' }) + .getAnimations()[0]; + }, + }, + { + desc: 'CSS transition of opacity', + setupAnimation: function(t) { + var div = addDiv(t, { style: 'transition: opacity 100s 100s' }); + getComputedStyle(div).opacity; + + div.style.opacity = 0; + return div.getAnimations()[0]; + }, + }, + { + desc: 'CSS transition of transform', + setupAnimation: function(t) { + var div = addDiv(t, { style: 'transition: transform 100s 100s' }); + getComputedStyle(div).transform; + + div.style.transform = 'translateX(100px)'; + return div.getAnimations()[0]; + }, + }, +]; + +delayPhaseTests.forEach(function(test) { + promise_test(function(t) { + var animation = test.setupAnimation(t); + + return animation.ready.then(function() { + assert_animation_is_running_on_compositor(animation, + test.desc + ' reports that it is running on the ' + + 'compositor even though it is in the delay phase'); + }); + }, 'isRunningOnCompositor for ' + test.desc + ' is true even though ' + + 'it is in the delay phase'); +}); + +// The purpose of thie test cases is to check that +// NS_FRAME_MAY_BE_TRANSFORMED flag on the associated nsIFrame persists +// after transform style on the frame is removed. +var delayPhaseWithTransformStyleTests = [ + { + desc: 'script animation of transform with transform style', + setupAnimation: function(t) { + return addDiv(t, { style: 'transform: translateX(10px)' }).animate( + { transform: ['translateX(0px)', 'translateX(100px)'] }, + { delay: 100 * MS_PER_SEC, duration: 100 * MS_PER_SEC }); + }, + }, + { + desc: 'CSS animation of transform with transform style', + setupAnimation: function(t) { + return addDiv(t, { style: 'animation: anim 100s 100s;' + + 'transform: translateX(10px)' }) + .getAnimations()[0]; + }, + }, + { + desc: 'CSS transition of transform with transform style', + setupAnimation: function(t) { + var div = addDiv(t, { style: 'transition: transform 100s 100s;' + + 'transform: translateX(10px)'}); + getComputedStyle(div).transform; + + div.style.transform = 'translateX(100px)'; + return div.getAnimations()[0]; + }, + }, +]; + +delayPhaseWithTransformStyleTests.forEach(function(test) { + promise_test(function(t) { + var animation = test.setupAnimation(t); + + return animation.ready.then(function() { + assert_animation_is_running_on_compositor(animation, + test.desc + ' reports that it is running on the ' + + 'compositor even though it is in the delay phase'); + }).then(function() { + // Remove the initial transform style during delay phase. + animation.effect.target.style.transform = 'none'; + return animation.ready; + }).then(function() { + assert_animation_is_running_on_compositor(animation, + test.desc + ' reports that it keeps running on the ' + + 'compositor after removing the initial transform style'); + }); + }, 'isRunningOnCompositor for ' + test.desc + ' is true after removing ' + + 'the initial transform style during the delay phase'); +}); + +var startsWithNoneTests = [ + { + desc: 'script animation of transform starts with transform:none segment', + setupAnimation: function(t) { + return addDiv(t).animate( + { transform: ['none', 'none', 'translateX(100px)'] }, 100 * MS_PER_SEC); + }, + }, + { + desc: 'CSS animation of transform starts with transform:none segment', + setupAnimation: function(t) { + return addDiv(t, + { style: 'animation: transform-starts-with-none 100s 100s' }) + .getAnimations()[0]; + }, + }, +]; + +startsWithNoneTests.forEach(function(test) { + promise_test(function(t) { + var animation = test.setupAnimation(t); + + return animation.ready.then(function() { + assert_animation_is_running_on_compositor(animation, + test.desc + ' reports that it is running on the ' + + 'compositor even though it is in transform:none segment'); + }); + }, 'isRunningOnCompositor for ' + test.desc + ' is true even though ' + + 'it is in transform:none segment'); +}); + +promise_test(function(t) { + var div = addDiv(t, { style: 'opacity: 1 ! important' }); + + var animation = div.animate( + { opacity: [0, 1] }, + { delay: 100 * MS_PER_SEC, duration: 100 * MS_PER_SEC }); + + return animation.ready.then(function() { + assert_animation_is_not_running_on_compositor(animation, + 'Opacity animation on an element which has opacity:1 important style' + + 'reports that it is not running on the compositor'); + // Clear the opacity style on the target element. + div.style.setProperty("opacity", "1", ""); + return waitForFrame(); + }).then(function() { + assert_animation_is_running_on_compositor(animation, + 'Opacity animations reports that it is running on the compositor after ' + + 'clearing the opacity style on the element'); + }); +}, 'Clearing *important* opacity style on the target element sends the ' + + 'animation to the compositor even if the animation is in the delay phase'); + +promise_test(function(t) { + var opaqueDiv = addDiv(t, { style: 'opacity: 1 ! important' }); + var anotherDiv = addDiv(t); + + var animation = opaqueDiv.animate( + { opacity: [0, 1] }, + { delay: 100 * MS_PER_SEC, duration: 100 * MS_PER_SEC }); + + return animation.ready.then(function() { + assert_animation_is_not_running_on_compositor(animation, + 'Opacity animation on an element which has opacity:1 important style' + + 'reports that it is not running on the compositor'); + // Changing target element to another element which has no opacity style. + animation.effect.target = anotherDiv; + return waitForFrame(); + }).then(function() { + assert_animation_is_running_on_compositor(animation, + 'Opacity animations reports that it is running on the compositor after ' + + 'changing the target element to another elemenent having no ' + + 'opacity style'); + }); +}, 'Changing target element of opacity animation sends the animation to the ' + + 'the compositor even if the animation is in the delay phase'); + +promise_test(function(t) { + var animation = + addDivAndAnimate(t, + {}, + { width: ['100px', '200px'] }, + { duration: 100 * MS_PER_SEC, delay: 100 * MS_PER_SEC }); + + return animation.ready.then(function() { + assert_animation_is_not_running_on_compositor(animation, + 'Width animation reports that it is not running on the compositor ' + + 'in the delay phase'); + // Changing to property runnable on the compositor. + animation.effect.setKeyframes({ opacity: [0, 1] }); + return waitForFrame(); + }).then(function() { + assert_animation_is_running_on_compositor(animation, + 'Opacity animation reports that it is running on the compositor ' + + 'after changing the property from width property in the delay phase'); + }); +}, 'Dynamic change to a property runnable on the compositor ' + + 'in the delay phase'); + +promise_test(function(t) { + var div = addDiv(t, { style: 'transition: opacity 100s; ' + + 'opacity: 0 !important' }); + getComputedStyle(div).opacity; + + div.style.setProperty('opacity', '1', 'important'); + getComputedStyle(div).opacity; + + var animation = div.getAnimations()[0]; + + return animation.ready.then(function() { + assert_animation_is_running_on_compositor(animation, + 'Transition reports that it is running on the compositor even if the ' + + 'property is overridden by an !important rule'); + }); +}, 'Transitions override important rules'); + +promise_test(function(t) { + var div = addDiv(t, { style: 'transition: opacity 100s; ' + + 'opacity: 0 !important' }); + getComputedStyle(div).opacity; + + div.animate({ opacity: [ 0, 1 ] }, 100 * MS_PER_SEC); + + div.style.setProperty('opacity', '1', 'important'); + getComputedStyle(div).opacity; + + var [transition, animation] = div.getAnimations(); + + return Promise.all([transition.ready, animation.ready]).then(function() { + assert_animation_is_not_running_on_compositor(transition, + 'Transition suppressed by an animation which is overridden by an ' + + '!important rule reports that it is NOT running on the compositor'); + assert_animation_is_not_running_on_compositor(animation, + 'Animation overridden by an !important rule reports that it is ' + + 'NOT running on the compositor'); + }); +}, 'Neither transition nor animation does run on the compositor if the ' + + 'property is overridden by an !important rule'); + +</script> +</body> diff --git a/dom/animation/test/crashtests/1216842-1.html b/dom/animation/test/crashtests/1216842-1.html new file mode 100644 index 000000000..8df6808ae --- /dev/null +++ b/dom/animation/test/crashtests/1216842-1.html @@ -0,0 +1,35 @@ +<!doctype html> +<html class="reftest-wait"> + <head> + <title>Bug 1216842: effect-level easing function produces negative values (compositor thread)</title> + <style> + #target { + width: 100px; height: 100px; + background: blue; + } + </style> + </head> + <body> + <div id="target"></div> + </body> + <script> + var target = document.getElementById("target"); + var effect = + new KeyframeEffectReadOnly( + target, + { opacity: [0, 1] }, + { + fill: "forwards", + /* The function produces negative values in (0, 0.766312060) */ + easing: "cubic-bezier(0,-0.5,1,-0.5)", + duration: 100, + iterations: 0.75 /* To finish in the extraporation range */ + } + ); + var animation = new Animation(effect, document.timeline); + animation.play(); + animation.finished.then(function() { + document.documentElement.className = ""; + }); + </script> +</html> diff --git a/dom/animation/test/crashtests/1216842-2.html b/dom/animation/test/crashtests/1216842-2.html new file mode 100644 index 000000000..ec2a2e167 --- /dev/null +++ b/dom/animation/test/crashtests/1216842-2.html @@ -0,0 +1,35 @@ +<!doctype html> +<html class="reftest-wait"> + <head> + <title>Bug 1216842: effect-level easing function produces values greater than 1 (compositor thread)</title> + <style> + #target { + width: 100px; height: 100px; + background: blue; + } + </style> + </head> + <body> + <div id="target"></div> + </body> + <script> + var target = document.getElementById("target"); + var effect = + new KeyframeEffectReadOnly( + target, + { opacity: [0, 1] }, + { + fill: "forwards", + /* The function produces values greater than 1 in (0.23368794, 1) */ + easing: "cubic-bezier(0,1.5,1,1.5)", + duration: 100, + iterations: 0.25 /* To finish in the extraporation range */ + } + ); + var animation = new Animation(effect, document.timeline); + animation.play(); + animation.finished.then(function() { + document.documentElement.className = ""; + }); + </script> +</html> diff --git a/dom/animation/test/crashtests/1216842-3.html b/dom/animation/test/crashtests/1216842-3.html new file mode 100644 index 000000000..2e5a762aa --- /dev/null +++ b/dom/animation/test/crashtests/1216842-3.html @@ -0,0 +1,27 @@ +<!doctype html> +<html class="reftest-wait"> + <head> + <title>Bug 1216842: effect-level easing function produces values greater than 1 (main-thread)</title> + </head> + <body> + <div id="target"></div> + </body> + <script> + var target = document.getElementById("target"); + var effect = + new KeyframeEffectReadOnly( + target, + { color: ["red", "blue"] }, + { + fill: "forwards", + /* The function produces values greater than 1 in (0.23368794, 1) */ + easing: "cubic-bezier(0,1.5,1,1.5)", + duration: 100 + } + ); + var animation = new Animation(effect, document.timeline); + animation.pause(); + animation.currentTime = 250; + document.documentElement.className = ""; + </script> +</html> diff --git a/dom/animation/test/crashtests/1216842-4.html b/dom/animation/test/crashtests/1216842-4.html new file mode 100644 index 000000000..2951adc95 --- /dev/null +++ b/dom/animation/test/crashtests/1216842-4.html @@ -0,0 +1,27 @@ +<!doctype html> +<html class="reftest-wait"> + <head> + <title>Bug 1216842: effect-level easing function produces negative values (main-thread)</title> + </head> + <body> + <div id="target"></div> + </body> + <script> + var target = document.getElementById("target"); + var effect = + new KeyframeEffectReadOnly( + target, + { color: ["red", "blue"] }, + { + fill: "forwards", + /* The function produces negative values in (0, 0.766312060) */ + easing: "cubic-bezier(0,-0.5,1,-0.5)", + duration: 100 + } + ); + var animation = new Animation(effect, document.timeline); + animation.pause(); + animation.currentTime = 250; + document.documentElement.className = ""; + </script> +</html> diff --git a/dom/animation/test/crashtests/1216842-5.html b/dom/animation/test/crashtests/1216842-5.html new file mode 100644 index 000000000..65c64fa48 --- /dev/null +++ b/dom/animation/test/crashtests/1216842-5.html @@ -0,0 +1,38 @@ +<!doctype html> +<html class="reftest-wait"> + <head> + <title> + Bug 1216842: effect-level easing function produces negative values passed + to step-end function (compositor thread) + </title> + <style> + #target { + width: 100px; height: 100px; + background: blue; + } + </style> + </head> + <body> + <div id="target"></div> + </body> + <script> + var target = document.getElementById("target"); + var effect = + new KeyframeEffectReadOnly( + target, + { opacity: [0, 1], easing: "step-end" }, + { + fill: "forwards", + /* The function produces negative values in (0, 0.766312060) */ + easing: "cubic-bezier(0,-0.5,1,-0.5)", + duration: 100, + iterations: 0.75 /* To finish in the extraporation range */ + } + ); + var animation = new Animation(effect, document.timeline); + animation.play(); + animation.finished.then(function() { + document.documentElement.className = ""; + }); + </script> +</html> diff --git a/dom/animation/test/crashtests/1216842-6.html b/dom/animation/test/crashtests/1216842-6.html new file mode 100644 index 000000000..a588c68f1 --- /dev/null +++ b/dom/animation/test/crashtests/1216842-6.html @@ -0,0 +1,38 @@ +<!doctype html> +<html class="reftest-wait"> + <head> + <title> + Bug 1216842: effect-level easing function produces values greater than 1 + which are passed to step-end function (compositor thread) + </title> + <style> + #target { + width: 100px; height: 100px; + background: blue; + } + </style> + </head> + <body> + <div id="target"></div> + </body> + <script> + var target = document.getElementById("target"); + var effect = + new KeyframeEffectReadOnly( + target, + { opacity: [0, 1], easing: "step-end" }, + { + fill: "forwards", + /* The function produces values greater than 1 in (0.23368794, 1) */ + easing: "cubic-bezier(0,1.5,1,1.5)", + duration: 100, + iterations: 0.25 /* To finish in the extraporation range */ + } + ); + var animation = new Animation(effect, document.timeline); + animation.play(); + animation.finished.then(function() { + document.documentElement.className = ""; + }); + </script> +</html> diff --git a/dom/animation/test/crashtests/1239889-1.html b/dom/animation/test/crashtests/1239889-1.html new file mode 100644 index 000000000..476f3322b --- /dev/null +++ b/dom/animation/test/crashtests/1239889-1.html @@ -0,0 +1,12 @@ +<!doctype html> +<html> + <head> + <title>Bug 1239889</title> + </head> + <body> + </body> + <script> + var div = document.createElement('div'); + var effect = new KeyframeEffectReadOnly(div, { opacity: [0, 1] }); + </script> +</html> diff --git a/dom/animation/test/crashtests/1244595-1.html b/dom/animation/test/crashtests/1244595-1.html new file mode 100644 index 000000000..13b2e2d7e --- /dev/null +++ b/dom/animation/test/crashtests/1244595-1.html @@ -0,0 +1,3 @@ +<div id=target><script> + var player = target.animate([{background: 'green'}, {background: 'green'}]); +</script> diff --git a/dom/animation/test/crashtests/1272475-1.html b/dom/animation/test/crashtests/1272475-1.html new file mode 100644 index 000000000..e0b049538 --- /dev/null +++ b/dom/animation/test/crashtests/1272475-1.html @@ -0,0 +1,20 @@ +<!doctype html> +<html> + <head> + <title>Bug 1272475 - scale function with an extreme large value</title> + <script> + function test() { + var div = document.createElement("div"); + div.setAttribute("style", "width: 1px; height: 1px; " + + "background: red;"); + document.body.appendChild(div); + div.animate([ { "transform": "scale(8)" }, + { "transform": "scale(9.5e+307)" }, + { "transform": "scale(32)" } ], + { "duration": 1000, "fill": "both" }); + } + </script> + </head> + <body onload="test()"> + </body> +</html> diff --git a/dom/animation/test/crashtests/1272475-2.html b/dom/animation/test/crashtests/1272475-2.html new file mode 100644 index 000000000..da0e8605b --- /dev/null +++ b/dom/animation/test/crashtests/1272475-2.html @@ -0,0 +1,20 @@ +<!doctype html> +<html> + <head> + <title>Bug 1272475 - rotate function with an extreme large value</title> + <script> + function test() { + var div = document.createElement("div"); + div.setAttribute("style", "width: 100px; height: 100px; " + + "background: red;"); + document.body.appendChild(div); + div.animate([ { "transform": "rotate(8rad)" }, + { "transform": "rotate(9.5e+307rad)" }, + { "transform": "rotate(32rad)" } ], + { "duration": 1000, "fill": "both" }); + } + </script> + </head> + <body onload="test()"> + </body> +</html> diff --git a/dom/animation/test/crashtests/1277272-1-inner.html b/dom/animation/test/crashtests/1277272-1-inner.html new file mode 100644 index 000000000..2ba52174d --- /dev/null +++ b/dom/animation/test/crashtests/1277272-1-inner.html @@ -0,0 +1,19 @@ +<!doctype html> +<head> +<script> +function start() { + var animation = document.body.animate([{marks: 'crop'},{marks: 'crop'}], 12); + document.write('<html><body></body></html>'); + + setTimeout(function() { animation.play(); }, 4); + setTimeout(function() { + animation.timeline = undefined; + SpecialPowers.Cu.forceGC(); + window.top.continueTest(); + }, 5); +} +</script> +</head> +<body onload="start()"></body> +</html> + diff --git a/dom/animation/test/crashtests/1277272-1.html b/dom/animation/test/crashtests/1277272-1.html new file mode 100644 index 000000000..f398bcf6d --- /dev/null +++ b/dom/animation/test/crashtests/1277272-1.html @@ -0,0 +1,26 @@ +<!doctype html> +<html class="reftest-wait"> +<head> +<script> +var count = 0; + +function start() { + if (++count > 10) { + document.documentElement.className = ""; + return; + } + + var frame = document.getElementById("frame"); + frame.src = "./1277272-1-inner.html"; +} + +function continueTest() { + setTimeout(start.bind(window), 1); +} + +</script> +</head> +<body onload="start()"></body> +<iframe id="frame"> +</html> + diff --git a/dom/animation/test/crashtests/1278485-1.html b/dom/animation/test/crashtests/1278485-1.html new file mode 100644 index 000000000..e7347f5d8 --- /dev/null +++ b/dom/animation/test/crashtests/1278485-1.html @@ -0,0 +1,26 @@ +<!DOCTYPE html> +<html> +<head> +<meta charset="UTF-8"> +<script> + +function boom() +{ + document.body.animate([], + { duration: 6, + easing: "cubic-bezier(0, -1e+39, 0, 0)" }); + document.body.animate([], + { duration: 6, + easing: "cubic-bezier(0, 1e+39, 0, 0)" }); + document.body.animate([], + { duration: 6, + easing: "cubic-bezier(0, 0, 0, -1e+39)" }); + document.body.animate([], + { duration: 6, + easing: "cubic-bezier(0, 0, 0, 1e+39)" }); +} + +</script> +</head> +<body onload="boom();"></body> +</html> diff --git a/dom/animation/test/crashtests/1290535-1.html b/dom/animation/test/crashtests/1290535-1.html new file mode 100644 index 000000000..20b44d8bf --- /dev/null +++ b/dom/animation/test/crashtests/1290535-1.html @@ -0,0 +1,20 @@ +<!DOCTYPE html> +<html> +<head> + <title>Bug 1290535 - Sort paced subproperties of a shorthand property</title> +<meta charset="UTF-8"> +<script> + +function test() +{ + var div = document.createElement('div'); + document.documentElement.appendChild(div); + div.animate([ { borderRadius: "0", borderTopRightRadius: "0" }, + { borderRadius: "50%" } ], + { spacing:"paced(border-radius)" }); +} + +</script> +</head> +<body onload="test();"></body> +</html> diff --git a/dom/animation/test/crashtests/crashtests.list b/dom/animation/test/crashtests/crashtests.list new file mode 100644 index 000000000..f61d7f876 --- /dev/null +++ b/dom/animation/test/crashtests/crashtests.list @@ -0,0 +1,13 @@ +pref(dom.animations-api.core.enabled,true) load 1239889-1.html +pref(dom.animations-api.core.enabled,true) load 1244595-1.html +pref(dom.animations-api.core.enabled,true) load 1216842-1.html +pref(dom.animations-api.core.enabled,true) load 1216842-2.html +pref(dom.animations-api.core.enabled,true) load 1216842-3.html +pref(dom.animations-api.core.enabled,true) load 1216842-4.html +pref(dom.animations-api.core.enabled,true) load 1216842-5.html +pref(dom.animations-api.core.enabled,true) load 1216842-6.html +pref(dom.animations-api.core.enabled,true) load 1272475-1.html +pref(dom.animations-api.core.enabled,true) load 1272475-2.html +pref(dom.animations-api.core.enabled,true) load 1278485-1.html +pref(dom.animations-api.core.enabled,true) load 1277272-1.html +pref(dom.animations-api.core.enabled,true) load 1290535-1.html diff --git a/dom/animation/test/css-animations/file_animation-cancel.html b/dom/animation/test/css-animations/file_animation-cancel.html new file mode 100644 index 000000000..85499addf --- /dev/null +++ b/dom/animation/test/css-animations/file_animation-cancel.html @@ -0,0 +1,154 @@ +<!doctype html> +<meta charset=utf-8> +<script src="../testcommon.js"></script> +<style> +@keyframes translateAnim { + to { transform: translate(100px) } +} +@keyframes marginLeftAnim { + to { margin-left: 100px } +} +@keyframes marginLeftAnim100To200 { + from { margin-left: 100px } + to { margin-left: 200px } +} +</style> +<body> +<script> +'use strict'; + +promise_test(function(t) { + var div = addDiv(t, { style: 'animation: translateAnim 100s' }); + var animation = div.getAnimations()[0]; + + return animation.ready.then(function() { + assert_not_equals(getComputedStyle(div).transform, 'none', + 'transform style is animated before cancelling'); + animation.cancel(); + assert_equals(getComputedStyle(div).transform, 'none', + 'transform style is no longer animated after cancelling'); + }); +}, 'Animated style is cleared after cancelling a running CSS animation'); + +promise_test(function(t) { + var div = addDiv(t, { style: 'animation: translateAnim 100s forwards' }); + var animation = div.getAnimations()[0]; + animation.finish(); + + return animation.ready.then(function() { + assert_not_equals(getComputedStyle(div).transform, 'none', + 'transform style is filling before cancelling'); + animation.cancel(); + assert_equals(getComputedStyle(div).transform, 'none', + 'fill style is cleared after cancelling'); + }); +}, 'Animated style is cleared after cancelling a filling CSS animation'); + +promise_test(function(t) { + var div = addDiv(t, { style: 'animation: translateAnim 100s' }); + var animation = div.getAnimations()[0]; + div.addEventListener('animationend', t.step_func(function() { + assert_unreached('Got unexpected end event on cancelled animation'); + })); + + return animation.ready.then(function() { + // Seek to just before the end then cancel + animation.currentTime = 99.9 * 1000; + animation.cancel(); + + // Then wait a couple of frames and check that no event was dispatched + return waitForAnimationFrames(2); + }); +}, 'Cancelled CSS animations do not dispatch events'); + +test(function(t) { + var div = addDiv(t, { style: 'animation: marginLeftAnim 100s linear' }); + var animation = div.getAnimations()[0]; + animation.cancel(); + + assert_equals(getComputedStyle(div).marginLeft, '0px', + 'margin-left style is not animated after cancelling'); + + animation.currentTime = 50 * 1000; + assert_equals(getComputedStyle(div).marginLeft, '50px', + 'margin-left style is updated when cancelled animation is' + + ' seeked'); +}, 'After cancelling an animation, it can still be seeked'); + +promise_test(function(t) { + var div = + addDiv(t, { style: 'animation: marginLeftAnim100To200 100s linear' }); + var animation = div.getAnimations()[0]; + + return animation.ready.then(function() { + animation.cancel(); + assert_equals(getComputedStyle(div).marginLeft, '0px', + 'margin-left style is not animated after cancelling'); + animation.play(); + assert_equals(getComputedStyle(div).marginLeft, '100px', + 'margin-left style is animated after re-starting animation'); + return animation.ready; + }).then(function() { + assert_equals(animation.playState, 'running', + 'Animation succeeds in running after being re-started'); + }); +}, 'After cancelling an animation, it can still be re-used'); + +test(function(t) { + var div = + addDiv(t, { style: 'animation: marginLeftAnim100To200 100s linear' }); + var animation = div.getAnimations()[0]; + animation.cancel(); + assert_equals(getComputedStyle(div).marginLeft, '0px', + 'margin-left style is not animated after cancelling'); + + // Trigger a change to some animation properties and check that this + // doesn't cause the animation to become live again + div.style.animationDuration = '200s'; + flushComputedStyle(div); + assert_equals(getComputedStyle(div).marginLeft, '0px', + 'margin-left style is still not animated after updating' + + ' animation-duration'); + assert_equals(animation.playState, 'idle', + 'Animation is still idle after updating animation-duration'); +}, 'After cancelling an animation, updating animation properties doesn\'t make' + + ' it live again'); + +test(function(t) { + var div = + addDiv(t, { style: 'animation: marginLeftAnim100To200 100s linear' }); + var animation = div.getAnimations()[0]; + animation.cancel(); + assert_equals(getComputedStyle(div).marginLeft, '0px', + 'margin-left style is not animated after cancelling'); + + // Make some changes to animation-play-state and check that the + // animation doesn't become live again. This is because it should be + // possible to cancel an animation from script such that all future + // changes to style are ignored. + + // Redundant change + div.style.animationPlayState = 'running'; + assert_equals(animation.playState, 'idle', + 'Animation is still idle after a redundant change to' + + ' animation-play-state'); + + // Pause + div.style.animationPlayState = 'paused'; + assert_equals(animation.playState, 'idle', + 'Animation is still idle after setting' + + ' animation-play-state: paused'); + + // Play + div.style.animationPlayState = 'running'; + assert_equals(animation.playState, 'idle', + 'Animation is still idle after re-setting' + + ' animation-play-state: running'); + +}, 'After cancelling an animation, updating animation-play-state doesn\'t' + + ' make it live again'); + +done(); +</script> +</body> +</html> diff --git a/dom/animation/test/css-animations/file_animation-computed-timing.html b/dom/animation/test/css-animations/file_animation-computed-timing.html new file mode 100644 index 000000000..53597a473 --- /dev/null +++ b/dom/animation/test/css-animations/file_animation-computed-timing.html @@ -0,0 +1,566 @@ +<!doctype html> +<meta charset=utf-8> +<script src="../testcommon.js"></script> +<style> +@keyframes moveAnimation { + from { margin-left: 100px } + to { margin-left: 200px } +} +</style> +<body> +<script> + +'use strict'; + +// -------------------- +// delay +// -------------------- +test(function(t) { + var div = addDiv(t, {style: 'animation: moveAnimation 100s'}); + var effect = div.getAnimations()[0].effect; + assert_equals(effect.getComputedTiming().delay, 0, + 'Initial value of delay'); +}, 'delay of a new animation'); + +test(function(t) { + var div = addDiv(t, {style: 'animation: moveAnimation 100s -10s'}); + var effect = div.getAnimations()[0].effect; + assert_equals(effect.getComputedTiming().delay, -10 * MS_PER_SEC, + 'Initial value of delay'); +}, 'Negative delay of a new animation'); + +test(function(t) { + var div = addDiv(t, {style: 'animation: moveAnimation 100s 10s'}); + var effect = div.getAnimations()[0].effect; + assert_equals(effect.getComputedTiming().delay, 10 * MS_PER_SEC, + 'Initial value of delay'); +}, 'Positive delay of a new animation'); + + +// -------------------- +// endDelay +// -------------------- +test(function(t) { + var div = addDiv(t, {style: 'animation: moveAnimation 100s'}); + var effect = div.getAnimations()[0].effect; + assert_equals(effect.getComputedTiming().endDelay, 0, + 'Initial value of endDelay'); +}, 'endDelay of a new animation'); + + +// -------------------- +// fill +// -------------------- +test(function(t) { + var getEffectWithFill = function(fill) { + var div = addDiv(t, {style: 'animation: moveAnimation 100s ' + fill}); + return div.getAnimations()[0].effect; + }; + + var effect = getEffectWithFill(''); + assert_equals(effect.getComputedTiming().fill, 'none', + 'Initial value of fill'); + effect = getEffectWithFill('forwards'); + assert_equals(effect.getComputedTiming().fill, 'forwards', + 'Fill forwards'); + effect = getEffectWithFill('backwards'); + assert_equals(effect.getComputedTiming().fill, 'backwards', + 'Fill backwards'); + effect = getEffectWithFill('both'); + assert_equals(effect.getComputedTiming().fill, 'both', + 'Fill forwards and backwards'); +}, 'fill of a new animation'); + + +// -------------------- +// iterationStart +// -------------------- +test(function(t) { + var div = addDiv(t, {style: 'animation: moveAnimation 100s'}); + var effect = div.getAnimations()[0].effect; + assert_equals(effect.getComputedTiming().iterationStart, 0, + 'Initial value of iterationStart'); +}, 'iterationStart of a new animation'); + + +// -------------------- +// iterations +// -------------------- +test(function(t) { + var div = addDiv(t, {style: 'animation: moveAnimation 100s'}); + var effect = div.getAnimations()[0].effect; + assert_equals(effect.getComputedTiming().iterations, 1, + 'Initial value of iterations'); +}, 'iterations of a new animation'); + +test(function(t) { + var div = addDiv(t, {style: 'animation: moveAnimation 100s 2016.5'}); + var effect = div.getAnimations()[0].effect; + assert_equals(effect.getComputedTiming().iterations, 2016.5, + 'Initial value of iterations'); +}, 'iterations of a finitely repeating animation'); + +test(function(t) { + var div = addDiv(t, {style: 'animation: moveAnimation 100s infinite'}); + var effect = div.getAnimations()[0].effect; + assert_equals(effect.getComputedTiming().iterations, Infinity, + 'Initial value of iterations'); +}, 'iterations of an infinitely repeating animation'); + + +// -------------------- +// duration +// -------------------- +test(function(t) { + var div = addDiv(t, {style: 'animation: moveAnimation 100s -10s infinite'}); + var effect = div.getAnimations()[0].effect; + assert_equals(effect.getComputedTiming().duration, 100 * MS_PER_SEC, + 'Initial value of duration'); +}, 'duration of a new animation'); + + +// -------------------- +// direction +// -------------------- +test(function(t) { + var getEffectWithDir = function(dir) { + var div = addDiv(t, {style: 'animation: moveAnimation 100s ' + dir}); + return div.getAnimations()[0].effect; + }; + + var effect = getEffectWithDir(''); + assert_equals(effect.getComputedTiming().direction, 'normal', + 'Initial value of normal direction'); + effect = getEffectWithDir('reverse'); + assert_equals(effect.getComputedTiming().direction, 'reverse', + 'Initial value of reverse direction'); + effect = getEffectWithDir('alternate'); + assert_equals(effect.getComputedTiming().direction, 'alternate', + 'Initial value of alternate direction'); + effect = getEffectWithDir('alternate-reverse'); + assert_equals(effect.getComputedTiming().direction, 'alternate-reverse', + 'Initial value of alternate-reverse direction'); +}, 'direction of a new animation'); + + +// -------------------- +// easing +// -------------------- +test(function(t) { + var div = addDiv(t, {style: 'animation: moveAnimation 100s'}); + var effect = div.getAnimations()[0].effect; + assert_equals(effect.getComputedTiming().easing, 'linear', + 'Initial value of easing'); +}, 'easing of a new animation'); + + +// ------------------------------ +// endTime +// = max(start delay + active duration + end delay, 0) +// -------------------- +test(function(t) { + var div = addDiv(t, {style: 'animation: moveAnimation 100s'}); + var effect = div.getAnimations()[0].effect; + assert_equals(effect.getComputedTiming().endTime, 100 * MS_PER_SEC, + 'Initial value of endTime'); +}, 'endTime of an new animation'); + +test(function(t) { + var div = addDiv(t, {style: 'animation: moveAnimation 100s -5s'}); + var effect = div.getAnimations()[0].effect; + var answer = (100 - 5) * MS_PER_SEC; + assert_equals(effect.getComputedTiming().endTime, answer, + 'Initial value of endTime'); +}, 'endTime of an animation with a negative delay'); + +test(function(t) { + var div = addDiv(t, {style: 'animation: moveAnimation 10s -100s infinite'}); + var effect = div.getAnimations()[0].effect; + assert_equals(effect.getComputedTiming().endTime, Infinity, + 'Initial value of endTime'); +}, 'endTime of an infinitely repeating animation'); + +test(function(t) { + var div = addDiv(t, {style: 'animation: moveAnimation 0s 100s infinite'}); + var effect = div.getAnimations()[0].effect; + assert_equals(effect.getComputedTiming().endTime, 100 * MS_PER_SEC, + 'Initial value of endTime'); +}, 'endTime of an infinitely repeating zero-duration animation'); + +test(function(t) { + // Fill forwards so div.getAnimations()[0] won't return an + // undefined value. + var div = addDiv(t, {style: 'animation: moveAnimation 10s -100s forwards'}); + var effect = div.getAnimations()[0].effect; + assert_equals(effect.getComputedTiming().endTime, 0, + 'Initial value of endTime'); +}, 'endTime of an animation that finishes before its startTime'); + + +// -------------------- +// activeDuration +// = iteration duration * iteration count +// -------------------- +test(function(t) { + var div = addDiv(t, {style: 'animation: moveAnimation 100s 5'}); + var effect = div.getAnimations()[0].effect; + var answer = 100 * MS_PER_SEC * 5; + assert_equals(effect.getComputedTiming().activeDuration, answer, + 'Initial value of activeDuration'); +}, 'activeDuration of a new animation'); + +test(function(t) { + var div = addDiv(t, {style: 'animation: moveAnimation 100s infinite'}); + var effect = div.getAnimations()[0].effect; + assert_equals(effect.getComputedTiming().activeDuration, Infinity, + 'Initial value of activeDuration'); +}, 'activeDuration of an infinitely repeating animation'); + +test(function(t) { + var div = addDiv(t, {style: 'animation: moveAnimation 0s 1s infinite'}); + var effect = div.getAnimations()[0].effect; + // If either the iteration duration or iteration count are zero, + // the active duration is zero. + assert_equals(effect.getComputedTiming().activeDuration, 0, + 'Initial value of activeDuration'); +}, 'activeDuration of an infinitely repeating zero-duration animation'); + +test(function(t) { + var div = addDiv(t, {style: 'animation: moveAnimation 100s 1s 0'}); + var effect = div.getAnimations()[0].effect; + // If either the iteration duration or iteration count are zero, + // the active duration is zero. + assert_equals(effect.getComputedTiming().activeDuration, 0, + 'Initial value of activeDuration'); +}, 'activeDuration of an animation with zero iterations'); + + +// -------------------- +// localTime +// -------------------- +test(function(t) { + var div = addDiv(t, {style: 'animation: moveAnimation 100s'}); + var effect = div.getAnimations()[0].effect; + assert_equals(effect.getComputedTiming().localTime, 0, + 'Initial value of localTime'); +}, 'localTime of a new animation'); + +test(function(t) { + var div = addDiv(t, {style: 'animation: moveAnimation 100s'}); + var anim = div.getAnimations()[0]; + anim.currentTime = 5 * MS_PER_SEC; + assert_equals(anim.effect.getComputedTiming().localTime, anim.currentTime, + 'current localTime after setting currentTime'); +}, 'localTime of an animation is always equal to currentTime'); + +promise_test(function(t) { + var div = addDiv(t, {style: 'animation: moveAnimation 100s'}); + + var anim = div.getAnimations()[0]; + anim.playbackRate = 2; // 2 times faster + + return anim.ready.then(function() { + assert_equals(anim.effect.getComputedTiming().localTime, anim.currentTime, + 'localTime is equal to currentTime'); + return waitForFrame(); + }).then(function() { + assert_equals(anim.effect.getComputedTiming().localTime, anim.currentTime, + 'localTime is equal to currentTime'); + }); +}, 'localTime reflects playbackRate immediately'); + +test(function(t) { + var div = addDiv(t); + var effect = new KeyframeEffectReadOnly(div, {left: ["0px", "100px"]}); + + assert_equals(effect.getComputedTiming().localTime, null, + 'localTime for orphaned effect'); +}, 'localTime of an AnimationEffect without an Animation'); + + +// -------------------- +// progress +// Note: Default timing function is linear. +// -------------------- +test(function(t) { + [{fill: '', progress: [ null, null ]}, + {fill: 'none', progress: [ null, null ]}, + {fill: 'forwards', progress: [ null, 1.0 ]}, + {fill: 'backwards', progress: [ 0.0, null ]}, + {fill: 'both', progress: [ 0.0, 1.0 ]}] + .forEach(function(test) { + var div = + addDiv(t, {style: 'animation: moveAnimation 100s 10s ' + test.fill}); + var anim = div.getAnimations()[0]; + assert_true(anim.effect.getComputedTiming().progress === test.progress[0], + 'initial progress with "' + test.fill + '" fill'); + anim.finish(); + assert_true(anim.effect.getComputedTiming().progress === test.progress[1], + 'finished progress with "' + test.fill + '" fill'); + }); +}, 'progress of an animation with different fill modes'); + +test(function(t) { + var div = addDiv(t, {style: 'animation: moveAnimation 10s 10 both'}); + var anim = div.getAnimations()[0]; + + assert_equals(anim.effect.getComputedTiming().progress, 0.0, + 'Initial value of progress'); + anim.currentTime += 2.5 * MS_PER_SEC; + assert_equals(anim.effect.getComputedTiming().progress, 0.25, + 'Value of progress'); + anim.currentTime += 5 * MS_PER_SEC; + assert_equals(anim.effect.getComputedTiming().progress, 0.75, + 'Value of progress'); + anim.currentTime += 5 * MS_PER_SEC; + assert_equals(anim.effect.getComputedTiming().progress, 0.25, + 'Value of progress'); + anim.finish() + assert_equals(anim.effect.getComputedTiming().progress, 1.0, + 'Value of progress'); +}, 'progress of an integral repeating animation with normal direction'); + +test(function(t) { + var div = addDiv(t); + // Note: FillMode here is "both" because + // 1. Since this a zero-duration animation, it will already have finished + // so it won't be returned by getAnimations() unless it fills forwards. + // 2. Fill backwards, so the progress before phase wouldn't be + // unresolved (null value). + var div = addDiv(t, {style: 'animation: moveAnimation 0s infinite both'}); + var anim = div.getAnimations()[0]; + + assert_equals(anim.effect.getComputedTiming().progress, 1.0, + 'Initial value of progress in after phase'); + + // Seek backwards + anim.currentTime -= 1 * MS_PER_SEC; + assert_equals(anim.effect.getComputedTiming().progress, 0.0, + 'Value of progress before phase'); +}, 'progress of an infinitely repeating zero-duration animation'); + +test(function(t) { + // Default iterations = 1 + var div = addDiv(t, {style: 'animation: moveAnimation 0s both'}); + var anim = div.getAnimations()[0]; + + assert_equals(anim.effect.getComputedTiming().progress, 1.0, + 'Initial value of progress in after phase'); + + // Seek backwards + anim.currentTime -= 1 * MS_PER_SEC; + assert_equals(anim.effect.getComputedTiming().progress, 0.0, + 'Value of progress before phase'); +}, 'progress of a finitely repeating zero-duration animation'); + +test(function(t) { + var div = addDiv(t, {style: 'animation: moveAnimation 0s 5s 10.25 both'}); + var anim = div.getAnimations()[0]; + + assert_equals(anim.effect.getComputedTiming().progress, 0.0, + 'Initial value of progress (before phase)'); + + // Using iteration duration of 1 now. + // currentIteration now is floor(10.25) = 10, so progress should be 25%. + anim.finish(); + assert_equals(anim.effect.getComputedTiming().progress, 0.25, + 'Value of progress in after phase'); +}, 'progress of a non-integral repeating zero-duration animation'); + +test(function(t) { + var div = addDiv(t, {style: 'animation: moveAnimation 0s 5s 10.25 both reverse'}); + var anim = div.getAnimations()[0]; + + assert_equals(anim.effect.getComputedTiming().progress, 1.0, + 'Initial value of progress (before phase)'); + + // Seek forwards + anim.finish(); + assert_equals(anim.effect.getComputedTiming().progress, 0.75, + 'Value of progress in after phase'); +}, 'Progress of a non-integral repeating zero-duration animation ' + + 'with reversing direction'); + +test(function(t) { + var div = addDiv(t, {style: 'animation: moveAnimation 10s 10.25 both alternate'}); + var anim = div.getAnimations()[0]; + + assert_equals(anim.effect.getComputedTiming().progress, 0.0, + 'Initial value of progress'); + anim.currentTime += 2.5 * MS_PER_SEC; + assert_equals(anim.effect.getComputedTiming().progress, 0.25, + 'Value of progress'); + anim.currentTime += 5 * MS_PER_SEC; + assert_equals(anim.effect.getComputedTiming().progress, 0.75, + 'Value of progress'); + anim.currentTime += 5 * MS_PER_SEC; + assert_equals(anim.effect.getComputedTiming().progress, 0.75, + 'Value of progress'); + anim.finish() + assert_equals(anim.effect.getComputedTiming().progress, 0.25, + 'Value of progress'); +}, 'progress of a non-integral repeating animation ' + + 'with alternate direction'); + +test(function(t) { + var div = addDiv(t, {style: 'animation: moveAnimation 10s 10.25 both alternate-reverse'}); + var anim = div.getAnimations()[0]; + + assert_equals(anim.effect.getComputedTiming().progress, 1.0, + 'Initial value of progress'); + anim.currentTime += 2.5 * MS_PER_SEC; + assert_equals(anim.effect.getComputedTiming().progress, 0.75, + 'Value of progress'); + anim.currentTime += 5 * MS_PER_SEC; + assert_equals(anim.effect.getComputedTiming().progress, 0.25, + 'Value of progress'); + anim.currentTime += 5 * MS_PER_SEC; + assert_equals(anim.effect.getComputedTiming().progress, 0.25, + 'Value of progress'); + anim.finish() + assert_equals(anim.effect.getComputedTiming().progress, 0.75, + 'Value of progress'); +}, 'progress of a non-integral repeating animation ' + + 'with alternate-reversing direction'); + +test(function(t) { + var div = addDiv(t, {style: 'animation: moveAnimation 0s 10.25 both alternate'}); + var anim = div.getAnimations()[0]; + + assert_equals(anim.effect.getComputedTiming().progress, 0.25, + 'Initial value of progress'); + anim.currentTime += 2.5 * MS_PER_SEC; + assert_equals(anim.effect.getComputedTiming().progress, 0.25, + 'Value of progress'); + anim.currentTime -= 5 * MS_PER_SEC; + assert_equals(anim.effect.getComputedTiming().progress, 0.0, + 'Value of progress'); + anim.finish() + assert_equals(anim.effect.getComputedTiming().progress, 0.25, + 'Value of progress'); +}, 'progress of a non-integral repeating zero-duration animation ' + + 'with alternate direction'); + +test(function(t) { + var div = addDiv(t, {style: 'animation: moveAnimation 0s 10.25 both alternate-reverse'}); + var anim = div.getAnimations()[0]; + + assert_equals(anim.effect.getComputedTiming().progress, 0.75, + 'Initial value of progress'); + anim.currentTime += 2.5 * MS_PER_SEC; + assert_equals(anim.effect.getComputedTiming().progress, 0.75, + 'Value of progress'); + anim.currentTime -= 5 * MS_PER_SEC; + assert_equals(anim.effect.getComputedTiming().progress, 1.0, + 'Value of progress'); + anim.finish() + assert_equals(anim.effect.getComputedTiming().progress, 0.75, + 'Value of progress'); +}, 'progress of a non-integral repeating zero-duration animation ' + + 'with alternate-reverse direction'); + + +// -------------------- +// currentIteration +// -------------------- +test(function(t) { + var div = addDiv(t, {style: 'animation: moveAnimation 100s 2s'}); + var effect = div.getAnimations()[0].effect; + assert_equals(effect.getComputedTiming().currentIteration, null, + 'Initial value of currentIteration before phase'); +}, 'currentIteration of a new animation with no backwards fill is unresolved ' + + 'in before phase'); + +test(function(t) { + var div = addDiv(t, {style: 'animation: moveAnimation 100s'}); + var anim = div.getAnimations()[0]; + assert_equals(anim.effect.getComputedTiming().currentIteration, 0, + 'Initial value of currentIteration'); +}, 'currentIteration of a new animation is zero'); + +test(function(t) { + // Note: FillMode here is "both" because + // 1. Since this a zero-duration animation, it will already have finished + // so it won't be returned by getAnimations() unless it fills forwards. + // 2. Fill backwards, so the currentIteration (before phase) wouldn't be + // unresolved (null value). + var div = addDiv(t, {style: 'animation: moveAnimation 0s infinite both'}); + var anim = div.getAnimations()[0]; + + assert_equals(anim.effect.getComputedTiming().currentIteration, Infinity, + 'Initial value of currentIteration in after phase'); + + // Seek backwards + anim.currentTime -= 2 * MS_PER_SEC; + assert_equals(anim.effect.getComputedTiming().currentIteration, 0, + 'Value of currentIteration count during before phase'); +}, 'currentIteration of an infinitely repeating zero-duration animation'); + +test(function(t) { + var div = addDiv(t, {style: 'animation: moveAnimation 0s 10.5 both'}); + var anim = div.getAnimations()[0]; + + // Note: currentIteration = ceil(iteration start + iteration count) - 1 + assert_equals(anim.effect.getComputedTiming().currentIteration, 10, + 'Initial value of currentIteration'); + + // Seek backwards + anim.currentTime -= 2 * MS_PER_SEC; + assert_equals(anim.effect.getComputedTiming().currentIteration, 0, + 'Value of currentIteration count during before phase'); +}, 'currentIteration of a finitely repeating zero-duration animation'); + +test(function(t) { + var div = addDiv(t, {style: 'animation: moveAnimation 100s 5.5 forwards'}); + var anim = div.getAnimations()[0]; + + assert_equals(anim.effect.getComputedTiming().currentIteration, 0, + 'Initial value of currentIteration'); + // The 3rd iteration + // Note: currentIteration = floor(scaled active time / iteration duration) + anim.currentTime = 250 * MS_PER_SEC; + assert_equals(anim.effect.getComputedTiming().currentIteration, 2, + 'Value of currentIteration during the 3rd iteration'); + // Finish + anim.finish(); + assert_equals(anim.effect.getComputedTiming().currentIteration, 5, + 'Value of currentIteration in after phase'); +}, 'currentIteration of an animation with a non-integral iteration count'); + +test(function(t) { + var div = addDiv(t, {style: 'animation: moveAnimation 100s 2 forwards'}); + var anim = div.getAnimations()[0]; + + assert_equals(anim.effect.getComputedTiming().currentIteration, 0, + 'Initial value of currentIteration'); + // Finish + anim.finish(); + assert_equals(anim.effect.getComputedTiming().currentIteration, 1, + 'Value of currentIteration in after phase'); +}, 'currentIteration of an animation with an integral iteration count'); + +test(function(t) { + var div = addDiv(t, {style: 'animation: moveAnimation 100s forwards'}); + var anim = div.getAnimations()[0]; + assert_equals(anim.effect.getComputedTiming().currentIteration, 0, + 'Initial value of currentIteration'); + // Finish + anim.finish(); + assert_equals(anim.effect.getComputedTiming().currentIteration, 0, + 'Value of currentIteration in after phase'); +}, 'currentIteration of an animation with a default iteration count'); + +test(function(t) { + var div = addDiv(t); + var effect = new KeyframeEffectReadOnly(div, {left: ["0px", "100px"]}); + + assert_equals(effect.getComputedTiming().currentIteration, null, + 'currentIteration for orphaned effect'); +}, 'currentIteration of an AnimationEffect without an Animation'); + +// TODO: If iteration duration is Infinity, currentIteration is 0. +// However, we cannot set iteration duration to Infinity in CSS Animation now. + +done(); +</script> +</body> diff --git a/dom/animation/test/css-animations/file_animation-currenttime.html b/dom/animation/test/css-animations/file_animation-currenttime.html new file mode 100644 index 000000000..ec6fb3f1a --- /dev/null +++ b/dom/animation/test/css-animations/file_animation-currenttime.html @@ -0,0 +1,345 @@ +<!doctype html> +<html> + <head> + <meta charset=utf-8> + <title>Tests for the effect of setting a CSS animation's + Animation.currentTime</title> + <style> + +.animated-div { + margin-left: 10px; + /* Make it easier to calculate expected values: */ + animation-timing-function: linear ! important; +} + +@keyframes anim { + from { margin-left: 100px; } + to { margin-left: 200px; } +} + + </style> + <script src="../testcommon.js"></script> + </head> + <body> + <script type="text/javascript"> + +'use strict'; + +// TODO: We should separate this test(Testing for CSS Animation events / +// Testing for currentTime of Web Animation). +// e.g: +// CSS Animation events test : +// - check the firing an event using Animation.currentTime +// The current Time of Web Animation test : +// - check an current time value on several situation(init / processing..) +// - Based on W3C Spec, check the behavior of setting current time. + +// TODO: Once the computedTiming property is implemented, add checks to the +// checker helpers to ensure that computedTiming's properties are updated as +// expected. +// See https://bugzilla.mozilla.org/show_bug.cgi?id=1108055 + +const CSS_ANIM_EVENTS = + ['animationstart', 'animationiteration', 'animationend']; + +test(function(t) +{ + var div = addDiv(t, {'class': 'animated-div'}); + div.style.animation = "anim 100s"; + var animation = div.getAnimations()[0]; + + // Animations shouldn't start until the next paint tick, so: + assert_equals(animation.currentTime, 0, + 'Animation.currentTime should be zero when an animation ' + + 'is initially created'); + + // Make sure the animation is running before we set the current time. + animation.startTime = animation.timeline.currentTime; + + animation.currentTime = 50 * MS_PER_SEC; + assert_times_equal(animation.currentTime, 50 * MS_PER_SEC, + 'Check setting of currentTime actually works'); +}, 'Sanity test to check round-tripping assigning to new animation\'s ' + + 'currentTime'); + +promise_test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + var eventWatcher = new EventWatcher(t, div, CSS_ANIM_EVENTS); + div.style.animation = "anim 100s 100s"; + var animation = div.getAnimations()[0]; + + return animation.ready.then(function() { + // the 0.0001 here is for rounding error + assert_less_than_equal(animation.currentTime, + animation.timeline.currentTime - animation.startTime + 0.0001, + 'Animation.currentTime should be less than the local time ' + + 'equivalent of the timeline\'s currentTime on the first paint tick ' + + 'after animation creation'); + + animation.currentTime = 100 * MS_PER_SEC; + return eventWatcher.wait_for('animationstart'); + }).then(function() { + animation.currentTime = 200 * MS_PER_SEC; + return eventWatcher.wait_for('animationend'); + }); +}, 'Skipping forward through animation'); + +promise_test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + var eventWatcher = new EventWatcher(t, div, CSS_ANIM_EVENTS); + div.style.animation = "anim 100s 100s"; + var animation = div.getAnimations()[0]; + animation.currentTime = 200 * MS_PER_SEC; + var previousTimelineTime = animation.timeline.currentTime; + + return eventWatcher.wait_for(['animationstart', + 'animationend']).then(function() { + assert_true(document.timeline.currentTime - previousTimelineTime < + 100 * MS_PER_SEC, + 'Sanity check that seeking worked rather than the events ' + + 'firing after normal playback through the very long ' + + 'animation duration'); + + animation.currentTime = 150 * MS_PER_SEC; + return eventWatcher.wait_for('animationstart'); + }).then(function() { + animation.currentTime = 0; + return eventWatcher.wait_for('animationend'); + }); +}, 'Skipping backwards through animation'); + +// Next we have multiple tests to check that redundant currentTime changes do +// NOT dispatch events. It's impossible to distinguish between events not being +// dispatched and events just taking an incredibly long time to dispatch +// without waiting an infinitely long time. Obviously we don't want to do that +// (block this test from finishing forever), so instead we just listen for +// events until two animation frames (i.e. requestAnimationFrame callbacks) +// have happened, then assume that no events will ever be dispatched for the +// redundant changes if no events were detected in that time. + +promise_test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + var eventWatcher = new EventWatcher(t, div, CSS_ANIM_EVENTS); + div.style.animation = "anim 100s 100s"; + var animation = div.getAnimations()[0]; + + animation.currentTime = 150 * MS_PER_SEC; + animation.currentTime = 50 * MS_PER_SEC; + + return waitForAnimationFrames(2); +}, 'Redundant change, before -> active, then back'); + +promise_test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + var eventWatcher = new EventWatcher(t, div, CSS_ANIM_EVENTS); + div.style.animation = "anim 100s 100s"; + var animation = div.getAnimations()[0]; + + animation.currentTime = 250 * MS_PER_SEC; + animation.currentTime = 50 * MS_PER_SEC; + + return waitForAnimationFrames(2); +}, 'Redundant change, before -> after, then back'); + +promise_test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + var eventWatcher = new EventWatcher(t, div, CSS_ANIM_EVENTS); + div.style.animation = "anim 100s 100s"; + var animation = div.getAnimations()[0]; + + var retPromise = eventWatcher.wait_for('animationstart').then(function() { + animation.currentTime = 50 * MS_PER_SEC; + animation.currentTime = 150 * MS_PER_SEC; + + return waitForAnimationFrames(2); + }); + // get us into the initial state: + animation.currentTime = 150 * MS_PER_SEC; + + return retPromise; +}, 'Redundant change, active -> before, then back'); + +promise_test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + var eventWatcher = new EventWatcher(t, div, CSS_ANIM_EVENTS); + div.style.animation = "anim 100s 100s"; + var animation = div.getAnimations()[0]; + + var retPromise = eventWatcher.wait_for('animationstart').then(function() { + animation.currentTime = 250 * MS_PER_SEC; + animation.currentTime = 150 * MS_PER_SEC; + + return waitForAnimationFrames(2); + }); + // get us into the initial state: + animation.currentTime = 150 * MS_PER_SEC; + + return retPromise; +}, 'Redundant change, active -> after, then back'); + +promise_test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + var eventWatcher = new EventWatcher(t, div, CSS_ANIM_EVENTS); + div.style.animation = "anim 100s 100s"; + var animation = div.getAnimations()[0]; + + var retPromise = eventWatcher.wait_for(['animationstart', + 'animationend']).then(function() { + animation.currentTime = 50 * MS_PER_SEC; + animation.currentTime = 250 * MS_PER_SEC; + + return waitForAnimationFrames(2); + }); + // get us into the initial state: + animation.currentTime = 250 * MS_PER_SEC; + + return retPromise; +}, 'Redundant change, after -> before, then back'); + +promise_test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + var eventWatcher = new EventWatcher(t, div, CSS_ANIM_EVENTS); + div.style.animation = "anim 100s 100s"; + var animation = div.getAnimations()[0]; + + var retPromise = eventWatcher.wait_for(['animationstart', + 'animationend']).then(function() { + animation.currentTime = 150 * MS_PER_SEC; + animation.currentTime = 250 * MS_PER_SEC; + + return waitForAnimationFrames(2); + }); + // get us into the initial state: + animation.currentTime = 250 * MS_PER_SEC; + + return retPromise; +}, 'Redundant change, after -> active, then back'); + +promise_test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + var eventWatcher = new EventWatcher(t, div, CSS_ANIM_EVENTS); + div.style.animation = "anim 100s" + var animation = div.getAnimations()[0]; + + animation.pause(); + animation.currentTime = 150 * MS_PER_SEC; + + return eventWatcher.wait_for(['animationstart', + 'animationend']).then(function() { + animation.currentTime = 50 * MS_PER_SEC; + return eventWatcher.wait_for('animationstart'); + }); +}, 'Seeking finished -> paused dispatches animationstart'); + +promise_test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + div.style.animation = "anim 100s"; + + var animation = div.getAnimations()[0]; + + return animation.ready.then(function() { + var exception; + try { + animation.currentTime = null; + } catch (e) { + exception = e; + } + assert_equals(exception.name, 'TypeError', + 'Expect TypeError exception on trying to set ' + + 'Animation.currentTime to null'); + }); +}, 'Setting currentTime to null'); + +promise_test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + div.style.animation = 'anim 100s'; + + var animation = div.getAnimations()[0]; + var pauseTime; + + return animation.ready.then(function() { + assert_not_equals(animation.currentTime, null, + 'Animation.currentTime not null on ready Promise resolve'); + animation.pause(); + return animation.ready; + }).then(function() { + pauseTime = animation.currentTime; + return waitForFrame(); + }).then(function() { + assert_equals(animation.currentTime, pauseTime, + 'Animation.currentTime is unchanged after pausing'); + }); +}, 'Animation.currentTime after pausing'); + +promise_test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + div.style.animation = "anim 100s"; + var animation = div.getAnimations()[0]; + + return animation.ready.then(function() { + // just before animation ends: + animation.currentTime = 100 * MS_PER_SEC - 1; + return waitForAnimationFrames(2); + }).then(function() { + assert_equals(animation.currentTime, 100 * MS_PER_SEC, + 'Animation.currentTime should not continue to increase after the ' + + 'animation has finished'); + }); +}, 'Animation.currentTime clamping'); + +promise_test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + div.style.animation = "anim 100s"; + var animation = div.getAnimations()[0]; + + return animation.ready.then(function() { + // play backwards: + animation.playbackRate = -1; + + // just before animation ends (at the "start"): + animation.currentTime = 1; + + return waitForAnimationFrames(2); + }).then(function() { + assert_equals(animation.currentTime, 0, + 'Animation.currentTime should not continue to decrease after an ' + + 'animation running in reverse has finished and currentTime is zero'); + }); +}, 'Animation.currentTime clamping for reversed animation'); + +test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + div.style.animation = 'anim 100s'; + var animation = div.getAnimations()[0]; + animation.cancel(); + + assert_equals(animation.currentTime, null, + 'The currentTime of a cancelled animation should be null'); +}, 'Animation.currentTime after cancelling'); + +promise_test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + div.style.animation = 'anim 100s'; + var animation = div.getAnimations()[0]; + + return animation.ready.then(function() { + animation.finish(); + + // Initiate a pause then abort it + animation.pause(); + animation.play(); + + // Wait to return to running state + return animation.ready; + }).then(function() { + assert_true(animation.currentTime < 100 * 1000, + 'After aborting a pause when finished, the currentTime should' + + ' jump back towards the start of the animation'); + }); +}, 'After aborting a pause when finished, the call to play() should rewind' + + ' the current time'); + +done(); + </script> + </body> +</html> diff --git a/dom/animation/test/css-animations/file_animation-finish.html b/dom/animation/test/css-animations/file_animation-finish.html new file mode 100644 index 000000000..996cb2ce7 --- /dev/null +++ b/dom/animation/test/css-animations/file_animation-finish.html @@ -0,0 +1,97 @@ +<!doctype html> +<meta charset=utf-8> +<script src="../testcommon.js"></script> +<style> +@keyframes anim { + from { margin-left: 100px; } + to { margin-left: 200px; } +} +</style> +<body> +<script> + +'use strict'; + +const ANIM_PROP_VAL = 'anim 100s'; +const ANIM_DURATION = 100000; // ms + +test(function(t) { + var div = addDiv(t); + div.style.animation = ANIM_PROP_VAL; + div.style.animationIterationCount = 'infinite'; + var animation = div.getAnimations()[0]; + + var threw = false; + try { + animation.finish(); + } catch (e) { + threw = true; + assert_equals(e.name, 'InvalidStateError', + 'Exception should be an InvalidStateError exception when ' + + 'trying to finish an infinite animation'); + } + assert_true(threw, + 'Expect InvalidStateError exception trying to finish an ' + + 'infinite animation'); +}, 'Test exceptions when finishing infinite animation'); + +async_test(function(t) { + var div = addDiv(t); + div.style.animation = ANIM_PROP_VAL + ' paused'; + var animation = div.getAnimations()[0]; + + animation.ready.then(t.step_func(function() { + animation.finish(); + assert_equals(animation.playState, 'finished', + 'The play state of a paused animation should become ' + + '"finished" after finish() is called'); + assert_approx_equals(animation.startTime, + animation.timeline.currentTime - ANIM_DURATION, + 0.0001, + 'The start time of a paused animation should be set ' + + 'after calling finish()'); + t.done(); + })); +}, 'Test finish() while paused'); + +test(function(t) { + var div = addDiv(t); + div.style.animation = ANIM_PROP_VAL + ' paused'; + var animation = div.getAnimations()[0]; + + // Update playbackRate so we can test that the calculated startTime + // respects it + animation.playbackRate = 2; + + // While animation is still pause-pending call finish() + animation.finish(); + + assert_equals(animation.playState, 'finished', + 'The play state of a pause-pending animation should become ' + + '"finished" after finish() is called'); + assert_approx_equals(animation.startTime, + animation.timeline.currentTime - ANIM_DURATION / 2, + 0.0001, + 'The start time of a pause-pending animation should ' + + 'be set after calling finish()'); +}, 'Test finish() while pause-pending with positive playbackRate'); + +test(function(t) { + var div = addDiv(t); + div.style.animation = ANIM_PROP_VAL + ' paused'; + var animation = div.getAnimations()[0]; + + animation.playbackRate = -2; + animation.finish(); + + assert_equals(animation.playState, 'finished', + 'The play state of a pause-pending animation should become ' + + '"finished" after finish() is called'); + assert_equals(animation.startTime, animation.timeline.currentTime, + 'The start time of a pause-pending animation should be ' + + 'set after calling finish()'); +}, 'Test finish() while pause-pending with negative playbackRate'); + +done(); +</script> +</body> diff --git a/dom/animation/test/css-animations/file_animation-finished.html b/dom/animation/test/css-animations/file_animation-finished.html new file mode 100644 index 000000000..c296abb11 --- /dev/null +++ b/dom/animation/test/css-animations/file_animation-finished.html @@ -0,0 +1,93 @@ +<!doctype html> +<meta charset=utf-8> +<script src="../testcommon.js"></script> +<style> +@keyframes abc { + to { transform: translate(10px) } +} +@keyframes def {} +</style> +<body> +<script> +'use strict'; + +const ANIM_PROP_VAL = 'abc 100s'; +const ANIM_DURATION = 100 * MS_PER_SEC; + +promise_test(function(t) { + var div = addDiv(t); + // Set up pending animation + div.style.animation = ANIM_PROP_VAL; + var animation = div.getAnimations()[0]; + var previousFinishedPromise = animation.finished; + // Set up listeners on finished promise + var retPromise = animation.finished.then(function() { + assert_unreached('finished promise is fulfilled'); + }).catch(function(err) { + assert_equals(err.name, 'AbortError', + 'finished promise is rejected with AbortError'); + assert_not_equals(animation.finished, previousFinishedPromise, + 'Finished promise should change after the original is ' + + 'rejected'); + }); + + // Now cancel the animation and flush styles + div.style.animation = ''; + window.getComputedStyle(div).animation; + + return retPromise; +}, 'finished promise is rejected when an animation is cancelled by resetting ' + + 'the animation property'); + +promise_test(function(t) { + var div = addDiv(t); + // As before, but this time instead of removing all animations, simply update + // the list of animations. At least for Firefox, updating is a different + // code path. + + // Set up pending animation + div.style.animation = ANIM_PROP_VAL; + var animation = div.getAnimations()[0]; + var previousFinishedPromise = animation.finished; + + // Set up listeners on finished promise + var retPromise = animation.finished.then(function() { + assert_unreached('finished promise was fulfilled'); + }).catch(function(err) { + assert_equals(err.name, 'AbortError', + 'finished promise is rejected with AbortError'); + assert_not_equals(animation.finished, previousFinishedPromise, + 'Finished promise should change after the original is ' + + 'rejected'); + }); + + // Now update the animation and flush styles + div.style.animation = 'def 100s'; + window.getComputedStyle(div).animation; + + return retPromise; +}, 'finished promise is rejected when an animation is cancelled by changing ' + + 'the animation property'); + +promise_test(function(t) { + var div = addDiv(t); + div.style.animation = ANIM_PROP_VAL; + var animation = div.getAnimations()[0]; + var previousFinishedPromise = animation.finished; + animation.currentTime = ANIM_DURATION; + return animation.finished.then(function() { + div.style.animationPlayState = 'running'; + return waitForAnimationFrames(2); + }).then(function() { + assert_equals(animation.finished, previousFinishedPromise, + 'Should not replay when animation-play-state changes to ' + + '"running" on finished animation'); + assert_equals(animation.currentTime, ANIM_DURATION, + 'currentTime should not change when animation-play-state ' + + 'changes to "running" on finished animation'); + }); +}, 'Test finished promise changes when animationPlayState set to running'); + +done(); +</script> +</body> diff --git a/dom/animation/test/css-animations/file_animation-id.html b/dom/animation/test/css-animations/file_animation-id.html new file mode 100644 index 000000000..dbd5ee0ee --- /dev/null +++ b/dom/animation/test/css-animations/file_animation-id.html @@ -0,0 +1,24 @@ +<!doctype html> +<meta charset=utf-8> +<script src="../testcommon.js"></script> +<style> +@keyframes abc { } +</style> +<body> +<script> +'use strict'; + +test(function(t) { + var div = addDiv(t); + div.style.animation = 'abc 100s'; + var animation = div.getAnimations()[0]; + assert_equals(animation.id, '', 'id for CSS Animation is initially empty'); + animation.id = 'anim' + + assert_equals(animation.id, 'anim', 'animation.id reflects the value set'); +}, 'Animation.id for CSS Animations'); + +done(); +</script> +</body> +</html> diff --git a/dom/animation/test/css-animations/file_animation-pausing.html b/dom/animation/test/css-animations/file_animation-pausing.html new file mode 100644 index 000000000..7176a0c1d --- /dev/null +++ b/dom/animation/test/css-animations/file_animation-pausing.html @@ -0,0 +1,165 @@ +<!doctype html> +<meta charset=utf-8> +<script src="../testcommon.js"></script> +<style> +@keyframes anim { + 0% { margin-left: 0px } + 100% { margin-left: 10000px } +} +</style> +<body> +<script> +'use strict'; + +function getMarginLeft(cs) { + return parseFloat(cs.marginLeft); +} + +promise_test(function(t) { + var div = addDiv(t); + var cs = window.getComputedStyle(div); + div.style.animation = 'anim 1000s paused'; + var animation = div.getAnimations()[0]; + assert_equals(getMarginLeft(cs), 0, + 'Initial value of margin-left is zero'); + animation.play(); + + return animation.ready.then(waitForFrame).then(function() { + assert_true(getMarginLeft(cs) > 0, + 'Playing value of margin-left is greater than zero'); + }); +}, 'play() overrides animation-play-state'); + +promise_test(function(t) { + var div = addDiv(t); + var cs = window.getComputedStyle(div); + div.style.animation = 'anim 1000s paused'; + var animation = div.getAnimations()[0]; + assert_equals(getMarginLeft(cs), 0, + 'Initial value of margin-left is zero'); + + animation.pause(); + div.style.animationPlayState = 'running'; + + return animation.ready.then(waitForFrame).then(function() { + assert_equals(cs.animationPlayState, 'running', + 'animation-play-state is running'); + assert_equals(getMarginLeft(cs), 0, + 'Paused value of margin-left is zero'); + }); +}, 'pause() overrides animation-play-state'); + +promise_test(function(t) { + var div = addDiv(t); + var cs = window.getComputedStyle(div); + div.style.animation = 'anim 1000s paused'; + var animation = div.getAnimations()[0]; + assert_equals(getMarginLeft(cs), 0, + 'Initial value of margin-left is zero'); + animation.play(); + var previousAnimVal; + + return animation.ready.then(function() { + div.style.animationPlayState = 'running'; + cs.animationPlayState; // Trigger style resolution + return waitForFrame(); + }).then(function() { + assert_equals(cs.animationPlayState, 'running', + 'animation-play-state is running'); + div.style.animationPlayState = 'paused'; + return animation.ready; + }).then(function() { + assert_equals(cs.animationPlayState, 'paused', + 'animation-play-state is paused'); + previousAnimVal = getMarginLeft(cs); + return waitForFrame(); + }).then(function() { + assert_equals(getMarginLeft(cs), previousAnimVal, + 'Animated value of margin-left does not change when' + + ' paused by style'); + }); +}, 'play() is overridden by later setting "animation-play-state: paused"'); + +promise_test(function(t) { + var div = addDiv(t); + var cs = window.getComputedStyle(div); + div.style.animation = 'anim 1000s'; + var animation = div.getAnimations()[0]; + assert_equals(getMarginLeft(cs), 0, + 'Initial value of margin-left is zero'); + + // Set the specified style first. If implementations fail to + // apply the style changes first, they will ignore the redundant + // call to play() and fail to correctly override the pause style. + div.style.animationPlayState = 'paused'; + animation.play(); + var previousAnimVal = getMarginLeft(cs); + + return animation.ready.then(waitForFrame).then(function() { + assert_equals(cs.animationPlayState, 'paused', + 'animation-play-state is paused'); + assert_true(getMarginLeft(cs) > previousAnimVal, + 'Playing value of margin-left is increasing'); + }); +}, 'play() flushes pending changes to animation-play-state first'); + +promise_test(function(t) { + var div = addDiv(t); + var cs = window.getComputedStyle(div); + div.style.animation = 'anim 1000s paused'; + var animation = div.getAnimations()[0]; + assert_equals(getMarginLeft(cs), 0, + 'Initial value of margin-left is zero'); + + // Unlike the previous test for play(), since calling pause() is sticky, + // we'll apply it even if the underlying style also says we're paused. + // + // We would like to test that implementations flush styles before running + // pause() but actually there's no style we can currently set that will + // change the behavior of pause(). That may change in the future + // (e.g. if we introduce animation-timeline or animation-playback-rate etc.). + // + // For now this just serves as a sanity check that we do the same thing + // even if we set style before calling the API. + div.style.animationPlayState = 'running'; + animation.pause(); + var previousAnimVal = getMarginLeft(cs); + + return animation.ready.then(waitForFrame).then(function() { + assert_equals(cs.animationPlayState, 'running', + 'animation-play-state is running'); + assert_equals(getMarginLeft(cs), previousAnimVal, + 'Paused value of margin-left does not change'); + }); +}, 'pause() applies pending changes to animation-play-state first'); +// (Note that we can't actually test for this; see comment above, in test-body.) + +promise_test(function(t) { + var div = addDiv(t, { style: 'animation: anim 1000s' }); + var animation = div.getAnimations()[0]; + var readyPromiseRun = false; + + return animation.ready.then(function() { + div.style.animationPlayState = 'paused'; + assert_equals(animation.playState, 'pending', 'Animation is pause pending'); + + // Set current time + animation.currentTime = 5 * MS_PER_SEC; + assert_equals(animation.playState, 'paused', + 'Animation is paused immediately after setting currentTime'); + assert_equals(animation.startTime, null, + 'Animation startTime is unresolved immediately after ' + + 'setting currentTime'); + assert_equals(animation.currentTime, 5 * MS_PER_SEC, + 'Animation currentTime does not change when forcing a ' + + 'pause operation to complete'); + + // The ready promise should now be resolved. If it's not then test will + // probably time out before anything else happens that causes it to resolve. + return animation.ready; + }); +}, 'Setting the current time completes a pending pause'); + +done(); +</script> +</body> diff --git a/dom/animation/test/css-animations/file_animation-playstate.html b/dom/animation/test/css-animations/file_animation-playstate.html new file mode 100644 index 000000000..ce9839f38 --- /dev/null +++ b/dom/animation/test/css-animations/file_animation-playstate.html @@ -0,0 +1,71 @@ +<!doctype html> +<meta charset=utf-8> +<script src="../testcommon.js"></script> +<style> +@keyframes anim { } +</style> +<body> +<script> +'use strict'; + +promise_test(function(t) { + var div = addDiv(t); + var cs = window.getComputedStyle(div); + div.style.animation = 'anim 1000s'; + var animation = div.getAnimations()[0]; + assert_equals(animation.playState, 'pending'); + + return animation.ready.then(function() { + assert_equals(animation.playState, 'running'); + }); +}, 'Animation returns correct playState when running'); + +promise_test(function(t) { + var div = addDiv(t); + var cs = window.getComputedStyle(div); + div.style.animation = 'anim 1000s paused'; + var animation = div.getAnimations()[0]; + assert_equals(animation.playState, 'pending'); + + return animation.ready.then(function() { + assert_equals(animation.playState, 'paused'); + }); +}, 'Animation returns correct playState when paused'); + +promise_test(function(t) { + var div = addDiv(t); + var cs = window.getComputedStyle(div); + div.style.animation = 'anim 1000s'; + var animation = div.getAnimations()[0]; + animation.pause(); + assert_equals(animation.playState, 'pending'); + + return animation.ready.then(function() { + assert_equals(animation.playState, 'paused'); + }); +}, 'Animation.playState updates when paused by script'); + +test(function(t) { + var div = addDiv(t); + var cs = window.getComputedStyle(div); + div.style.animation = 'anim 1000s paused'; + var animation = div.getAnimations()[0]; + div.style.animationPlayState = 'running'; + + // This test also checks that calling playState flushes style + assert_equals(animation.playState, 'pending', + 'Animation.playState reports pending after updating' + + ' animation-play-state (got: ' + animation.playState + ')'); +}, 'Animation.playState updates when resumed by setting style'); + +test(function(t) { + var div = addDiv(t); + div.style.animation = 'anim 1000s'; + var animation = div.getAnimations()[0]; + animation.cancel(); + assert_equals(animation.playState, 'idle'); +}, 'Animation returns correct playState when cancelled'); + +done(); +</script> +</body> diff --git a/dom/animation/test/css-animations/file_animation-ready.html b/dom/animation/test/css-animations/file_animation-ready.html new file mode 100644 index 000000000..9318a1a18 --- /dev/null +++ b/dom/animation/test/css-animations/file_animation-ready.html @@ -0,0 +1,149 @@ +<!doctype html> +<meta charset=utf-8> +<script src="../testcommon.js"></script> +<style> +@keyframes abc { + to { transform: translate(10px) } +} +</style> +<body> +<script> +'use strict'; + +promise_test(function(t) { + var div = addDiv(t); + div.style.animation = 'abc 100s paused'; + var animation = div.getAnimations()[0]; + var originalReadyPromise = animation.ready; + + return animation.ready.then(function() { + div.style.animationPlayState = 'running'; + assert_not_equals(animation.ready, originalReadyPromise, + 'After updating animation-play-state a new ready promise' + + ' object is created'); + }); +}, 'A new ready promise is created when setting animation-play-state: running'); + +promise_test(function(t) { + var div = addDiv(t); + + // Set up pending animation + div.style.animation = 'abc 100s'; + var animation = div.getAnimations()[0]; + assert_equals(animation.playState, 'pending', + 'Animation is initially pending'); + + // Set up listeners on ready promise + var retPromise = animation.ready.then(function() { + assert_unreached('ready promise is fulfilled'); + }).catch(function(err) { + assert_equals(err.name, 'AbortError', + 'ready promise is rejected with AbortError'); + }); + + // Now cancel the animation and flush styles + div.style.animation = ''; + window.getComputedStyle(div).animation; + + return retPromise; +}, 'ready promise is rejected when an animation is cancelled by resetting' + + ' the animation property'); + +promise_test(function(t) { + var div = addDiv(t); + + // As before, but this time instead of removing all animations, simply update + // the list of animations. At least for Firefox, updating is a different + // code path. + + // Set up pending animation + div.style.animation = 'abc 100s'; + var animation = div.getAnimations()[0]; + assert_equals(animation.playState, 'pending', + 'Animation is initially pending'); + + // Set up listeners on ready promise + var retPromise = animation.ready.then(function() { + assert_unreached('ready promise was fulfilled'); + }).catch(function(err) { + assert_equals(err.name, 'AbortError', + 'ready promise is rejected with AbortError'); + }); + + // Now update the animation and flush styles + div.style.animation = 'def 100s'; + window.getComputedStyle(div).animation; + + return retPromise; +}, 'ready promise is rejected when an animation is cancelled by updating' + + ' the animation property'); + +promise_test(function(t) { + var div = addDiv(t, { style: 'animation: abc 100s' }); + var animation = div.getAnimations()[0]; + var originalReadyPromise = animation.ready; + + return animation.ready.then(function() { + div.style.animationPlayState = 'paused'; + assert_not_equals(animation.ready, originalReadyPromise, + 'A new Promise object is generated when setting' + + ' animation-play-state: paused'); + }); +}, 'A new ready promise is created when setting animation-play-state: paused'); + +promise_test(function(t) { + var div = addDiv(t, { style: 'animation: abc 100s' }); + var animation = div.getAnimations()[0]; + + return animation.ready.then(function() { + div.style.animationPlayState = 'paused'; + var firstReadyPromise = animation.ready; + animation.pause(); + assert_equals(animation.ready, firstReadyPromise, + 'Ready promise objects are identical after redundant pause'); + }); +}, 'Pausing twice re-uses the same Promise'); + +promise_test(function(t) { + var div = addDiv(t, { style: 'animation: abc 100s' }); + var animation = div.getAnimations()[0]; + + return animation.ready.then(function() { + div.style.animationPlayState = 'paused'; + + // Flush style and verify we're pending at the same time + assert_equals(animation.playState, 'pending', 'Animation is pending'); + var pauseReadyPromise = animation.ready; + + // Now play again immediately + div.style.animationPlayState = 'running'; + assert_equals(animation.playState, 'pending', 'Animation is still pending'); + assert_equals(animation.ready, pauseReadyPromise, + 'The pause Promise is re-used when playing while waiting' + + ' to pause'); + + return animation.ready; + }).then(function() { + assert_equals(animation.playState, 'running', + 'Animation is running after aborting a pause'); + }); +}, 'If a pause operation is interrupted, the ready promise is reused'); + +promise_test(function(t) { + var div = addDiv(t, { style: 'animation: abc 100s' }); + var animation = div.getAnimations()[0]; + + return animation.ready.then(function() { + div.style.animationPlayState = 'paused'; + return animation.ready; + }).then(function(resolvedAnimation) { + assert_equals(resolvedAnimation, animation, + 'Promise received when ready Promise for a pause operation' + + ' is completed is the animation on which the pause was' + + ' performed'); + }); +}, 'When a pause is complete the Promise callback gets the correct animation'); + +done(); +</script> +</body> diff --git a/dom/animation/test/css-animations/file_animation-reverse.html b/dom/animation/test/css-animations/file_animation-reverse.html new file mode 100644 index 000000000..5060fa55f --- /dev/null +++ b/dom/animation/test/css-animations/file_animation-reverse.html @@ -0,0 +1,29 @@ +<!doctype html> +<meta charset=utf-8> +<script src="../testcommon.js"></script> +<style> +@keyframes anim { + to { transform: translate(100px) } +} +</style> +<body> +<script> +'use strict'; + +test(function(t) { + var div = addDiv(t, { style: 'animation: anim 100s' }); + var animation = div.getAnimations()[0]; + div.style.animation = ""; + flushComputedStyle(div); + + assert_equals(animation.currentTime, null); + animation.reverse(); + + assert_equals(animation.currentTime, 100 * MS_PER_SEC, + 'animation.currentTime should be its effect end'); +}, 'reverse() from idle state starts playing the animation'); + + +done(); +</script> +</body> diff --git a/dom/animation/test/css-animations/file_animation-starttime.html b/dom/animation/test/css-animations/file_animation-starttime.html new file mode 100644 index 000000000..46144464c --- /dev/null +++ b/dom/animation/test/css-animations/file_animation-starttime.html @@ -0,0 +1,383 @@ +<!doctype html> +<html> + <head> + <meta charset=utf-8> + <title>Tests for the effect of setting a CSS animation's + Animation.startTime</title> + <style> + +.animated-div { + margin-left: 10px; + /* Make it easier to calculate expected values: */ + animation-timing-function: linear ! important; +} + +@keyframes anim { + from { margin-left: 100px; } + to { margin-left: 200px; } +} + + </style> + <script src="../testcommon.js"></script> + </head> + <body> + <script type="text/javascript"> + +'use strict'; + +// TODO: We should separate this test(Testing for CSS Animation events / +// Testing for start time of Web Animation). +// e.g: +// CSS Animation events test: +// - check the firing an event after setting an Animation.startTime +// The start time of Web Animation test: +// - check an start time value on several situation(init / processing..) +// - Based on W3C Spec, check the behavior of setting current time. + +// TODO: Once the computedTiming property is implemented, add checks to the +// checker helpers to ensure that computedTiming's properties are updated as +// expected. +// See https://bugzilla.mozilla.org/show_bug.cgi?id=1108055 + +const CSS_ANIM_EVENTS = + ['animationstart', 'animationiteration', 'animationend']; + +test(function(t) +{ + var div = addDiv(t, { 'style': 'animation: anim 100s' }); + var animation = div.getAnimations()[0]; + + assert_equals(animation.startTime, null, 'startTime is unresolved'); +}, 'startTime of a newly created (play-pending) animation is unresolved'); + +test(function(t) +{ + var div = addDiv(t, { 'style': 'animation: anim 100s paused' }); + var animation = div.getAnimations()[0]; + assert_equals(animation.startTime, null, 'startTime is unresolved'); +}, 'startTime of a newly created (pause-pending) animation is unresolved'); + +promise_test(function(t) +{ + var div = addDiv(t, { 'style': 'animation: anim 100s' }); + var animation = div.getAnimations()[0]; + + return animation.ready.then(function() { + assert_true(animation.startTime > 0, + 'startTime is resolved when running'); + }); +}, 'startTime is resolved when running'); + +promise_test(function(t) +{ + var div = addDiv(t, { 'style': 'animation: anim 100s paused' }); + var animation = div.getAnimations()[0]; + + return animation.ready.then(function() { + assert_equals(animation.startTime, null, + 'startTime is unresolved when paused'); + }); +}, 'startTime is unresolved when paused'); + +promise_test(function(t) +{ + var div = addDiv(t, { 'style': 'animation: anim 100s' }); + var animation = div.getAnimations()[0]; + + return animation.ready.then(function() { + div.style.animationPlayState = 'paused'; + getComputedStyle(div).animationPlayState; + + assert_not_equals(animation.startTime, null, + 'startTime is resolved when pause-pending'); + + div.style.animationPlayState = 'running'; + getComputedStyle(div).animationPlayState; + + assert_not_equals(animation.startTime, null, + 'startTime is preserved when a pause is aborted'); + }); +}, 'startTime while pause-pending and play-pending'); + +promise_test(function(t) { + var div = addDiv(t, { 'style': 'animation: anim 100s' }); + var animation = div.getAnimations()[0]; + // Seek to end to put us in the finished state + animation.currentTime = 100 * MS_PER_SEC; + + return animation.ready.then(function() { + // Call play() which puts us back in the running state + animation.play(); + + assert_equals(animation.startTime, null, 'startTime is unresolved'); + }); +}, 'startTime while play-pending from finished state'); + +test(function(t) { + var div = addDiv(t, { 'style': 'animation: anim 100s' }); + var animation = div.getAnimations()[0]; + animation.finish(); + // Call play() which puts us back in the running state + animation.play(); + + assert_equals(animation.startTime, null, 'startTime is unresolved'); +}, 'startTime while play-pending from finished state using finish()'); + +promise_test(function(t) { + var div = addDiv(t, { style: 'animation: anim 100s' }); + var animation = div.getAnimations()[0]; + + assert_equals(animation.startTime, null, 'The initial startTime is null'); + var initialTimelineTime = document.timeline.currentTime; + + return animation.ready.then(function() { + assert_true(animation.startTime > initialTimelineTime, + 'After the animation has started, startTime is greater than ' + + 'the time when it was started'); + var startTimeBeforePausing = animation.startTime; + + div.style.animationPlayState = 'paused'; + // Flush styles just in case querying animation.startTime doesn't flush + // styles (which would be a bug in of itself and could mask a further bug + // by causing startTime to appear to not change). + getComputedStyle(div).animationPlayState; + + assert_equals(animation.startTime, startTimeBeforePausing, + 'The startTime does not change when pausing-pending'); + return animation.ready; + }).then(function() { + assert_equals(animation.startTime, null, + 'After actually pausing, the startTime of an animation ' + + 'is null'); + }); +}, 'Pausing should make the startTime become null'); + +test(function(t) +{ + var div = addDiv(t, {'class': 'animated-div'}); + div.style.animation = 'anim 100s 100s'; + var animation = div.getAnimations()[0]; + var currentTime = animation.timeline.currentTime; + animation.startTime = currentTime; + + assert_times_equal(animation.startTime, currentTime, + 'Check setting of startTime actually works'); +}, 'Sanity test to check round-tripping assigning to a new animation\'s ' + + 'startTime'); + +promise_test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + var eventWatcher = new EventWatcher(t, div, CSS_ANIM_EVENTS); + div.style.animation = 'anim 100s 100s'; + var animation = div.getAnimations()[0]; + + return animation.ready.then(function() { + assert_less_than_equal(animation.startTime, animation.timeline.currentTime, + 'Animation.startTime should be less than the timeline\'s ' + + 'currentTime on the first paint tick after animation creation'); + + animation.startTime = animation.timeline.currentTime - 100 * MS_PER_SEC; + return eventWatcher.wait_for('animationstart'); + }).then(function() { + animation.startTime = animation.timeline.currentTime - 200 * MS_PER_SEC; + return eventWatcher.wait_for('animationend'); + }); +}, 'Skipping forward through animation'); + +promise_test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + var eventWatcher = new EventWatcher(t, div, CSS_ANIM_EVENTS); + div.style.animation = 'anim 100s 100s'; + var animation = div.getAnimations()[0]; + animation.startTime = animation.timeline.currentTime - 200 * MS_PER_SEC; + var previousTimelineTime = animation.timeline.currentTime; + + return eventWatcher.wait_for(['animationstart', + 'animationend']).then(function() { + assert_true(document.timeline.currentTime - previousTimelineTime < + 100 * MS_PER_SEC, + 'Sanity check that seeking worked rather than the events ' + + 'firing after normal playback through the very long ' + + 'animation duration'); + + animation.startTime = animation.timeline.currentTime - 150 * MS_PER_SEC; + + // Despite going backwards from after the end of the animation (to being + // in the active interval), we now expect an 'animationstart' event + // because the animation should go from being inactive to active. + return eventWatcher.wait_for('animationstart'); + }).then(function() { + animation.startTime = animation.timeline.currentTime; + + // Despite going backwards from just after the active interval starts to + // the animation start time, we now expect an animationend event + // because we went from inside to outside the active interval. + return eventWatcher.wait_for('animationend'); + }).then(function() { + assert_less_than_equal(animation.startTime, animation.timeline.currentTime, + 'Animation.startTime should be less than the timeline\'s ' + + 'currentTime on the first paint tick after animation creation'); + }); +}, 'Skipping backwards through animation'); + +// Next we have multiple tests to check that redundant startTime changes do NOT +// dispatch events. It's impossible to distinguish between events not being +// dispatched and events just taking an incredibly long time to dispatch +// without waiting an infinitely long time. Obviously we don't want to do that +// (block this test from finishing forever), so instead we just listen for +// events until two animation frames (i.e. requestAnimationFrame callbacks) +// have happened, then assume that no events will ever be dispatched for the +// redundant changes if no events were detected in that time. + +promise_test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + var eventWatcher = new EventWatcher(t, div, CSS_ANIM_EVENTS); + div.style.animation = "anim 100s 100s"; + var animation = div.getAnimations()[0]; + + animation.startTime = animation.timeline.currentTime - 150 * MS_PER_SEC; + animation.startTime = animation.timeline.currentTime - 50 * MS_PER_SEC; + + return waitForAnimationFrames(2); +}, 'Redundant change, before -> active, then back'); + +promise_test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + var eventWatcher = new EventWatcher(t, div, CSS_ANIM_EVENTS); + div.style.animation = "anim 100s 100s"; + var animation = div.getAnimations()[0]; + + animation.startTime = animation.timeline.currentTime - 250 * MS_PER_SEC; + animation.startTime = animation.timeline.currentTime - 50 * MS_PER_SEC; + + return waitForAnimationFrames(2); +}, 'Redundant change, before -> after, then back'); + +promise_test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + var eventWatcher = new EventWatcher(t, div, CSS_ANIM_EVENTS); + div.style.animation = "anim 100s 100s"; + var animation = div.getAnimations()[0]; + + var retPromise = eventWatcher.wait_for('animationstart').then(function() { + animation.startTime = animation.timeline.currentTime - 50 * MS_PER_SEC; + animation.startTime = animation.timeline.currentTime - 150 * MS_PER_SEC; + + return waitForAnimationFrames(2); + }); + // get us into the initial state: + animation.startTime = animation.timeline.currentTime - 150 * MS_PER_SEC; + + return retPromise; +}, 'Redundant change, active -> before, then back'); + +promise_test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + var eventWatcher = new EventWatcher(t, div, CSS_ANIM_EVENTS); + div.style.animation = "anim 100s 100s"; + var animation = div.getAnimations()[0]; + + var retPromise = eventWatcher.wait_for('animationstart').then(function() { + animation.startTime = animation.timeline.currentTime - 250 * MS_PER_SEC; + animation.startTime = animation.timeline.currentTime - 150 * MS_PER_SEC; + + return waitForAnimationFrames(2); + }); + // get us into the initial state: + animation.startTime = animation.timeline.currentTime - 150 * MS_PER_SEC; + + return retPromise; +}, 'Redundant change, active -> after, then back'); + +promise_test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + var eventWatcher = new EventWatcher(t, div, CSS_ANIM_EVENTS); + div.style.animation = "anim 100s 100s"; + var animation = div.getAnimations()[0]; + + var retPromise = eventWatcher.wait_for(['animationstart', + 'animationend']).then(function() { + animation.startTime = animation.timeline.currentTime - 50 * MS_PER_SEC; + animation.startTime = animation.timeline.currentTime - 250 * MS_PER_SEC; + + return waitForAnimationFrames(2); + }); + // get us into the initial state: + animation.startTime = animation.timeline.currentTime - 250 * MS_PER_SEC; + + return retPromise; +}, 'Redundant change, after -> before, then back'); + +promise_test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + var eventWatcher = new EventWatcher(t, div, CSS_ANIM_EVENTS); + div.style.animation = "anim 100s 100s"; + var animation = div.getAnimations()[0]; + + var retPromise = eventWatcher.wait_for(['animationstart', + 'animationend']).then(function() { + animation.startTime = animation.timeline.currentTime - 150 * MS_PER_SEC; + animation.startTime = animation.timeline.currentTime - 250 * MS_PER_SEC; + + return waitForAnimationFrames(2); + + }); + // get us into the initial state: + animation.startTime = animation.timeline.currentTime - 250 * MS_PER_SEC; + + return retPromise; +}, 'Redundant change, after -> active, then back'); + +promise_test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + div.style.animation = 'anim 100s 100s'; + var animation = div.getAnimations()[0]; + var storedCurrentTime; + + return animation.ready.then(function() { + storedCurrentTime = animation.currentTime; + animation.startTime = null; + return animation.ready; + }).then(function() { + assert_equals(animation.currentTime, storedCurrentTime, + 'Test that hold time is correct'); + }); +}, 'Setting startTime to null'); + +promise_test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + div.style.animation = 'anim 100s'; + var animation = div.getAnimations()[0]; + + return animation.ready.then(function() { + var savedStartTime = animation.startTime; + + assert_not_equals(animation.startTime, null, + 'Animation.startTime not null on ready Promise resolve'); + + animation.pause(); + return animation.ready; + }).then(function() { + assert_equals(animation.startTime, null, + 'Animation.startTime is null after paused'); + assert_equals(animation.playState, 'paused', + 'Animation.playState is "paused" after pause() call'); + }); +}, 'Animation.startTime after pausing'); + +promise_test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + div.style.animation = 'anim 100s'; + var animation = div.getAnimations()[0]; + + return animation.ready.then(function() { + animation.cancel(); + assert_equals(animation.startTime, null, + 'The startTime of a cancelled animation should be null'); + }); +}, 'Animation.startTime after cancelling'); + +done(); + </script> + </body> +</html> diff --git a/dom/animation/test/css-animations/file_animations-dynamic-changes.html b/dom/animation/test/css-animations/file_animations-dynamic-changes.html new file mode 100644 index 000000000..8f16536ae --- /dev/null +++ b/dom/animation/test/css-animations/file_animations-dynamic-changes.html @@ -0,0 +1,154 @@ +<!doctype html> +<meta charset=utf-8> +<script src="../testcommon.js"></script> +<style> +@keyframes anim1 { + to { left: 100px } +} +@keyframes anim2 { } +</style> +<body> +<script> +'use strict'; + +promise_test(function(t) { + var div = addDiv(t); + div.style.animation = 'anim1 100s'; + var originalAnimation = div.getAnimations()[0]; + var originalStartTime; + var originalCurrentTime; + + // Wait a moment so we can confirm the startTime doesn't change (and doesn't + // simply reflect the current time). + return originalAnimation.ready.then(function() { + originalStartTime = originalAnimation.startTime; + originalCurrentTime = originalAnimation.currentTime; + + // Wait a moment so we can confirm the startTime doesn't change (and + // doesn't simply reflect the current time). + return waitForFrame(); + }).then(function() { + div.style.animationDuration = '200s'; + var animation = div.getAnimations()[0]; + assert_equals(animation, originalAnimation, + 'The same Animation is returned after updating' + + ' animation duration'); + assert_equals(animation.startTime, originalStartTime, + 'Animations returned by getAnimations preserve' + + ' their startTime even when they are updated'); + // Sanity check + assert_not_equals(animation.currentTime, originalCurrentTime, + 'Animation.currentTime has updated in next' + + ' requestAnimationFrame callback'); + }); +}, 'Animations preserve their startTime when changed'); + +test(function(t) { + var div = addDiv(t); + div.style.animation = 'anim1 100s, anim1 100s'; + + // Store original state + var animations = div.getAnimations(); + var animation1 = animations[0]; + var animation2 = animations[1]; + + // Update first in list + div.style.animationDuration = '200s, 100s'; + animations = div.getAnimations(); + assert_equals(animations[0], animation1, + 'First Animation is in same position after update'); + assert_equals(animations[1], animation2, + 'Second Animation is in same position after update'); +}, 'Updated Animations maintain their order in the list'); + +promise_test(function(t) { + var div = addDiv(t); + div.style.animation = 'anim1 200s, anim1 100s'; + + // Store original state + var animations = div.getAnimations(); + var animation1 = animations[0]; + var animation2 = animations[1]; + + // Wait before continuing so we can compare start times (otherwise the + // new Animation objects and existing Animation objects will all have the same + // start time). + return waitForAllAnimations(animations).then(waitForFrame).then(function() { + // Swap duration of first and second in list and prepend animation at the + // same time + div.style.animation = 'anim1 100s, anim1 100s, anim1 200s'; + animations = div.getAnimations(); + assert_true(animations[0] !== animation1 && animations[0] !== animation2, + 'New Animation is prepended to start of list'); + assert_equals(animations[1], animation1, + 'First Animation is in second position after update'); + assert_equals(animations[2], animation2, + 'Second Animation is in third position after update'); + assert_equals(animations[1].startTime, animations[2].startTime, + 'Old Animations have the same start time'); + // TODO: Check that animations[0].startTime === null + return animations[0].ready; + }).then(function() { + assert_true(animations[0].startTime > animations[1].startTime, + 'New Animation has later start time'); + }); +}, 'Only the startTimes of existing animations are preserved'); + +promise_test(function(t) { + var div = addDiv(t); + div.style.animation = 'anim1 100s, anim1 100s'; + var secondAnimation = div.getAnimations()[1]; + + // Wait before continuing so we can compare start times + return secondAnimation.ready.then(waitForFrame).then(function() { + // Trim list of animations + div.style.animationName = 'anim1'; + var animations = div.getAnimations(); + assert_equals(animations.length, 1, 'List of Animations was trimmed'); + assert_equals(animations[0], secondAnimation, + 'Remaining Animation is the second one in the list'); + assert_equals(typeof(animations[0].startTime), 'number', + 'Remaining Animation has resolved startTime'); + assert_true(animations[0].startTime < animations[0].timeline.currentTime, + 'Remaining Animation preserves startTime'); + }); +}, 'Animations are removed from the start of the list while preserving' + + ' the state of existing Animations'); + +promise_test(function(t) { + var div = addDiv(t); + div.style.animation = 'anim1 100s'; + var firstAddedAnimation = div.getAnimations()[0], + secondAddedAnimation, + animations; + + // Wait and add second Animation + return firstAddedAnimation.ready.then(waitForFrame).then(function() { + div.style.animation = 'anim1 100s, anim1 100s'; + secondAddedAnimation = div.getAnimations()[0]; + + // Wait again and add another Animation + return secondAddedAnimation.ready.then(waitForFrame); + }).then(function() { + div.style.animation = 'anim1 100s, anim2 100s, anim1 100s'; + animations = div.getAnimations(); + assert_not_equals(firstAddedAnimation, secondAddedAnimation, + 'New Animations are added to start of the list'); + assert_equals(animations[0], secondAddedAnimation, + 'Second Animation remains in same position after' + + ' interleaving'); + assert_equals(animations[2], firstAddedAnimation, + 'First Animation remains in same position after' + + ' interleaving'); + return animations[1].ready; + }).then(function() { + assert_true(animations[1].startTime > animations[0].startTime, + 'Interleaved animation starts later than existing animations'); + assert_true(animations[0].startTime > animations[2].startTime, + 'Original animations retain their start time'); + }); +}, 'Animation state is preserved when interleaving animations in list'); + +done(); +</script> +</body> diff --git a/dom/animation/test/css-animations/file_cssanimation-animationname.html b/dom/animation/test/css-animations/file_cssanimation-animationname.html new file mode 100644 index 000000000..fd69d8577 --- /dev/null +++ b/dom/animation/test/css-animations/file_cssanimation-animationname.html @@ -0,0 +1,37 @@ +<!doctype html> +<meta charset=utf-8> +<script src="../testcommon.js"></script> +<style> +@keyframes xyz { + to { left: 100px } +} +</style> +<body> +<script> +'use strict'; + +test(function(t) { + var div = addDiv(t); + div.style.animation = 'xyz 100s'; + assert_equals(div.getAnimations()[0].animationName, 'xyz', + 'Animation name matches keyframes rule name'); +}, 'Animation name makes keyframe rule'); + +test(function(t) { + var div = addDiv(t); + div.style.animation = 'x\\yz 100s'; + assert_equals(div.getAnimations()[0].animationName, 'xyz', + 'Escaped animation name matches keyframes rule name'); +}, 'Escaped animation name'); + +test(function(t) { + var div = addDiv(t); + div.style.animation = 'x\\79 z 100s'; + assert_equals(div.getAnimations()[0].animationName, 'xyz', + 'Hex-escaped animation name matches keyframes rule' + + ' name'); +}, 'Animation name with hex-escape'); + +done(); +</script> +</body> diff --git a/dom/animation/test/css-animations/file_document-get-animations.html b/dom/animation/test/css-animations/file_document-get-animations.html new file mode 100644 index 000000000..abe02d7fc --- /dev/null +++ b/dom/animation/test/css-animations/file_document-get-animations.html @@ -0,0 +1,276 @@ +<!doctype html> +<meta charset=utf-8> +<script src="../testcommon.js"></script> +<style> +@keyframes animLeft { + to { left: 100px } +} +@keyframes animTop { + to { top: 100px } +} +@keyframes animBottom { + to { bottom: 100px } +} +@keyframes animRight { + to { right: 100px } +} +</style> +<body> +<script> +'use strict'; + +test(function(t) { + assert_equals(document.getAnimations().length, 0, + 'getAnimations returns an empty sequence for a document' + + ' with no animations'); +}, 'getAnimations for non-animated content'); + +test(function(t) { + var div = addDiv(t); + + // Add an animation + div.style.animation = 'animLeft 100s'; + assert_equals(document.getAnimations().length, 1, + 'getAnimations returns a running CSS Animation'); + + // Add another animation + div.style.animation = 'animLeft 100s, animTop 100s'; + assert_equals(document.getAnimations().length, 2, + 'getAnimations returns two running CSS Animations'); + + // Remove both + div.style.animation = ''; + assert_equals(document.getAnimations().length, 0, + 'getAnimations returns no running CSS Animations'); +}, 'getAnimations for CSS Animations'); + +test(function(t) { + var div = addDiv(t); + div.style.animation = 'animLeft 100s, animTop 100s, animRight 100s, ' + + 'animBottom 100s'; + + var animations = document.getAnimations(); + assert_equals(animations.length, 4, + 'getAnimations returns all running CSS Animations'); + assert_equals(animations[0].animationName, 'animLeft', + 'Order of first animation returned'); + assert_equals(animations[1].animationName, 'animTop', + 'Order of second animation returned'); + assert_equals(animations[2].animationName, 'animRight', + 'Order of third animation returned'); + assert_equals(animations[3].animationName, 'animBottom', + 'Order of fourth animation returned'); +}, 'Order of CSS Animations - within an element'); + +test(function(t) { + var div1 = addDiv(t, { style: 'animation: animLeft 100s' }); + var div2 = addDiv(t, { style: 'animation: animLeft 100s' }); + var div3 = addDiv(t, { style: 'animation: animLeft 100s' }); + var div4 = addDiv(t, { style: 'animation: animLeft 100s' }); + + var animations = document.getAnimations(); + assert_equals(animations.length, 4, + 'getAnimations returns all running CSS Animations'); + assert_equals(animations[0].effect.target, div1, + 'Order of first animation returned'); + assert_equals(animations[1].effect.target, div2, + 'Order of second animation returned'); + assert_equals(animations[2].effect.target, div3, + 'Order of third animation returned'); + assert_equals(animations[3].effect.target, div4, + 'Order of fourth animation returned'); + + // Order should be depth-first pre-order so add some depth as follows: + // + // <parent> + // / | + // 2 3 + // / \ + // 1 4 + // + // Which should give: 2, 1, 4, 3 + div2.appendChild(div1); + div2.appendChild(div4); + animations = document.getAnimations(); + assert_equals(animations[0].effect.target, div2, + 'Order of first animation returned after tree surgery'); + assert_equals(animations[1].effect.target, div1, + 'Order of second animation returned after tree surgery'); + assert_equals(animations[2].effect.target, div4, + 'Order of third animation returned after tree surgery'); + assert_equals(animations[3].effect.target, div3, + 'Order of fourth animation returned after tree surgery'); + +}, 'Order of CSS Animations - across elements'); + +test(function(t) { + var div1 = addDiv(t, { style: 'animation: animLeft 100s, animTop 100s' }); + var div2 = addDiv(t, { style: 'animation: animBottom 100s' }); + + var expectedResults = [ [ div1, 'animLeft' ], + [ div1, 'animTop' ], + [ div2, 'animBottom' ] ]; + var animations = document.getAnimations(); + assert_equals(animations.length, expectedResults.length, + 'getAnimations returns all running CSS Animations'); + animations.forEach(function(anim, i) { + assert_equals(anim.effect.target, expectedResults[i][0], + 'Target of animation in position ' + i); + assert_equals(anim.animationName, expectedResults[i][1], + 'Name of animation in position ' + i); + }); + + // Modify tree structure and animation list + div2.appendChild(div1); + div1.style.animation = 'animLeft 100s, animRight 100s, animTop 100s'; + + expectedResults = [ [ div2, 'animBottom' ], + [ div1, 'animLeft' ], + [ div1, 'animRight' ], + [ div1, 'animTop' ] ]; + animations = document.getAnimations(); + assert_equals(animations.length, expectedResults.length, + 'getAnimations returns all running CSS Animations after ' + + 'making changes'); + animations.forEach(function(anim, i) { + assert_equals(anim.effect.target, expectedResults[i][0], + 'Target of animation in position ' + i + ' after changes'); + assert_equals(anim.animationName, expectedResults[i][1], + 'Name of animation in position ' + i + ' after changes'); + }); +}, 'Order of CSS Animations - across and within elements'); + +test(function(t) { + var div = addDiv(t, { style: 'animation: animLeft 100s, animTop 100s' }); + var animLeft = document.getAnimations()[0]; + assert_equals(animLeft.animationName, 'animLeft', + 'Originally, animLeft animation comes first'); + + // Disassociate animLeft from markup and restart + div.style.animation = 'animTop 100s'; + animLeft.play(); + + var animations = document.getAnimations(); + assert_equals(animations.length, 2, + 'getAnimations returns markup-bound and free animations'); + assert_equals(animations[0].animationName, 'animTop', + 'Markup-bound animations come first'); + assert_equals(animations[1], animLeft, 'Free animations come last'); +}, 'Order of CSS Animations - markup-bound vs free animations'); + +test(function(t) { + var div = addDiv(t, { style: 'animation: animLeft 100s, animTop 100s' }); + var animLeft = document.getAnimations()[0]; + var animTop = document.getAnimations()[1]; + + // Disassociate both animations from markup and restart in opposite order + div.style.animation = ''; + animTop.play(); + animLeft.play(); + + var animations = document.getAnimations(); + assert_equals(animations.length, 2, + 'getAnimations returns free animations'); + assert_equals(animations[0], animTop, + 'Free animations are returned in the order they are started'); + assert_equals(animations[1], animLeft, + 'Animations started later are returned later'); + + // Restarting an animation should have no effect + animTop.cancel(); + animTop.play(); + assert_equals(document.getAnimations()[0], animTop, + 'After restarting, the ordering of free animations' + + ' does not change'); +}, 'Order of CSS Animations - free animations'); + +test(function(t) { + // Add an animation first + var div = addDiv(t, { style: 'animation: animLeft 100s' }); + div.style.top = '0px'; + div.style.transition = 'all 100s'; + flushComputedStyle(div); + + // *Then* add a transition + div.style.top = '100px'; + flushComputedStyle(div); + + // Although the transition was added later, it should come first in the list + var animations = document.getAnimations(); + assert_equals(animations.length, 2, + 'Both CSS animations and transitions are returned'); + assert_class_string(animations[0], 'CSSTransition', 'Transition comes first'); + assert_class_string(animations[1], 'CSSAnimation', 'Animation comes second'); +}, 'Order of CSS Animations and CSS Transitions'); + +test(function(t) { + var div = addDiv(t, { style: 'animation: animLeft 100s forwards' }); + div.getAnimations()[0].finish(); + assert_equals(document.getAnimations().length, 1, + 'Forwards-filling CSS animations are returned'); +}, 'Finished but filling CSS Animations are returned'); + +test(function(t) { + var div = addDiv(t, { style: 'animation: animLeft 100s' }); + div.getAnimations()[0].finish(); + assert_equals(document.getAnimations().length, 0, + 'Non-filling finished CSS animations are not returned'); +}, 'Finished but not filling CSS Animations are not returned'); + +test(function(t) { + var div = addDiv(t, { style: 'animation: animLeft 100s 100s' }); + assert_equals(document.getAnimations().length, 1, + 'Yet-to-start CSS animations are returned'); +}, 'Yet-to-start CSS Animations are returned'); + +test(function(t) { + var div = addDiv(t, { style: 'animation: animLeft 100s' }); + div.getAnimations()[0].cancel(); + assert_equals(document.getAnimations().length, 0, + 'CSS animations cancelled by the API are not returned'); +}, 'CSS Animations cancelled via the API are not returned'); + +test(function(t) { + var div = addDiv(t, { style: 'animation: animLeft 100s' }); + var anim = div.getAnimations()[0]; + anim.cancel(); + anim.play(); + assert_equals(document.getAnimations().length, 1, + 'CSS animations cancelled and restarted by the API are ' + + 'returned'); +}, 'CSS Animations cancelled and restarted via the API are returned'); + +test(function(t) { + addStyle(t, { '#parent::after': 'animation: animLeft 10s;', + '#parent::before': 'animation: animRight 10s;' }); + // create two divs with these arrangement: + // parent + // ::before, + // ::after + // | + // child + var parent = addDiv(t, { 'id': 'parent' }); + var child = addDiv(t); + parent.appendChild(child); + [parent, child].forEach((div) => { + div.setAttribute('style', 'animation: animBottom 10s'); + }); + + var anims = document.getAnimations(); + assert_equals(anims.length, 4, + 'CSS animations on both pseudo-elements and elements ' + + 'are returned'); + assert_equals(anims[0].effect.target, parent, + 'The animation targeting the parent element comes first'); + assert_equals(anims[1].effect.target.type, '::before', + 'The animation targeting the ::before element comes second'); + assert_equals(anims[2].effect.target.type, '::after', + 'The animation targeting the ::after element comes third'); + assert_equals(anims[3].effect.target, child, + 'The animation targeting the child element comes last'); +}, 'CSS Animations targetting (pseudo-)elements should have correct order ' + + 'after sorting'); + +done(); +</script> +</body> diff --git a/dom/animation/test/css-animations/file_effect-target.html b/dom/animation/test/css-animations/file_effect-target.html new file mode 100644 index 000000000..006028e34 --- /dev/null +++ b/dom/animation/test/css-animations/file_effect-target.html @@ -0,0 +1,54 @@ +<!doctype html> +<meta charset=utf-8> +<script src="../testcommon.js"></script> +<style> +@keyframes anim { } +</style> +<body> +<script> +'use strict'; + +test(function(t) { + var div = addDiv(t); + div.style.animation = 'anim 100s'; + var animation = div.getAnimations()[0]; + assert_equals(animation.effect.target, div, + 'Animation.target is the animatable div'); +}, 'Returned CSS animations have the correct effect target'); + +test(function(t) { + addStyle(t, { '.after::after': 'animation: anim 10s, anim 100s;' }); + var div = addDiv(t, { class: 'after' }); + var anims = document.getAnimations(); + assert_equals(anims.length, 2, + 'Got animations running on ::after pseudo element'); + assert_equals(anims[0].effect.target, anims[1].effect.target, + 'Both animations return the same target object'); +}, 'effect.target should return the same CSSPseudoElement object each time'); + +test(function(t) { + addStyle(t, { '.after::after': 'animation: anim 10s;' }); + var div = addDiv(t, { class: 'after' }); + var pseudoTarget = document.getAnimations()[0].effect.target; + var effect = new KeyframeEffectReadOnly(pseudoTarget, + { background: ["blue", "red"] }, + 3 * MS_PER_SEC); + var newAnim = new Animation(effect, document.timeline); + newAnim.play(); + var anims = document.getAnimations(); + assert_equals(anims.length, 2, + 'Got animations running on ::after pseudo element'); + assert_not_equals(anims[0], newAnim, + 'The scriped-generated animation appears last'); + assert_equals(newAnim.effect.target, pseudoTarget, + 'The effect.target of the scripted-generated animation is ' + + 'the same as the one from the argument of ' + + 'KeyframeEffectReadOnly constructor'); + assert_equals(anims[0].effect.target, newAnim.effect.target, + 'Both animations return the same target object'); +}, 'effect.target from the script-generated animation should return the same ' + + 'CSSPseudoElement object as that from the CSS generated animation'); + +done(); +</script> +</body> diff --git a/dom/animation/test/css-animations/file_element-get-animations.html b/dom/animation/test/css-animations/file_element-get-animations.html new file mode 100644 index 000000000..68386c98b --- /dev/null +++ b/dom/animation/test/css-animations/file_element-get-animations.html @@ -0,0 +1,445 @@ +<!doctype html> +<meta charset=utf-8> +<script src="../testcommon.js"></script> +<style> +@keyframes anim1 { + to { left: 100px } +} +@keyframes anim2 { + to { top: 100px } +} +@keyframes multiPropAnim { + to { background: green, opacity: 0.5, left: 100px, top: 100px } +} +@keyframes empty { } +</style> +<body> +<script> +'use strict'; + +test(function(t) { + var div = addDiv(t); + assert_equals(div.getAnimations().length, 0, + 'getAnimations returns an empty sequence for an element' + + ' with no animations'); +}, 'getAnimations for non-animated content'); + +promise_test(function(t) { + var div = addDiv(t); + + // FIXME: This test does too many things. It should be split up. + + // Add an animation + div.style.animation = 'anim1 100s'; + var animations = div.getAnimations(); + assert_equals(animations.length, 1, + 'getAnimations returns an Animation running CSS Animations'); + return animations[0].ready.then(function() { + var startTime = animations[0].startTime; + assert_true(startTime > 0 && startTime <= document.timeline.currentTime, + 'CSS animation has a sensible start time'); + + // Wait a moment then add a second animation. + // + // We wait for the next frame so that we can test that the start times of + // the animations differ. + return waitForFrame(); + }).then(function() { + div.style.animation = 'anim1 100s, anim2 100s'; + animations = div.getAnimations(); + assert_equals(animations.length, 2, + 'getAnimations returns one Animation for each value of' + + ' animation-name'); + // Wait until both Animations are ready + // (We don't make any assumptions about the order of the Animations since + // that is the purpose of the following test.) + return waitForAllAnimations(animations); + }).then(function() { + assert_true(animations[0].startTime < animations[1].startTime, + 'Additional Animations for CSS animations start after the original' + + ' animation and appear later in the list'); + }); +}, 'getAnimations for CSS Animations'); + +test(function(t) { + var div = addDiv(t, { style: 'animation: anim1 100s' }); + assert_class_string(div.getAnimations()[0], 'CSSAnimation', + 'Interface of returned animation is CSSAnimation'); +}, 'getAnimations returns CSSAnimation objects for CSS Animations'); + +test(function(t) { + var div = addDiv(t); + + // Add an animation that targets multiple properties + div.style.animation = 'multiPropAnim 100s'; + assert_equals(div.getAnimations().length, 1, + 'getAnimations returns only one Animation for a CSS Animation' + + ' that targets multiple properties'); +}, 'getAnimations for multi-property animations'); + +promise_test(function(t) { + var div = addDiv(t); + + // Add an animation + div.style.backgroundColor = 'red'; + div.style.animation = 'anim1 100s'; + window.getComputedStyle(div).backgroundColor; + + // Wait until a frame after the animation starts, then add a transition + var animations = div.getAnimations(); + return animations[0].ready.then(waitForFrame).then(function() { + div.style.transition = 'all 100s'; + div.style.backgroundColor = 'green'; + + animations = div.getAnimations(); + assert_equals(animations.length, 2, + 'getAnimations returns Animations for both animations and' + + ' transitions that run simultaneously'); + assert_class_string(animations[0], 'CSSTransition', + 'First-returned animation is the CSS Transition'); + assert_class_string(animations[1], 'CSSAnimation', + 'Second-returned animation is the CSS Animation'); + }); +}, 'getAnimations for both CSS Animations and CSS Transitions at once'); + +async_test(function(t) { + var div = addDiv(t); + + // Set up event listener + div.addEventListener('animationend', t.step_func(function() { + assert_equals(div.getAnimations().length, 0, + 'getAnimations does not return Animations for finished ' + + ' (and non-forwards-filling) CSS Animations'); + t.done(); + })); + + // Add a very short animation + div.style.animation = 'anim1 0.01s'; +}, 'getAnimations for CSS Animations that have finished'); + +async_test(function(t) { + var div = addDiv(t); + + // Set up event listener + div.addEventListener('animationend', t.step_func(function() { + assert_equals(div.getAnimations().length, 1, + 'getAnimations returns Animations for CSS Animations that have' + + ' finished but are filling forwards'); + t.done(); + })); + + // Add a very short animation + div.style.animation = 'anim1 0.01s forwards'; +}, 'getAnimations for CSS Animations that have finished but are' + + ' forwards filling'); + +test(function(t) { + var div = addDiv(t); + div.style.animation = 'none 100s'; + + var animations = div.getAnimations(); + assert_equals(animations.length, 0, + 'getAnimations returns an empty sequence for an element' + + ' with animation-name: none'); + + div.style.animation = 'none 100s, anim1 100s'; + animations = div.getAnimations(); + assert_equals(animations.length, 1, + 'getAnimations returns Animations only for those CSS Animations whose' + + ' animation-name is not none'); +}, 'getAnimations for CSS Animations with animation-name: none'); + +test(function(t) { + var div = addDiv(t); + div.style.animation = 'missing 100s'; + var animations = div.getAnimations(); + assert_equals(animations.length, 0, + 'getAnimations returns an empty sequence for an element' + + ' with animation-name: missing'); + + div.style.animation = 'anim1 100s, missing 100s'; + animations = div.getAnimations(); + assert_equals(animations.length, 1, + 'getAnimations returns Animations only for those CSS Animations whose' + + ' animation-name is found'); +}, 'getAnimations for CSS Animations with animation-name: missing'); + +promise_test(function(t) { + var div = addDiv(t); + div.style.animation = 'anim1 100s, notyet 100s'; + var animations = div.getAnimations(); + assert_equals(animations.length, 1, + 'getAnimations initally only returns Animations for CSS Animations whose' + + ' animation-name is found'); + + return animations[0].ready.then(waitForFrame).then(function() { + var keyframes = '@keyframes notyet { to { left: 100px; } }'; + document.styleSheets[0].insertRule(keyframes, 0); + animations = div.getAnimations(); + assert_equals(animations.length, 2, + 'getAnimations includes Animation when @keyframes rule is added' + + ' later'); + return waitForAllAnimations(animations); + }).then(function() { + assert_true(animations[0].startTime < animations[1].startTime, + 'Newly added animation has a later start time'); + document.styleSheets[0].deleteRule(0); + }); +}, 'getAnimations for CSS Animations where the @keyframes rule is added' + + ' later'); + +test(function(t) { + var div = addDiv(t); + div.style.animation = 'anim1 100s, anim1 100s'; + assert_equals(div.getAnimations().length, 2, + 'getAnimations returns one Animation for each CSS animation-name' + + ' even if the names are duplicated'); +}, 'getAnimations for CSS Animations with duplicated animation-name'); + +test(function(t) { + var div = addDiv(t); + div.style.animation = 'empty 100s'; + assert_equals(div.getAnimations().length, 1, + 'getAnimations returns Animations for CSS animations with an' + + ' empty keyframes rule'); +}, 'getAnimations for CSS Animations with empty keyframes rule'); + +promise_test(function(t) { + var div = addDiv(t); + div.style.animation = 'anim1 100s 100s'; + var animations = div.getAnimations(); + assert_equals(animations.length, 1, + 'getAnimations returns animations for CSS animations whose' + + ' delay makes them start later'); + return animations[0].ready.then(waitForFrame).then(function() { + assert_true(animations[0].startTime <= document.timeline.currentTime, + 'For CSS Animations in delay phase, the start time of the Animation is' + + ' not in the future'); + }); +}, 'getAnimations for CSS animations in delay phase'); + +test(function(t) { + var div = addDiv(t); + div.style.animation = 'anim1 0s 100s'; + assert_equals(div.getAnimations().length, 1, + 'getAnimations returns animations for CSS animations whose' + + ' duration is zero'); + div.remove(); +}, 'getAnimations for zero-duration CSS Animations'); + +test(function(t) { + var div = addDiv(t); + div.style.animation = 'anim1 100s'; + var originalAnimation = div.getAnimations()[0]; + + // Update pause state (an Animation change) + div.style.animationPlayState = 'paused'; + var pendingAnimation = div.getAnimations()[0]; + assert_equals(pendingAnimation.playState, 'pending', + 'animation\'s play state is updated'); + assert_equals(originalAnimation, pendingAnimation, + 'getAnimations returns the same objects even when their' + + ' play state changes'); + + // Update duration (an Animation change) + div.style.animationDuration = '200s'; + var extendedAnimation = div.getAnimations()[0]; + // FIXME: Check extendedAnimation.effect.timing.duration has changed once the + // API is available + assert_equals(originalAnimation, extendedAnimation, + 'getAnimations returns the same objects even when their' + + ' duration changes'); +}, 'getAnimations returns objects with the same identity'); + +test(function(t) { + var div = addDiv(t); + div.style.animation = 'anim1 100s'; + + assert_equals(div.getAnimations().length, 1, + 'getAnimations returns an animation before cancelling'); + + var animation = div.getAnimations()[0]; + + animation.cancel(); + assert_equals(div.getAnimations().length, 0, + 'getAnimations does not return cancelled animations'); + + animation.play(); + assert_equals(div.getAnimations().length, 1, + 'getAnimations returns cancelled animations that have been re-started'); + +}, 'getAnimations for CSS Animations that are cancelled'); + +promise_test(function(t) { + var div = addDiv(t); + div.style.animation = 'anim2 100s'; + + return div.getAnimations()[0].ready.then(function() { + // Prepend to the list and test that even though anim1 was triggered + // *after* anim2, it should come first because it appears first + // in the animation-name property. + div.style.animation = 'anim1 100s, anim2 100s'; + var anims = div.getAnimations(); + assert_equals(anims[0].animationName, 'anim1', + 'animation order after prepending to list'); + assert_equals(anims[1].animationName, 'anim2', + 'animation order after prepending to list'); + + // Normally calling cancel and play would this push anim1 to the top of + // the stack but it shouldn't for CSS animations that map an the + // animation-name property. + var anim1 = anims[0]; + anim1.cancel(); + anim1.play(); + anims = div.getAnimations(); + assert_equals(anims[0].animationName, 'anim1', + 'animation order after cancelling and restarting'); + assert_equals(anims[1].animationName, 'anim2', + 'animation order after cancelling and restarting'); + }); +}, 'getAnimations for CSS Animations follows animation-name order'); + +test(function(t) { + addStyle(t, { '#target::after': 'animation: anim1 10s;', + '#target::before': 'animation: anim1 10s;' }); + var target = addDiv(t, { 'id': 'target' }); + target.style.animation = 'anim1 100s'; + + var animations = target.getAnimations({ subtree: false }); + assert_equals(animations.length, 1, + 'Should find only the element'); + assert_equals(animations[0].effect.target, target, + 'Effect target should be the element'); +}, 'Test AnimationFilter{ subtree: false } with single element'); + +test(function(t) { + addStyle(t, { '#target::after': 'animation: anim1 10s;', + '#target::before': 'animation: anim1 10s;' }); + var target = addDiv(t, { 'id': 'target' }); + target.style.animation = 'anim1 100s'; + + var animations = target.getAnimations({ subtree: true }); + assert_equals(animations.length, 3, + 'getAnimations({ subtree: true }) ' + + 'should return animations on pseudo-elements'); + assert_equals(animations[0].effect.target, target, + 'The animation targeting the parent element ' + + 'should be returned first'); + assert_equals(animations[1].effect.target.type, '::before', + 'The animation targeting the ::before pseudo-element ' + + 'should be returned second'); + assert_equals(animations[2].effect.target.type, '::after', + 'The animation targeting the ::after pesudo-element ' + + 'should be returned last'); +}, 'Test AnimationFilter{ subtree: true } with single element'); + +test(function(t) { + addStyle(t, { '#parent::after': 'animation: anim1 10s;', + '#parent::before': 'animation: anim1 10s;', + '#child::after': 'animation: anim1 10s;', + '#child::before': 'animation: anim1 10s;' }); + var parent = addDiv(t, { 'id': 'parent' }); + parent.style.animation = 'anim1 100s'; + var child = addDiv(t, { 'id': 'child' }); + child.style.animation = 'anim1 100s'; + parent.appendChild(child); + + var animations = parent.getAnimations({ subtree: false }); + assert_equals(animations.length, 1, + 'Should find only the element even if it has a child'); + assert_equals(animations[0].effect.target, parent, + 'Effect target shuld be the element'); +}, 'Test AnimationFilter{ subtree: false } with element that has a child'); + +test(function(t) { + addStyle(t, { '#parent::after': 'animation: anim1 10s;', + '#parent::before': 'animation: anim1 10s;', + '#child::after': 'animation: anim1 10s;', + '#child::before': 'animation: anim1 10s;' }); + var parent = addDiv(t, { 'id': 'parent' }); + var child = addDiv(t, { 'id': 'child' }); + parent.style.animation = 'anim1 100s'; + child.style.animation = 'anim1 100s'; + parent.appendChild(child); + + var animations = parent.getAnimations({ subtree: true }); + assert_equals(animations.length, 6, + 'Should find all elements, pesudo-elements that parent has'); + + assert_equals(animations[0].effect.target, parent, + 'The animation targeting the parent element ' + + 'should be returned first'); + assert_equals(animations[1].effect.target.type, '::before', + 'The animation targeting the ::before pseudo-element ' + + 'should be returned second'); + assert_equals(animations[1].effect.target.parentElement, parent, + 'This ::before element should be child of parent element'); + assert_equals(animations[2].effect.target.type, '::after', + 'The animation targeting the ::after pesudo-element ' + + 'should be returned third'); + assert_equals(animations[2].effect.target.parentElement, parent, + 'This ::after element should be child of parent element'); + + assert_equals(animations[3].effect.target, child, + 'The animation targeting the child element ' + + 'should be returned fourth'); + assert_equals(animations[4].effect.target.type, '::before', + 'The animation targeting the ::before pseudo-element ' + + 'should be returned fifth'); + assert_equals(animations[4].effect.target.parentElement, child, + 'This ::before element should be child of child element'); + assert_equals(animations[5].effect.target.type, '::after', + 'The animation targeting the ::after pesudo-element ' + + 'should be returned last'); + assert_equals(animations[5].effect.target.parentElement, child, + 'This ::after element should be child of child element'); +}, 'Test AnimationFilter{ subtree: true } with element that has a child'); + +test(function(t) { + var parent = addDiv(t, { 'id': 'parent' }); + var child1 = addDiv(t, { 'id': 'child1' }); + var grandchild1 = addDiv(t, { 'id': 'grandchild1' }); + var grandchild2 = addDiv(t, { 'id': 'grandchild2' }); + var child2 = addDiv(t, { 'id': 'child2' }); + + parent.style.animation = 'anim1 100s'; + child1.style.animation = 'anim1 100s'; + grandchild1.style.animation = 'anim1 100s'; + grandchild2.style.animation = 'anim1 100s'; + child2.style.animation = 'anim1 100s'; + + parent.appendChild(child1); + child1.appendChild(grandchild1); + child1.appendChild(grandchild2); + parent.appendChild(child2); + + var animations = parent.getAnimations({ subtree: true }); + assert_equals( + parent.getAnimations({ subtree: true }).length, 5, + 'Should find all descendants of the element'); + + assert_equals(animations[0].effect.target, parent, + 'The animation targeting the parent element ' + + 'should be returned first'); + + assert_equals(animations[1].effect.target, child1, + 'The animation targeting the child1 element ' + + 'should be returned second'); + + assert_equals(animations[2].effect.target, grandchild1, + 'The animation targeting the grandchild1 element ' + + 'should be returned third'); + + assert_equals(animations[3].effect.target, grandchild2, + 'The animation targeting the grandchild2 element ' + + 'should be returned fourth'); + + assert_equals(animations[4].effect.target, child2, + 'The animation targeting the child2 element ' + + 'should be returned last'); + +}, 'Test AnimationFilter{ subtree: true } with element that has many descendant'); + +done(); +</script> +</body> diff --git a/dom/animation/test/css-animations/file_keyframeeffect-getkeyframes.html b/dom/animation/test/css-animations/file_keyframeeffect-getkeyframes.html new file mode 100644 index 000000000..15e2d23f1 --- /dev/null +++ b/dom/animation/test/css-animations/file_keyframeeffect-getkeyframes.html @@ -0,0 +1,672 @@ +<!doctype html> +<meta charset=utf-8> +<script src="../testcommon.js"></script> +<style> +@keyframes anim-empty { } + +@keyframes anim-empty-frames { + from { } + to { } +} + +@keyframes anim-only-timing { + from { animation-timing-function: linear; } + to { } +} + +@keyframes anim-only-non-animatable { + from { animation-duration: 3s; } + to { animation-duration: 5s; } +} + +@keyframes anim-simple { + from { color: black; } + to { color: white; } +} + +@keyframes anim-simple-three { + from { color: black; } + 50% { color: blue; } + to { color: white; } +} + +@keyframes anim-simple-timing { + from { color: black; animation-timing-function: linear; } + 50% { color: blue; animation-timing-function: ease-in-out; } + to { color: white; animation-timing-function: step-end; } +} + +@keyframes anim-simple-timing-some { + from { color: black; animation-timing-function: linear; } + 50% { color: blue; } + to { color: white; } +} + +@keyframes anim-simple-shorthand { + from { margin: 8px; } + to { margin: 16px; } +} + +@keyframes anim-omit-to { + from { color: blue; } +} + +@keyframes anim-omit-from { + to { color: blue; } +} + +@keyframes anim-omit-from-to { + 50% { color: blue; } +} + +@keyframes anim-partially-omit-to { + from { margin-top: 50px; + margin-bottom: 100px; } + to { margin-top: 150px !important; /* ignored */ + margin-bottom: 200px; } +} + +@keyframes anim-different-props { + from { color: black; margin-top: 8px; } + 25% { color: blue; } + 75% { margin-top: 12px; } + to { color: white; margin-top: 16px } +} + +@keyframes anim-different-props-and-easing { + from { color: black; margin-top: 8px; animation-timing-function: linear; } + 25% { color: blue; animation-timing-function: step-end; } + 75% { margin-top: 12px; animation-timing-function: ease-in; } + to { color: white; margin-top: 16px } +} + +@keyframes anim-merge-offset { + from { color: black; } + to { color: white; } + from { margin-top: 8px; } + to { margin-top: 16px; } +} + +@keyframes anim-merge-offset-and-easing { + from { color: black; animation-timing-function: step-end; } + to { color: white; } + from { margin-top: 8px; animation-timing-function: linear; } + to { margin-top: 16px; } + from { font-size: 16px; animation-timing-function: step-end; } + to { font-size: 32px; } + from { padding-left: 2px; animation-timing-function: linear; } + to { padding-left: 4px; } +} + +@keyframes anim-no-merge-equiv-easing { + from { margin-top: 0px; animation-timing-function: steps(1, end); } + from { margin-right: 0px; animation-timing-function: step-end; } + from { margin-bottom: 0px; animation-timing-function: steps(1); } + 50% { margin-top: 10px; animation-timing-function: step-end; } + 50% { margin-right: 10px; animation-timing-function: step-end; } + 50% { margin-bottom: 10px; animation-timing-function: step-end; } + to { margin-top: 20px; margin-right: 20px; margin-bottom: 20px; } +} + +@keyframes anim-overriding { + from { padding-top: 50px } + 50%, from { padding-top: 30px } /* wins: 0% */ + 75%, 85%, 50% { padding-top: 20px } /* wins: 75%, 50% */ + 100%, 85% { padding-top: 70px } /* wins: 100% */ + 85.1% { padding-top: 60px } /* wins: 85.1% */ + 85% { padding-top: 30px } /* wins: 85% */ +} + +@keyframes anim-filter { + to { filter: blur(5px) sepia(60%) saturate(30%); } +} + +@keyframes anim-text-shadow { + to { text-shadow: none; } +} + +@keyframes anim-background-size { + to { background-size: 50%, 6px, contain } +} + +:root { + --var-100px: 100px; +} +@keyframes anim-variables { + to { transform: translate(var(--var-100px), 0) } +} +@keyframes anim-variables-shorthand { + to { margin: var(--var-100px) } +} +</style> +<body> +<script> +"use strict"; + +function getKeyframes(e) { + return e.getAnimations()[0].effect.getKeyframes(); +} + +function assert_frames_equal(a, b, name) { + assert_equals(Object.keys(a).sort().toString(), + Object.keys(b).sort().toString(), + "properties on " + name); + for (var p in a) { + if (p === 'offset' || p === 'computedOffset') { + assert_approx_equals(a[p], b[p], 0.00001, + "value for '" + p + "' on " + name); + } else { + assert_equals(a[p], b[p], "value for '" + p + "' on " + name); + } + } +} + +// animation-timing-function values to test with, where the value +// is exactly the same as its serialization, sorted by the order +// getKeyframes() will group frames with the same easing function +// together (by nsTimingFunction::Compare). +const kTimingFunctionValues = [ + "ease", + "linear", + "ease-in", + "ease-out", + "ease-in-out", + "steps(1, start)", + "steps(2, start)", + "steps(1)", + "steps(2)", + "cubic-bezier(0, 0, 1, 1)", + "cubic-bezier(0, 0.25, 0.75, 1)" +]; + +test(function(t) { + var div = addDiv(t); + + div.style.animation = 'anim-empty 100s'; + assert_equals(getKeyframes(div).length, 0, + "number of frames with empty @keyframes"); + + div.style.animation = 'anim-empty-frames 100s'; + assert_equals(getKeyframes(div).length, 0, + "number of frames when @keyframes has empty keyframes"); + + div.style.animation = 'anim-only-timing 100s'; + assert_equals(getKeyframes(div).length, 0, + "number of frames when @keyframes only has keyframes with " + + "animation-timing-function"); + + div.style.animation = 'anim-only-non-animatable 100s'; + assert_equals(getKeyframes(div).length, 0, + "number of frames when @keyframes only has frames with " + + "non-animatable properties"); +}, 'KeyframeEffectReadOnly.getKeyframes() returns no frames for various kinds' + + ' of empty enimations'); + +test(function(t) { + var div = addDiv(t); + + div.style.animation = 'anim-simple 100s'; + var frames = getKeyframes(div); + + assert_equals(frames.length, 2, "number of frames"); + + var expected = [ + { offset: 0, computedOffset: 0, easing: "ease", + color: "rgb(0, 0, 0)" }, + { offset: 1, computedOffset: 1, easing: "ease", + color: "rgb(255, 255, 255)" }, + ]; + + for (var i = 0; i < frames.length; i++) { + assert_frames_equal(frames[i], expected[i], "ComputedKeyframe #" + i); + } +}, 'KeyframeEffectReadOnly.getKeyframes() returns expected frames for a simple' + + 'animation'); + +test(function(t) { + kTimingFunctionValues.forEach(function(easing) { + var div = addDiv(t); + + div.style.animation = 'anim-simple-three 100s ' + easing; + var frames = getKeyframes(div); + + assert_equals(frames.length, 3, "number of frames"); + + for (var i = 0; i < frames.length; i++) { + assert_equals(frames[i].easing, easing, + "value for 'easing' on ComputedKeyframe #" + i); + } + }); +}, 'KeyframeEffectReadOnly.getKeyframes() returns frames with expected easing' + + ' values, when the easing comes from animation-timing-function on the' + + ' element'); + +test(function(t) { + var div = addDiv(t); + + div.style.animation = 'anim-simple-timing 100s'; + var frames = getKeyframes(div); + + assert_equals(frames.length, 3, "number of frames"); + assert_equals(frames[0].easing, "linear", + "value of 'easing' on ComputedKeyframe #0"); + assert_equals(frames[1].easing, "ease-in-out", + "value of 'easing' on ComputedKeyframe #1"); + assert_equals(frames[2].easing, "steps(1)", + "value of 'easing' on ComputedKeyframe #2"); +}, 'KeyframeEffectReadOnly.getKeyframes() returns frames with expected easing' + + ' values, when the easing is specified on each keyframe'); + +test(function(t) { + var div = addDiv(t); + + div.style.animation = 'anim-simple-timing-some 100s step-start'; + var frames = getKeyframes(div); + + assert_equals(frames.length, 3, "number of frames"); + assert_equals(frames[0].easing, "linear", + "value of 'easing' on ComputedKeyframe #0"); + assert_equals(frames[1].easing, "steps(1, start)", + "value of 'easing' on ComputedKeyframe #1"); + assert_equals(frames[2].easing, "steps(1, start)", + "value of 'easing' on ComputedKeyframe #2"); +}, 'KeyframeEffectReadOnly.getKeyframes() returns frames with expected easing' + + ' values, when the easing is specified on some keyframes'); + +test(function(t) { + var div = addDiv(t); + + div.style.animation = 'anim-simple-shorthand 100s'; + var frames = getKeyframes(div); + + assert_equals(frames.length, 2, "number of frames"); + + var expected = [ + { offset: 0, computedOffset: 0, easing: "ease", + marginBottom: "8px", marginLeft: "8px", + marginRight: "8px", marginTop: "8px" }, + { offset: 1, computedOffset: 1, easing: "ease", + marginBottom: "16px", marginLeft: "16px", + marginRight: "16px", marginTop: "16px" }, + ]; + + for (var i = 0; i < frames.length; i++) { + assert_frames_equal(frames[i], expected[i], "ComputedKeyframe #" + i); + } +}, 'KeyframeEffectReadOnly.getKeyframes() returns expected frames for a simple' + + ' animation that specifies a single shorthand property'); + +test(function(t) { + var div = addDiv(t); + + div.style.animation = 'anim-omit-to 100s'; + div.style.color = 'white'; + var frames = getKeyframes(div); + + assert_equals(frames.length, 2, "number of frames"); + + var expected = [ + { offset: 0, computedOffset: 0, easing: "ease", + color: "rgb(0, 0, 255)" }, + { offset: 1, computedOffset: 1, easing: "ease", + color: "rgb(255, 255, 255)" }, + ]; + + for (var i = 0; i < frames.length; i++) { + assert_frames_equal(frames[i], expected[i], "ComputedKeyframe #" + i); + } +}, 'KeyframeEffectReadOnly.getKeyframes() returns expected frames for an ' + + 'animation with a 0% keyframe and no 100% keyframe'); + +test(function(t) { + var div = addDiv(t); + + div.style.animation = 'anim-omit-from 100s'; + div.style.color = 'white'; + var frames = getKeyframes(div); + + assert_equals(frames.length, 2, "number of frames"); + + var expected = [ + { offset: 0, computedOffset: 0, easing: "ease", + color: "rgb(255, 255, 255)" }, + { offset: 1, computedOffset: 1, easing: "ease", + color: "rgb(0, 0, 255)" }, + ]; + + for (var i = 0; i < frames.length; i++) { + assert_frames_equal(frames[i], expected[i], "ComputedKeyframe #" + i); + } +}, 'KeyframeEffectReadOnly.getKeyframes() returns expected frames for an ' + + 'animation with a 100% keyframe and no 0% keyframe'); + +test(function(t) { + var div = addDiv(t); + + div.style.animation = 'anim-omit-from-to 100s'; + div.style.color = 'white'; + var frames = getKeyframes(div); + + assert_equals(frames.length, 3, "number of frames"); + + var expected = [ + { offset: 0, computedOffset: 0, easing: "ease", + color: "rgb(255, 255, 255)" }, + { offset: 0.5, computedOffset: 0.5, easing: "ease", + color: "rgb(0, 0, 255)" }, + { offset: 1, computedOffset: 1, easing: "ease", + color: "rgb(255, 255, 255)" }, + ]; + + for (var i = 0; i < frames.length; i++) { + assert_frames_equal(frames[i], expected[i], "ComputedKeyframe #" + i); + } +}, 'KeyframeEffectReadOnly.getKeyframes() returns expected frames for an ' + + 'animation with no 0% or 100% keyframe but with a 50% keyframe'); + +test(function(t) { + var div = addDiv(t); + + div.style.animation = 'anim-partially-omit-to 100s'; + div.style.marginTop = '250px'; + var frames = getKeyframes(div); + + assert_equals(frames.length, 2, "number of frames"); + + var expected = [ + { offset: 0, computedOffset: 0, easing: "ease", + marginTop: '50px', marginBottom: '100px' }, + { offset: 1, computedOffset: 1, easing: "ease", + marginTop: '250px', marginBottom: '200px' }, + ]; + + for (var i = 0; i < frames.length; i++) { + assert_frames_equal(frames[i], expected[i], "ComputedKeyframe #" + i); + } +}, 'KeyframeEffectReadOnly.getKeyframes() returns expected frames for an ' + + 'animation with a partially complete 100% keyframe (because the ' + + '!important rule is ignored)'); + +test(function(t) { + var div = addDiv(t); + + div.style.animation = 'anim-different-props 100s'; + var frames = getKeyframes(div); + + assert_equals(frames.length, 4, "number of frames"); + + var expected = [ + { offset: 0, computedOffset: 0, easing: "ease", + color: "rgb(0, 0, 0)", marginTop: "8px" }, + { offset: 0.25, computedOffset: 0.25, easing: "ease", + color: "rgb(0, 0, 255)" }, + { offset: 0.75, computedOffset: 0.75, easing: "ease", + marginTop: "12px" }, + { offset: 1, computedOffset: 1, easing: "ease", + color: "rgb(255, 255, 255)", marginTop: "16px" }, + ]; + + for (var i = 0; i < frames.length; i++) { + assert_frames_equal(frames[i], expected[i], "ComputedKeyframe #" + i); + } +}, 'KeyframeEffectReadOnly.getKeyframes() returns expected frames for an ' + + 'animation with different properties on different keyframes, all ' + + 'with the same easing function'); + +test(function(t) { + var div = addDiv(t); + + div.style.animation = 'anim-different-props-and-easing 100s'; + var frames = getKeyframes(div); + + assert_equals(frames.length, 4, "number of frames"); + + var expected = [ + { offset: 0, computedOffset: 0, easing: "linear", + color: "rgb(0, 0, 0)", marginTop: "8px" }, + { offset: 0.25, computedOffset: 0.25, easing: "steps(1)", + color: "rgb(0, 0, 255)" }, + { offset: 0.75, computedOffset: 0.75, easing: "ease-in", + marginTop: "12px" }, + { offset: 1, computedOffset: 1, easing: "ease", + color: "rgb(255, 255, 255)", marginTop: "16px" }, + ]; + + for (var i = 0; i < frames.length; i++) { + assert_frames_equal(frames[i], expected[i], "ComputedKeyframe #" + i); + } +}, 'KeyframeEffectReadOnly.getKeyframes() returns expected frames for an ' + + 'animation with different properties on different keyframes, with ' + + 'a different easing function on each'); + +test(function(t) { + var div = addDiv(t); + + div.style.animation = 'anim-merge-offset 100s'; + var frames = getKeyframes(div); + + assert_equals(frames.length, 2, "number of frames"); + + var expected = [ + { offset: 0, computedOffset: 0, easing: "ease", + color: "rgb(0, 0, 0)", marginTop: "8px" }, + { offset: 1, computedOffset: 1, easing: "ease", + color: "rgb(255, 255, 255)", marginTop: "16px" }, + ]; + + for (var i = 0; i < frames.length; i++) { + assert_frames_equal(frames[i], expected[i], "ComputedKeyframe #" + i); + } +}, 'KeyframeEffectReadOnly.getKeyframes() returns expected frames for an ' + + 'animation with multiple keyframes for the same time, and all with ' + + 'the same easing function'); + +test(function(t) { + var div = addDiv(t); + + div.style.animation = 'anim-merge-offset-and-easing 100s'; + var frames = getKeyframes(div); + + assert_equals(frames.length, 3, "number of frames"); + + var expected = [ + { offset: 0, computedOffset: 0, easing: "steps(1)", + color: "rgb(0, 0, 0)", fontSize: "16px" }, + { offset: 0, computedOffset: 0, easing: "linear", + marginTop: "8px", paddingLeft: "2px" }, + { offset: 1, computedOffset: 1, easing: "ease", + color: "rgb(255, 255, 255)", fontSize: "32px", marginTop: "16px", + paddingLeft: "4px" }, + ]; + + for (var i = 0; i < frames.length; i++) { + assert_frames_equal(frames[i], expected[i], "ComputedKeyframe #" + i); + } +}, 'KeyframeEffectReadOnly.getKeyframes() returns expected frames for an ' + + 'animation with multiple keyframes for the same time and with ' + + 'different easing functions'); + +test(function(t) { + var div = addDiv(t); + + div.style.animation = 'anim-no-merge-equiv-easing 100s'; + var frames = getKeyframes(div); + + assert_equals(frames.length, 3, "number of frames"); + + var expected = [ + { offset: 0, computedOffset: 0, easing: "steps(1)", + marginTop: "0px", marginRight: "0px", marginBottom: "0px" }, + { offset: 0.5, computedOffset: 0.5, easing: "steps(1)", + marginTop: "10px", marginRight: "10px", marginBottom: "10px" }, + { offset: 1, computedOffset: 1, easing: "ease", + marginTop: "20px", marginRight: "20px", marginBottom: "20px" }, + ]; + + for (var i = 0; i < frames.length; i++) { + assert_frames_equal(frames[i], expected[i], "ComputedKeyframe #" + i); + } +}, 'KeyframeEffectReadOnly.getKeyframes() returns expected frames for an ' + + 'animation with multiple keyframes for the same time and with ' + + 'different but equivalent easing functions'); + +test(function(t) { + var div = addDiv(t); + + div.style.animation = 'anim-overriding 100s'; + var frames = getKeyframes(div); + + assert_equals(frames.length, 6, "number of frames"); + + var expected = [ + { offset: 0, computedOffset: 0, easing: "ease", + paddingTop: "30px" }, + { offset: 0.5, computedOffset: 0.5, easing: "ease", + paddingTop: "20px" }, + { offset: 0.75, computedOffset: 0.75, easing: "ease", + paddingTop: "20px" }, + { offset: 0.85, computedOffset: 0.85, easing: "ease", + paddingTop: "30px" }, + { offset: 0.851, computedOffset: 0.851, easing: "ease", + paddingTop: "60px" }, + { offset: 1, computedOffset: 1, easing: "ease", + paddingTop: "70px" }, + ]; + + for (var i = 0; i < frames.length; i++) { + assert_frames_equal(frames[i], expected[i], "ComputedKeyframe #" + i); + } +}, 'KeyframeEffectReadOnly.getKeyframes() returns expected frames for ' + + 'overlapping keyframes'); + +// Gecko-specific test case: We are specifically concerned here that the +// computed value for filter, "none", is correctly represented. + +test(function(t) { + var div = addDiv(t); + + div.style.animation = 'anim-filter 100s'; + var frames = getKeyframes(div); + + assert_equals(frames.length, 2, "number of frames"); + + var expected = [ + { offset: 0, computedOffset: 0, easing: "ease", + filter: "none" }, + { offset: 1, computedOffset: 1, easing: "ease", + filter: "blur(5px) sepia(60%) saturate(30%)" }, + ]; + + for (var i = 0; i < frames.length; i++) { + assert_frames_equal(frames[i], expected[i], "ComputedKeyframe #" + i); + } +}, 'KeyframeEffectReadOnly.getKeyframes() returns expected values for ' + + 'animations with filter properties and missing keyframes'); + +// Gecko-specific test case: We are specifically concerned here that the +// computed value for text-shadow and a "none" specified on a keyframe +// are correctly represented. + +test(function(t) { + var div = addDiv(t); + + div.style.textShadow = '1px 1px 2px black, 0 0 16px blue, 0 0 3.2px blue'; + div.style.animation = 'anim-text-shadow 100s'; + var frames = getKeyframes(div); + + assert_equals(frames.length, 2, "number of frames"); + + var expected = [ + { offset: 0, computedOffset: 0, easing: "ease", + textShadow: "1px 1px 2px 0px rgb(0, 0, 0)," + + " 0px 0px 16px 0px rgb(0, 0, 255)," + + " 0px 0px 3.2px 0px rgb(0, 0, 255)" }, + { offset: 1, computedOffset: 1, easing: "ease", textShadow: "none" }, + ]; + + for (var i = 0; i < frames.length; i++) { + assert_frames_equal(frames[i], expected[i], "ComputedKeyframe #" + i); + } +}, 'KeyframeEffectReadOnly.getKeyframes() returns expected values for ' + + 'animations with text-shadow properties and missing keyframes'); + +// Gecko-specific test case: We are specifically concerned here that the +// initial value for background-size and the specified list are correctly +// represented. + +test(function(t) { + var div = addDiv(t); + + div.style.animation = 'anim-background-size 100s'; + var frames = getKeyframes(div); + + assert_equals(frames.length, 2, "number of frames"); + + var expected = [ + { offset: 0, computedOffset: 0, easing: "ease", + backgroundSize: "auto auto" }, + { offset: 1, computedOffset: 1, easing: "ease", + backgroundSize: "50% auto, 6px auto, contain" }, + ]; + + for (var i = 0; i < frames.length; i++) { + assert_frames_equal(frames[i], expected[i], "ComputedKeyframe #" + i); + } + + // Test inheriting a background-size value + + expected[0].backgroundSize = div.style.backgroundSize = + "30px auto, 40% auto, auto auto"; + frames = getKeyframes(div); + + for (var i = 0; i < frames.length; i++) { + assert_frames_equal(frames[i], expected[i], "ComputedKeyframe #" + i + + " after updating current style"); + } +}, 'KeyframeEffectReadOnly.getKeyframes() returns expected values for ' + + 'animations with background-size properties and missing keyframes'); + +test(function(t) { + var div = addDiv(t); + div.style.animation = 'anim-variables 100s'; + + var frames = getKeyframes(div); + + var expected = [ + { offset: 0, computedOffset: 0, easing: "ease", + transform: "none" }, + { offset: 1, computedOffset: 1, easing: "ease", + transform: "translate(100px, 0px)" }, + ]; + for (var i = 0; i < frames.length; i++) { + assert_frames_equal(frames[i], expected[i], "ComputedKeyframe #" + i); + } +}, 'KeyframeEffectReadOnly.getKeyframes() returns expected values for ' + + 'animations with CSS variables as keyframe values'); + +test(function(t) { + var div = addDiv(t); + div.style.animation = 'anim-variables-shorthand 100s'; + + var frames = getKeyframes(div); + + var expected = [ + { offset: 0, computedOffset: 0, easing: "ease", + marginBottom: "0px", + marginLeft: "0px", + marginRight: "0px", + marginTop: "0px" }, + { offset: 1, computedOffset: 1, easing: "ease", + marginBottom: "100px", + marginLeft: "100px", + marginRight: "100px", + marginTop: "100px" }, + ]; + for (var i = 0; i < frames.length; i++) { + assert_frames_equal(frames[i], expected[i], "ComputedKeyframe #" + i); + } +}, 'KeyframeEffectReadOnly.getKeyframes() returns expected values for ' + + 'animations with CSS variables as keyframe values in a shorthand property'); +done(); +</script> +</body> diff --git a/dom/animation/test/css-animations/file_pseudoElement-get-animations.html b/dom/animation/test/css-animations/file_pseudoElement-get-animations.html new file mode 100644 index 000000000..bebe14533 --- /dev/null +++ b/dom/animation/test/css-animations/file_pseudoElement-get-animations.html @@ -0,0 +1,70 @@ +<!doctype html> +<meta charset=utf-8> +<script src="../testcommon.js"></script> +<style> +@keyframes anim1 { } +@keyframes anim2 { } +.before::before { + animation: anim1 10s; +} +.after-with-mix-anims-trans::after { + content: ''; + animation: anim1 10s, anim2 10s; + width: 0px; + height: 0px; + transition: all 100s; +} +.after-change::after { + width: 100px; + height: 100px; +} +</style> +<body> +<script> +'use strict'; + +test(function(t) { + var div = addDiv(t, { class: 'before' }); + var pseudoTarget = document.getAnimations()[0].effect.target; + assert_equals(pseudoTarget.getAnimations().length, 1, + 'Expected number of animations are returned'); + assert_equals(pseudoTarget.getAnimations()[0].animationName, 'anim1', + 'CSS animation name matches'); +}, 'getAnimations returns CSSAnimation objects'); + +test(function(t) { + var div = addDiv(t, { class: 'after-with-mix-anims-trans' }); + // Trigger transitions + flushComputedStyle(div); + div.classList.add('after-change'); + + // Create additional animation on the pseudo-element from script + var pseudoTarget = document.getAnimations()[0].effect.target; + var effect = new KeyframeEffectReadOnly(pseudoTarget, + { background: ["blue", "red"] }, + 3 * MS_PER_SEC); + var newAnimation = new Animation(effect, document.timeline); + newAnimation.id = 'scripted-anim'; + newAnimation.play(); + + // Check order - the script-generated animation should appear later + var anims = pseudoTarget.getAnimations(); + assert_equals(anims.length, 5, + 'Got expected number of animations/trnasitions running on ' + + '::after pseudo element'); + assert_equals(anims[0].transitionProperty, 'height', + '1st animation is the 1st transition sorted by name'); + assert_equals(anims[1].transitionProperty, 'width', + '2nd animation is the 2nd transition sorted by name '); + assert_equals(anims[2].animationName, 'anim1', + '3rd animation is the 1st animation in animation-name list'); + assert_equals(anims[3].animationName, 'anim2', + '4rd animation is the 2nd animation in animation-name list'); + assert_equals(anims[4].id, 'scripted-anim', + 'Animation added by script appears last'); +}, 'getAnimations returns css transitions/animations, and script-generated ' + + 'animations in the expected order'); + +done(); +</script> +</body> diff --git a/dom/animation/test/css-animations/test_animation-cancel.html b/dom/animation/test/css-animations/test_animation-cancel.html new file mode 100644 index 000000000..15c9b482f --- /dev/null +++ b/dom/animation/test/css-animations/test_animation-cancel.html @@ -0,0 +1,15 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id="log"></div> +<script> +'use strict'; +setup({explicit_done: true}); +SpecialPowers.pushPrefEnv( + { "set": [["dom.animations-api.core.enabled", true]]}, + function() { + window.open("file_animation-cancel.html"); + }); +</script> +</html> diff --git a/dom/animation/test/css-animations/test_animation-computed-timing.html b/dom/animation/test/css-animations/test_animation-computed-timing.html new file mode 100644 index 000000000..c1b40aaf3 --- /dev/null +++ b/dom/animation/test/css-animations/test_animation-computed-timing.html @@ -0,0 +1,16 @@ +<!doctype html> +<html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id="log"></div> +<script> +'use strict'; +setup({explicit_done: true}); +SpecialPowers.pushPrefEnv( + { "set": [["dom.animations-api.core.enabled", true]]}, + function() { + window.open("file_animation-computed-timing.html"); + }); +</script> +</html> diff --git a/dom/animation/test/css-animations/test_animation-currenttime.html b/dom/animation/test/css-animations/test_animation-currenttime.html new file mode 100644 index 000000000..7e3a8d74d --- /dev/null +++ b/dom/animation/test/css-animations/test_animation-currenttime.html @@ -0,0 +1,15 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id="log"></div> +<script> +'use strict'; +setup({explicit_done: true}); +SpecialPowers.pushPrefEnv( + { "set": [["dom.animations-api.core.enabled", true]]}, + function() { + window.open("file_animation-currenttime.html"); + }); +</script> +</html> diff --git a/dom/animation/test/css-animations/test_animation-finish.html b/dom/animation/test/css-animations/test_animation-finish.html new file mode 100644 index 000000000..abbd267d8 --- /dev/null +++ b/dom/animation/test/css-animations/test_animation-finish.html @@ -0,0 +1,15 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id="log"></div> +<script> +'use strict'; +setup({explicit_done: true}); +SpecialPowers.pushPrefEnv( + { "set": [["dom.animations-api.core.enabled", true]]}, + function() { + window.open("file_animation-finish.html"); + }); +</script> +</html> diff --git a/dom/animation/test/css-animations/test_animation-finished.html b/dom/animation/test/css-animations/test_animation-finished.html new file mode 100644 index 000000000..295ffe0af --- /dev/null +++ b/dom/animation/test/css-animations/test_animation-finished.html @@ -0,0 +1,15 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id="log"></div> +<script> +'use strict'; +setup({explicit_done: true}); +SpecialPowers.pushPrefEnv( + { "set": [["dom.animations-api.core.enabled", true]]}, + function() { + window.open("file_animation-finished.html"); + }); +</script> +</html> diff --git a/dom/animation/test/css-animations/test_animation-id.html b/dom/animation/test/css-animations/test_animation-id.html new file mode 100644 index 000000000..c23501b8d --- /dev/null +++ b/dom/animation/test/css-animations/test_animation-id.html @@ -0,0 +1,15 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id="log"></div> +<script> +'use strict'; +setup({explicit_done: true}); +SpecialPowers.pushPrefEnv( + { "set": [["dom.animations-api.core.enabled", true]]}, + function() { + window.open("file_animation-id.html"); + }); +</script> +</html> diff --git a/dom/animation/test/css-animations/test_animation-pausing.html b/dom/animation/test/css-animations/test_animation-pausing.html new file mode 100644 index 000000000..10be1ddf0 --- /dev/null +++ b/dom/animation/test/css-animations/test_animation-pausing.html @@ -0,0 +1,15 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id="log"></div> +<script> +'use strict'; +setup({explicit_done: true}); +SpecialPowers.pushPrefEnv( + { "set": [["dom.animations-api.core.enabled", true]]}, + function() { + window.open("file_animation-pausing.html"); + }); +</script> +</html> diff --git a/dom/animation/test/css-animations/test_animation-playstate.html b/dom/animation/test/css-animations/test_animation-playstate.html new file mode 100644 index 000000000..54c8e1f10 --- /dev/null +++ b/dom/animation/test/css-animations/test_animation-playstate.html @@ -0,0 +1,15 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id="log"></div> +<script> +'use strict'; +setup({explicit_done: true}); +SpecialPowers.pushPrefEnv( + { "set": [["dom.animations-api.core.enabled", true]]}, + function() { + window.open("file_animation-playstate.html"); + }); +</script> +</html> diff --git a/dom/animation/test/css-animations/test_animation-ready.html b/dom/animation/test/css-animations/test_animation-ready.html new file mode 100644 index 000000000..445f751b4 --- /dev/null +++ b/dom/animation/test/css-animations/test_animation-ready.html @@ -0,0 +1,15 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id="log"></div> +<script> +'use strict'; +setup({explicit_done: true}); +SpecialPowers.pushPrefEnv( + { "set": [["dom.animations-api.core.enabled", true]]}, + function() { + window.open("file_animation-ready.html"); + }); +</script> +</html> diff --git a/dom/animation/test/css-animations/test_animation-reverse.html b/dom/animation/test/css-animations/test_animation-reverse.html new file mode 100644 index 000000000..673b1e0d3 --- /dev/null +++ b/dom/animation/test/css-animations/test_animation-reverse.html @@ -0,0 +1,15 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id="log"></div> +<script> +'use strict'; +setup({explicit_done: true}); +SpecialPowers.pushPrefEnv( + { "set": [["dom.animations-api.core.enabled", true]]}, + function() { + window.open("file_animation-reverse.html"); + }); +</script> +</html> diff --git a/dom/animation/test/css-animations/test_animation-starttime.html b/dom/animation/test/css-animations/test_animation-starttime.html new file mode 100644 index 000000000..dfae89ffa --- /dev/null +++ b/dom/animation/test/css-animations/test_animation-starttime.html @@ -0,0 +1,15 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id="log"></div> +<script> +'use strict'; +setup({explicit_done: true}); +SpecialPowers.pushPrefEnv( + { "set": [["dom.animations-api.core.enabled", true]]}, + function() { + window.open("file_animation-starttime.html"); + }); +</script> +</html> diff --git a/dom/animation/test/css-animations/test_animations-dynamic-changes.html b/dom/animation/test/css-animations/test_animations-dynamic-changes.html new file mode 100644 index 000000000..ce4eb378d --- /dev/null +++ b/dom/animation/test/css-animations/test_animations-dynamic-changes.html @@ -0,0 +1,15 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id="log"></div> +<script> +'use strict'; +setup({explicit_done: true}); +SpecialPowers.pushPrefEnv( + { "set": [["dom.animations-api.core.enabled", true]]}, + function() { + window.open("file_animations-dynamic-changes.html"); + }); +</script> +</html> diff --git a/dom/animation/test/css-animations/test_cssanimation-animationname.html b/dom/animation/test/css-animations/test_cssanimation-animationname.html new file mode 100644 index 000000000..ccddecc33 --- /dev/null +++ b/dom/animation/test/css-animations/test_cssanimation-animationname.html @@ -0,0 +1,15 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id="log"></div> +<script> +'use strict'; +setup({explicit_done: true}); +SpecialPowers.pushPrefEnv( + { "set": [["dom.animations-api.core.enabled", true]]}, + function() { + window.open("file_cssanimation-animationname.html"); + }); +</script> +</html> diff --git a/dom/animation/test/css-animations/test_document-get-animations.html b/dom/animation/test/css-animations/test_document-get-animations.html new file mode 100644 index 000000000..dc964e62c --- /dev/null +++ b/dom/animation/test/css-animations/test_document-get-animations.html @@ -0,0 +1,15 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id="log"></div> +<script> +'use strict'; +setup({explicit_done: true}); +SpecialPowers.pushPrefEnv( + { "set": [["dom.animations-api.core.enabled", true]]}, + function() { + window.open("file_document-get-animations.html"); + }); +</script> +</html> diff --git a/dom/animation/test/css-animations/test_effect-target.html b/dom/animation/test/css-animations/test_effect-target.html new file mode 100644 index 000000000..6c230c729 --- /dev/null +++ b/dom/animation/test/css-animations/test_effect-target.html @@ -0,0 +1,15 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id="log"></div> +<script> +'use strict'; +setup({explicit_done: true}); +SpecialPowers.pushPrefEnv( + { "set": [["dom.animations-api.core.enabled", true]]}, + function() { + window.open("file_effect-target.html"); + }); +</script> +</html> diff --git a/dom/animation/test/css-animations/test_element-get-animations.html b/dom/animation/test/css-animations/test_element-get-animations.html new file mode 100644 index 000000000..7b39e65cc --- /dev/null +++ b/dom/animation/test/css-animations/test_element-get-animations.html @@ -0,0 +1,15 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id="log"></div> +<script> +'use strict'; +setup({explicit_done: true}); +SpecialPowers.pushPrefEnv( + { "set": [["dom.animations-api.core.enabled", true]]}, + function() { + window.open("file_element-get-animations.html"); + }); +</script> +</html> diff --git a/dom/animation/test/css-animations/test_keyframeeffect-getkeyframes.html b/dom/animation/test/css-animations/test_keyframeeffect-getkeyframes.html new file mode 100644 index 000000000..3cf227008 --- /dev/null +++ b/dom/animation/test/css-animations/test_keyframeeffect-getkeyframes.html @@ -0,0 +1,15 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id="log"></div> +<script> +'use strict'; +setup({explicit_done: true}); +SpecialPowers.pushPrefEnv( + { "set": [["dom.animations-api.core.enabled", true]]}, + function() { + window.open("file_keyframeeffect-getkeyframes.html"); + }); +</script> +</html> diff --git a/dom/animation/test/css-animations/test_pseudoElement-get-animations.html b/dom/animation/test/css-animations/test_pseudoElement-get-animations.html new file mode 100644 index 000000000..1e0dc5c82 --- /dev/null +++ b/dom/animation/test/css-animations/test_pseudoElement-get-animations.html @@ -0,0 +1,14 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id="log"></div> +<script> +'use strict'; +setup({explicit_done: true}); +SpecialPowers.pushPrefEnv( + { "set": [["dom.animations-api.core.enabled", true]]}, + function() { + window.open("file_pseudoElement-get-animations.html"); + }); +</script> diff --git a/dom/animation/test/css-transitions/file_animation-cancel.html b/dom/animation/test/css-transitions/file_animation-cancel.html new file mode 100644 index 000000000..6094b383f --- /dev/null +++ b/dom/animation/test/css-transitions/file_animation-cancel.html @@ -0,0 +1,165 @@ +<!doctype html> +<meta charset=utf-8> +<script src="../testcommon.js"></script> +<body> +<script> +'use strict'; + +promise_test(function(t) { + var div = addDiv(t, { style: 'margin-left: 0px' }); + flushComputedStyle(div); + + div.style.transition = 'margin-left 100s'; + div.style.marginLeft = '1000px'; + flushComputedStyle(div); + + var animation = div.getAnimations()[0]; + return animation.ready.then(waitForFrame).then(function() { + assert_not_equals(getComputedStyle(div).marginLeft, '1000px', + 'transform style is animated before cancelling'); + animation.cancel(); + assert_equals(getComputedStyle(div).marginLeft, div.style.marginLeft, + 'transform style is no longer animated after cancelling'); + }); +}, 'Animated style is cleared after cancelling a running CSS transition'); + +promise_test(function(t) { + var div = addDiv(t, { style: 'margin-left: 0px' }); + flushComputedStyle(div); + + div.style.transition = 'margin-left 100s'; + div.style.marginLeft = '1000px'; + flushComputedStyle(div); + + div.addEventListener('transitionend', function() { + assert_unreached('Got unexpected end event on cancelled transition'); + }); + + var animation = div.getAnimations()[0]; + return animation.ready.then(function() { + // Seek to just before the end then cancel + animation.currentTime = 99.9 * 1000; + animation.cancel(); + + // Then wait a couple of frames and check that no event was dispatched + return waitForAnimationFrames(2); + }); +}, 'Cancelled CSS transitions do not dispatch events'); + +promise_test(function(t) { + var div = addDiv(t, { style: 'margin-left: 0px' }); + flushComputedStyle(div); + + div.style.transition = 'margin-left 100s'; + div.style.marginLeft = '1000px'; + flushComputedStyle(div); + + var animation = div.getAnimations()[0]; + return animation.ready.then(function() { + animation.cancel(); + assert_equals(getComputedStyle(div).marginLeft, '1000px', + 'margin-left style is not animated after cancelling'); + animation.play(); + assert_equals(getComputedStyle(div).marginLeft, '0px', + 'margin-left style is animated after re-starting transition'); + return animation.ready; + }).then(function() { + assert_equals(animation.playState, 'running', + 'Transition succeeds in running after being re-started'); + }); +}, 'After cancelling a transition, it can still be re-used'); + +promise_test(function(t) { + var div = addDiv(t, { style: 'margin-left: 0px' }); + flushComputedStyle(div); + + div.style.transition = 'margin-left 100s'; + div.style.marginLeft = '1000px'; + flushComputedStyle(div); + + var animation = div.getAnimations()[0]; + return animation.ready.then(function() { + animation.finish(); + animation.cancel(); + assert_equals(getComputedStyle(div).marginLeft, '1000px', + 'margin-left style is not animated after cancelling'); + animation.play(); + assert_equals(getComputedStyle(div).marginLeft, '0px', + 'margin-left style is animated after re-starting transition'); + return animation.ready; + }).then(function() { + assert_equals(animation.playState, 'running', + 'Transition succeeds in running after being re-started'); + }); +}, 'After cancelling a finished transition, it can still be re-used'); + +test(function(t) { + var div = addDiv(t, { style: 'margin-left: 0px' }); + flushComputedStyle(div); + + div.style.transition = 'margin-left 100s'; + div.style.marginLeft = '1000px'; + flushComputedStyle(div); + + var animation = div.getAnimations()[0]; + animation.cancel(); + assert_equals(getComputedStyle(div).marginLeft, '1000px', + 'margin-left style is not animated after cancelling'); + + // Trigger a change to a transition property and check that this + // doesn't cause the animation to become live again + div.style.transitionDuration = '200s'; + flushComputedStyle(div); + assert_equals(getComputedStyle(div).marginLeft, '1000px', + 'margin-left style is still not animated after updating' + + ' transition-duration'); + assert_equals(animation.playState, 'idle', + 'Transition is still idle after updating transition-duration'); +}, 'After cancelling a transition, updating transition properties doesn\'t make' + + ' it live again'); + +promise_test(function(t) { + var div = addDiv(t, { style: 'margin-left: 0px' }); + flushComputedStyle(div); + + div.style.transition = 'margin-left 100s'; + div.style.marginLeft = '1000px'; + flushComputedStyle(div); + + var animation = div.getAnimations()[0]; + return animation.ready.then(function() { + assert_equals(animation.playState, 'running'); + div.style.display = 'none'; + return waitForFrame(); + }).then(function() { + assert_equals(animation.playState, 'idle'); + assert_equals(getComputedStyle(div).marginLeft, '1000px'); + }); +}, 'Setting display:none on an element cancels its transitions'); + +promise_test(function(t) { + var parentDiv = addDiv(t); + var childDiv = document.createElement('div'); + parentDiv.appendChild(childDiv); + childDiv.setAttribute('style', 'margin-left: 0px'); + + flushComputedStyle(childDiv); + + childDiv.style.transition = 'margin-left 100s'; + childDiv.style.marginLeft = '1000px'; + flushComputedStyle(childDiv); + + var animation = childDiv.getAnimations()[0]; + return animation.ready.then(function() { + assert_equals(animation.playState, 'running'); + parentDiv.style.display = 'none'; + return waitForFrame(); + }).then(function() { + assert_equals(animation.playState, 'idle'); + assert_equals(getComputedStyle(childDiv).marginLeft, '1000px'); + }); +}, 'Setting display:none cancels transitions on a child element'); + +done(); +</script> +</body> diff --git a/dom/animation/test/css-transitions/file_animation-computed-timing.html b/dom/animation/test/css-transitions/file_animation-computed-timing.html new file mode 100644 index 000000000..2dac82d75 --- /dev/null +++ b/dom/animation/test/css-transitions/file_animation-computed-timing.html @@ -0,0 +1,315 @@ +<!doctype html> +<meta charset=utf-8> +<script src="../testcommon.js"></script> +<style> + +.animated-div { + margin-left: 100px; +} + +</style> +<body> +<script> + +'use strict'; + +// -------------------- +// delay +// -------------------- +test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + div.style.transition = 'margin-left 10s'; + flushComputedStyle(div); + div.style.marginLeft = '10px'; + + + var effect = div.getAnimations()[0].effect; + assert_equals(effect.getComputedTiming().delay, 0, + 'Initial value of delay'); +}, 'delay of a new tranisition'); + +test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + div.style.transition = 'margin-left 10s 10s'; + flushComputedStyle(div); + div.style.marginLeft = '10px'; + + var effect = div.getAnimations()[0].effect; + assert_equals(effect.getComputedTiming().delay, 10000, + 'Initial value of delay'); +}, 'Positive delay of a new transition'); + +test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + div.style.transition = 'margin-left 10s -5s'; + flushComputedStyle(div); + div.style.marginLeft = '10px'; + + var effect = div.getAnimations()[0].effect; + assert_equals(effect.getComputedTiming().delay, -5000, + 'Initial value of delay'); +}, 'Negative delay of a new transition'); + + +// -------------------- +// endDelay +// -------------------- +test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + div.style.transition = 'margin-left 10s'; + flushComputedStyle(div); + div.style.marginLeft = '10px'; + + var effect = div.getAnimations()[0].effect; + assert_equals(effect.getComputedTiming().endDelay, 0, + 'Initial value of endDelay'); +}, 'endDelay of a new transition'); + + +// -------------------- +// fill +// -------------------- +test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + div.style.transition = 'margin-left 10s'; + flushComputedStyle(div); + div.style.marginLeft = '10px'; + + var effect = div.getAnimations()[0].effect; + assert_equals(effect.getComputedTiming().fill, 'backwards', + 'Fill backwards'); +}, 'fill of a new transition'); + + +// -------------------- +// iterationStart +// -------------------- +test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + div.style.transition = 'margin-left 10s'; + flushComputedStyle(div); + div.style.marginLeft = '10px'; + + var effect = div.getAnimations()[0].effect; + assert_equals(effect.getComputedTiming().iterationStart, 0, + 'Initial value of iterationStart'); +}, 'iterationStart of a new transition'); + + +// -------------------- +// iterations +// -------------------- +test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + div.style.transition = 'margin-left 10s'; + flushComputedStyle(div); + div.style.marginLeft = '10px'; + + var effect = div.getAnimations()[0].effect; + assert_equals(effect.getComputedTiming().iterations, 1, + 'Initial value of iterations'); +}, 'iterations of a new transition'); + + +// -------------------- +// duration +// -------------------- +test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + div.style.transition = 'margin-left 10s'; + flushComputedStyle(div); + div.style.marginLeft = '10px'; + + var effect = div.getAnimations()[0].effect; + assert_equals(effect.getComputedTiming().duration, 10000, + 'Initial value of duration'); +}, 'duration of a new transition'); + + +// -------------------- +// direction +// -------------------- +test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + div.style.transition = 'margin-left 10s'; + flushComputedStyle(div); + div.style.marginLeft = '10px'; + + var effect = div.getAnimations()[0].effect; + assert_equals(effect.getComputedTiming().direction, 'normal', + 'Initial value of direction'); +}, 'direction of a new transition'); + + +// -------------------- +// easing +// -------------------- +test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + div.style.transition = 'margin-left 10s'; + flushComputedStyle(div); + div.style.marginLeft = '10px'; + + var effect = div.getAnimations()[0].effect; + assert_equals(effect.getComputedTiming().easing, 'linear', + 'Initial value of easing'); +}, 'easing of a new transition'); + + +// ------------------------------ +// endTime +// = max(start delay + active duration + end delay, 0) +// -------------------- +test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + div.style.transition = 'margin-left 100s -5s'; + flushComputedStyle(div); + div.style.marginLeft = '10px'; + + var effect = div.getAnimations()[0].effect; + var answer = 100000 - 5000; // ms + assert_equals(effect.getComputedTiming().endTime, answer, + 'Initial value of endTime'); +}, 'endTime of a new transition'); + + +// -------------------- +// activeDuration +// = iteration duration * iteration count(==1) +// -------------------- +test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + div.style.transition = 'margin-left 100s -5s'; + flushComputedStyle(div); + div.style.marginLeft = '10px'; + + var effect = div.getAnimations()[0].effect; + assert_equals(effect.getComputedTiming().activeDuration, 100000, + 'Initial value of activeDuration'); +}, 'activeDuration of a new transition'); + + +// -------------------- +// localTime +// -------------------- +test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + div.style.transition = 'margin-left 100s'; + flushComputedStyle(div); + div.style.marginLeft = '10px'; + + var effect = div.getAnimations()[0].effect; + assert_equals(effect.getComputedTiming().localTime, 0, + 'Initial value of localTime'); +}, 'localTime of a new transition'); + +test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + div.style.transition = 'margin-left 100s'; + flushComputedStyle(div); + div.style.marginLeft = '10px'; + + var anim = div.getAnimations()[0]; + anim.currentTime = 5000; + assert_equals(anim.effect.getComputedTiming().localTime, anim.currentTime, + 'current localTime after setting currentTime'); +}, 'localTime is always equal to currentTime'); + +async_test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + div.style.transition = 'margin-left 100s'; + flushComputedStyle(div); + div.style.marginLeft = '10px'; + + var anim = div.getAnimations()[0]; + anim.playbackRate = 2; // 2 times faster + + anim.ready.then(t.step_func(function() { + assert_equals(anim.effect.getComputedTiming().localTime, anim.currentTime, + 'localTime is equal to currentTime'); + return waitForFrame(); + })).then(t.step_func_done(function() { + assert_equals(anim.effect.getComputedTiming().localTime, anim.currentTime, + 'localTime is equal to currentTime'); + })); +}, 'localTime reflects playbackRate immediately'); + + +// -------------------- +// progress +// -------------------- +test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + div.style.transition = 'margin-left 10.5s'; + flushComputedStyle(div); + div.style.marginLeft = '10px'; + + var effect = div.getAnimations()[0].effect; + assert_equals(effect.getComputedTiming().progress, 0.0, + 'Initial value of progress'); +}, 'progress of a new transition'); + +test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + div.style.transition = 'margin-left 10.5s 2s'; + flushComputedStyle(div); + div.style.marginLeft = '10px'; + + var effect = div.getAnimations()[0].effect; + assert_equals(effect.getComputedTiming().progress, 0.0, + 'Initial value of progress'); +}, 'progress of a new transition with positive delay in before phase'); + +test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + div.style.transition = 'margin-left 10.5s'; + flushComputedStyle(div); + div.style.marginLeft = '10px'; + + var anim = div.getAnimations()[0]; + anim.finish() + assert_equals(anim.effect.getComputedTiming().progress, null, + 'finished progress'); +}, 'progress of a finished transition'); + + +// -------------------- +// currentIteration +// -------------------- +test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + div.style.transition = 'margin-left 10s'; + flushComputedStyle(div); + div.style.marginLeft = '10px'; + + var effect = div.getAnimations()[0].effect; + assert_equals(effect.getComputedTiming().currentIteration, 0, + 'Initial value of currentIteration'); +}, 'currentIteration of a new transition'); + +test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + div.style.transition = 'margin-left 10s 2s'; + flushComputedStyle(div); + div.style.marginLeft = '10px'; + + var effect = div.getAnimations()[0].effect; + assert_equals(effect.getComputedTiming().currentIteration, 0, + 'Initial value of currentIteration'); +}, 'currentIteration of a new transition with positive delay in before phase'); + +test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + div.style.transition = 'margin-left 10s'; + flushComputedStyle(div); + div.style.marginLeft = '10px'; + + var anim = div.getAnimations()[0]; + anim.finish(); + assert_equals(anim.effect.getComputedTiming().currentIteration, null, + 'finished currentIteration'); +}, 'currentIteration of a finished transition'); + +done(); +</script> +</body> diff --git a/dom/animation/test/css-transitions/file_animation-currenttime.html b/dom/animation/test/css-transitions/file_animation-currenttime.html new file mode 100644 index 000000000..2a0f105d2 --- /dev/null +++ b/dom/animation/test/css-transitions/file_animation-currenttime.html @@ -0,0 +1,307 @@ +<!doctype html> +<html> + <head> + <meta charset=utf-8> + <title>Tests for the effect of setting a CSS transition's + Animation.currentTime</title> + <style> + +.animated-div { + margin-left: 100px; + transition: margin-left 1000s linear 1000s; +} + + </style> + <script src="../testcommon.js"></script> + </head> + <body> + <script type="text/javascript"> + +'use strict'; + +// TODO: Once the computedTiming property is implemented, add checks to the +// checker helpers to ensure that computedTiming's properties are updated as +// expected. +// See https://bugzilla.mozilla.org/show_bug.cgi?id=1108055 + + +const ANIM_DELAY_MS = 1000000; // 1000s +const ANIM_DUR_MS = 1000000; // 1000s + +/** + * These helpers get the value that the currentTime needs to be set to, to put + * an animation that uses the above ANIM_DELAY_MS and ANIM_DUR_MS values into + * the middle of various phases or points through the active duration. + */ +function currentTimeForBeforePhase() { + return ANIM_DELAY_MS / 2; +} +function currentTimeForActivePhase() { + return ANIM_DELAY_MS + ANIM_DUR_MS / 2; +} +function currentTimeForAfterPhase() { + return ANIM_DELAY_MS + ANIM_DUR_MS + ANIM_DELAY_MS / 2; +} +function currentTimeForStartOfActiveInterval() { + return ANIM_DELAY_MS; +} +function currentTimeForFiftyPercentThroughActiveInterval() { + return ANIM_DELAY_MS + ANIM_DUR_MS * 0.5; +} +function currentTimeForEndOfActiveInterval() { + return ANIM_DELAY_MS + ANIM_DUR_MS; +} + + +// Expected computed 'margin-left' values at points during the active interval: +// When we assert_between_inclusive using these values we could in theory cause +// intermittent failure due to very long delays between paints, but since the +// active duration is 1000s long, a delay would need to be around 100s to cause +// that. If that's happening then there are likely other issues that should be +// fixed, so a failure to make us look into that seems like a good thing. +const INITIAL_POSITION = 100; +const TEN_PCT_POSITION = 110; +const FIFTY_PCT_POSITION = 150; +const END_POSITION = 200; + + +// The terms used for the naming of the following helper functions refer to +// terms used in the Web Animations specification for specific phases of an +// animation. The terms can be found here: +// +// http://w3c.github.io/web-animations/#animation-effect-phases-and-states + +// Called when currentTime is set to zero (the beginning of the start delay). +function checkStateOnSettingCurrentTimeToZero(animation) +{ + // We don't test animation.currentTime since our caller just set it. + + assert_equals(animation.playState, 'running', + 'Animation.playState should be "running" at the start of ' + + 'the start delay'); + + assert_equals(animation.effect.target.style.animationPlayState, 'running', + 'Animation.effect.target.style.animationPlayState should be ' + + '"running" at the start of the start delay'); + + var div = animation.effect.target; + var marginLeft = parseFloat(getComputedStyle(div).marginLeft); + assert_equals(marginLeft, UNANIMATED_POSITION, + 'the computed value of margin-left should be unaffected ' + + 'at the beginning of the start delay'); +} + +// Called when the ready Promise's callbacks should happen +function checkStateOnReadyPromiseResolved(animation) +{ + // the 0.0001 here is for rounding error + assert_less_than_equal(animation.currentTime, + animation.timeline.currentTime - animation.startTime + 0.0001, + 'Animation.currentTime should be less than the local time ' + + 'equivalent of the timeline\'s currentTime on the first paint tick ' + + 'after animation creation'); + + assert_equals(animation.playState, 'running', + 'Animation.playState should be "running" on the first paint ' + + 'tick after animation creation'); + + var div = animation.effect.target; + var marginLeft = parseFloat(getComputedStyle(div).marginLeft); + assert_equals(marginLeft, INITIAL_POSITION, + 'the computed value of margin-left should be unaffected ' + + 'by an animation with a delay on ready Promise resolve'); +} + +// Called when currentTime is set to the time the active interval starts. +function checkStateAtActiveIntervalStartTime(animation) +{ + // We don't test animation.currentTime since our caller just set it. + + assert_equals(animation.playState, 'running', + 'Animation.playState should be "running" at the start of ' + + 'the active interval'); + + var div = animation.effect.target; + var marginLeft = parseFloat(getComputedStyle(div).marginLeft); + assert_between_inclusive(marginLeft, INITIAL_POSITION, TEN_PCT_POSITION, + 'the computed value of margin-left should be close to the value at the ' + + 'beginning of the animation'); +} + +function checkStateAtFiftyPctOfActiveInterval(animation) +{ + // We don't test animation.currentTime since our caller just set it. + + var div = animation.effect.target; + var marginLeft = parseFloat(getComputedStyle(div).marginLeft); + assert_equals(marginLeft, FIFTY_PCT_POSITION, + 'the computed value of margin-left should be half way through the ' + + 'animation at the midpoint of the active interval'); +} + +// Called when currentTime is set to the time the active interval ends. +function checkStateAtActiveIntervalEndTime(animation) +{ + // We don't test animation.currentTime since our caller just set it. + + assert_equals(animation.playState, 'finished', + 'Animation.playState should be "finished" at the end of ' + + 'the active interval'); + + var div = animation.effect.target; + var marginLeft = parseFloat(getComputedStyle(div).marginLeft); + assert_equals(marginLeft, END_POSITION, + 'the computed value of margin-left should be the final transitioned-to ' + + 'value at the end of the active duration'); +} + +test(function(t) +{ + var div = addDiv(t, {'class': 'animated-div'}); + flushComputedStyle(div); + div.style.marginLeft = '200px'; // initiate transition + + var animation = div.getAnimations()[0]; + assert_equals(animation.currentTime, 0, 'currentTime should be zero'); +}, 'currentTime of a newly created transition is zero'); + + +test(function(t) +{ + var div = addDiv(t, {'class': 'animated-div'}); + flushComputedStyle(div); + div.style.marginLeft = '200px'; // initiate transition + + var animation = div.getAnimations()[0]; + + // So that animation is running instead of paused when we set currentTime: + animation.startTime = animation.timeline.currentTime; + + animation.currentTime = 10; + assert_equals(animation.currentTime, 10, + 'Check setting of currentTime actually works'); +}, 'Sanity test to check round-tripping assigning to new animation\'s ' + + 'currentTime'); + + +async_test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + var eventWatcher = new EventWatcher(t, div, 'transitionend'); + + flushComputedStyle(div); + div.style.marginLeft = '200px'; // initiate transition + + var animation = div.getAnimations()[0]; + + animation.ready.then(t.step_func(function() { + checkStateOnReadyPromiseResolved(animation); + + animation.currentTime = currentTimeForStartOfActiveInterval(); + checkStateAtActiveIntervalStartTime(animation); + + animation.currentTime = currentTimeForFiftyPercentThroughActiveInterval(); + checkStateAtFiftyPctOfActiveInterval(animation); + + animation.currentTime = currentTimeForEndOfActiveInterval(); + return eventWatcher.wait_for('transitionend'); + })).then(t.step_func(function() { + checkStateAtActiveIntervalEndTime(animation); + })).catch(t.step_func(function(reason) { + assert_unreached(reason); + })).then(function() { + t.done(); + }); +}, 'Skipping forward through transition'); + + +test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + var eventWatcher = new EventWatcher(t, div, 'transitionend'); + + flushComputedStyle(div); + div.style.marginLeft = '200px'; // initiate transition + + var animation = div.getAnimations()[0]; + + // Unlike in the case of CSS animations, we cannot skip to the end and skip + // backwards since when we reach the end the transition effect is removed and + // changes to the Animation object no longer affect the element. For + // this reason we only skip forwards as far as the 50% through point. + + animation.ready.then(t.step_func(function() { + animation.currentTime = currentTimeForFiftyPercentThroughActiveInterval(); + checkStateAtFiftyPctOfActiveInterval(animation); + + animation.currentTime = currentTimeForStartOfActiveInterval(); + + // Despite going backwards from being in the active interval to being + // before it, we now expect a 'transitionend' event because the transition + // should go from being active to inactive. + // + // Calling checkStateAtActiveIntervalStartTime will check computed style, + // causing computed style to be updated and the 'transitionend' event to + // be dispatched synchronously. We need to call wait_for first + // otherwise eventWatcher will assert that the event was unexpected. + eventWatcher.wait_for('transitionend').then(function() { + t.done(); + }); + checkStateAtActiveIntervalStartTime(animation); + })); +}, 'Skipping backwards through transition'); + + +async_test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + flushComputedStyle(div); + div.style.marginLeft = '200px'; // initiate transition + + var animation = div.getAnimations()[0]; + + animation.ready.then(t.step_func(function() { + var exception; + try { + animation.currentTime = null; + } catch (e) { + exception = e; + } + assert_equals(exception.name, 'TypeError', + 'Expect TypeError exception on trying to set ' + + 'Animation.currentTime to null'); + })).catch(t.step_func(function(reason) { + assert_unreached(reason); + })).then(function() { + t.done(); + }); +}, 'Setting currentTime to null'); + + +async_test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + flushComputedStyle(div); + div.style.marginLeft = '200px'; // initiate transition + + var animation = div.getAnimations()[0]; + var pauseTime; + + animation.ready.then(t.step_func(function() { + assert_not_equals(animation.currentTime, null, + 'Animation.currentTime not null on ready Promise resolve'); + animation.pause(); + return animation.ready; + })).then(t.step_func(function() { + pauseTime = animation.currentTime; + return waitForFrame(); + })).then(t.step_func(function() { + assert_equals(animation.currentTime, pauseTime, + 'Animation.currentTime is unchanged after pausing'); + })).catch(t.step_func(function(reason) { + assert_unreached(reason); + })).then(function() { + t.done(); + }); +}, 'Animation.currentTime after pausing'); + +done(); + </script> + </body> +</html> diff --git a/dom/animation/test/css-transitions/file_animation-finished.html b/dom/animation/test/css-transitions/file_animation-finished.html new file mode 100644 index 000000000..2f6bcf47d --- /dev/null +++ b/dom/animation/test/css-transitions/file_animation-finished.html @@ -0,0 +1,61 @@ +<!doctype html> +<meta charset=utf-8> +<script src="../testcommon.js"></script> +<style> + +.animated-div { + margin-left: 100px; + transition: margin-left 1000s linear 1000s; +} + +</style> +<body> +<script> + +'use strict'; + +const ANIM_DELAY_MS = 1000000; // 1000s +const ANIM_DUR_MS = 1000000; // 1000s + +async_test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + flushComputedStyle(div); + div.style.marginLeft = '200px'; // initiate transition + + var animation = div.getAnimations()[0]; + + animation.finish(); + + animation.finished.then(t.step_func(function() { + animation.play(); + assert_equals(animation.currentTime, 0, + 'Replaying a finished transition should reset its ' + + 'currentTime'); + t.done(); + })); +}, 'Test restarting a finished transition'); + +async_test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + flushComputedStyle(div); + div.style.marginLeft = '200px'; // initiate transition + + var animation = div.getAnimations()[0]; + + animation.ready.then(function() { + animation.playbackRate = -1; + return animation.finished; + }).then(t.step_func(function() { + animation.play(); + // FIXME: once animation.effect.computedTiming.endTime is available (bug + // 1108055) we should use that here. + assert_equals(animation.currentTime, ANIM_DELAY_MS + ANIM_DUR_MS, + 'Replaying a finished reversed transition should reset ' + + 'its currentTime to the end of the effect'); + t.done(); + })); +}, 'Test restarting a reversed finished transition'); + +done(); +</script> +</body> diff --git a/dom/animation/test/css-transitions/file_animation-pausing.html b/dom/animation/test/css-transitions/file_animation-pausing.html new file mode 100644 index 000000000..b2f2d4618 --- /dev/null +++ b/dom/animation/test/css-transitions/file_animation-pausing.html @@ -0,0 +1,50 @@ +<!doctype html> +<meta charset=utf-8> +<script src="../testcommon.js"></script> +<body> +<script> +'use strict'; + +function getMarginLeft(cs) { + return parseFloat(cs.marginLeft); +} + +async_test(function(t) { + var div = addDiv(t); + var cs = window.getComputedStyle(div); + + div.style.marginLeft = '0px'; + cs.marginLeft; // Flush style to set up transition start point + div.style.transition = 'margin-left 100s'; + div.style.marginLeft = '10000px'; + cs.marginLeft; + + var animation = div.getAnimations()[0]; + assert_equals(getMarginLeft(cs), 0, + 'Initial value of margin-left is zero'); + var previousAnimVal = getMarginLeft(cs); + + animation.ready.then(waitForFrame).then(t.step_func(function() { + assert_true(getMarginLeft(cs) > previousAnimVal, + 'margin-left is initially increasing'); + animation.pause(); + return animation.ready; + })).then(t.step_func(function() { + previousAnimVal = getMarginLeft(cs); + return waitForFrame(); + })).then(t.step_func(function() { + assert_equals(getMarginLeft(cs), previousAnimVal, + 'margin-left does not increase after calling pause()'); + previousAnimVal = getMarginLeft(cs); + animation.play(); + return animation.ready.then(waitForFrame); + })).then(t.step_func(function() { + assert_true(getMarginLeft(cs) > previousAnimVal, + 'margin-left increases after calling play()'); + t.done(); + })); +}, 'pause() and play() a transition'); + +done(); +</script> +</body> diff --git a/dom/animation/test/css-transitions/file_animation-ready.html b/dom/animation/test/css-transitions/file_animation-ready.html new file mode 100644 index 000000000..f141da796 --- /dev/null +++ b/dom/animation/test/css-transitions/file_animation-ready.html @@ -0,0 +1,96 @@ +<!doctype html> +<meta charset=utf-8> +<script src="../testcommon.js"></script> +<body> +<script> +'use strict'; + +async_test(function(t) { + var div = addDiv(t); + div.style.transform = 'translate(0px)'; + window.getComputedStyle(div).transform; + div.style.transition = 'transform 100s'; + div.style.transform = 'translate(10px)'; + window.getComputedStyle(div).transform; + + var animation = div.getAnimations()[0]; + var originalReadyPromise = animation.ready; + + animation.ready.then(t.step_func(function() { + assert_equals(animation.ready, originalReadyPromise, + 'Ready promise is the same object when playing completes'); + animation.pause(); + assert_not_equals(animation.ready, originalReadyPromise, + 'Ready promise object identity differs when pausing'); + t.done(); + })); +}, 'A new ready promise is created each time play() is called' + + ' the animation property'); + +async_test(function(t) { + var div = addDiv(t); + + // Set up pending transition + div.style.transform = 'translate(0px)'; + window.getComputedStyle(div).transform; + div.style.transition = 'transform 100s'; + div.style.transform = 'translate(10px)'; + window.getComputedStyle(div).transform; + + var animation = div.getAnimations()[0]; + assert_equals(animation.playState, 'pending', 'Animation is initially pending'); + + // Set up listeners on ready promise + animation.ready.then(t.step_func(function() { + assert_unreached('ready promise was fulfilled'); + })).catch(t.step_func(function(err) { + assert_equals(err.name, 'AbortError', + 'ready promise is rejected with AbortError'); + assert_equals(animation.playState, 'idle', + 'Animation is idle after transition was cancelled'); + })).then(t.step_func(function() { + t.done(); + })); + + // Now remove transform from transition-property and flush styles + div.style.transitionProperty = 'none'; + window.getComputedStyle(div).transitionProperty; + +}, 'ready promise is rejected when a transition is cancelled by updating' + + ' transition-property'); + +async_test(function(t) { + var div = addDiv(t); + + // Set up pending transition + div.style.marginLeft = '0px'; + window.getComputedStyle(div).marginLeft; + div.style.transition = 'margin-left 100s'; + div.style.marginLeft = '100px'; + window.getComputedStyle(div).marginLeft; + + var animation = div.getAnimations()[0]; + assert_equals(animation.playState, 'pending', 'Animation is initially pending'); + + // Set up listeners on ready promise + animation.ready.then(t.step_func(function() { + assert_unreached('ready promise was fulfilled'); + })).catch(t.step_func(function(err) { + assert_equals(err.name, 'AbortError', + 'ready promise is rejected with AbortError'); + assert_equals(animation.playState, 'idle', + 'Animation is idle after transition was cancelled'); + })).then(t.step_func(function() { + t.done(); + })); + + // Now update the transition to animate to something not-interpolable + div.style.marginLeft = 'auto'; + window.getComputedStyle(div).marginLeft; + +}, 'ready promise is rejected when a transition is cancelled by changing' + + ' the transition property to something not interpolable'); + +done(); +</script> +</body> diff --git a/dom/animation/test/css-transitions/file_animation-starttime.html b/dom/animation/test/css-transitions/file_animation-starttime.html new file mode 100644 index 000000000..a156ba0a0 --- /dev/null +++ b/dom/animation/test/css-transitions/file_animation-starttime.html @@ -0,0 +1,284 @@ +<!doctype html> +<html> + <head> + <meta charset=utf-8> + <title>Tests for the effect of setting a CSS transition's + Animation.startTime</title> + <style> + +.animated-div { + margin-left: 100px; + transition: margin-left 1000s linear 1000s; +} + + </style> + <script src="../testcommon.js"></script> + </head> + <body> + <script type="text/javascript"> + +'use strict'; + +// TODO: Once the computedTiming property is implemented, add checks to the +// checker helpers to ensure that computedTiming's properties are updated as +// expected. +// See https://bugzilla.mozilla.org/show_bug.cgi?id=1108055 + + +const ANIM_DELAY_MS = 1000000; // 1000s +const ANIM_DUR_MS = 1000000; // 1000s + +/** + * These helpers get the value that the startTime needs to be set to, to put an + * animation that uses the above ANIM_DELAY_MS and ANIM_DUR_MS values into the + * middle of various phases or points through the active duration. + */ +function startTimeForBeforePhase(timeline) { + return timeline.currentTime - ANIM_DELAY_MS / 2; +} +function startTimeForActivePhase(timeline) { + return timeline.currentTime - ANIM_DELAY_MS - ANIM_DUR_MS / 2; +} +function startTimeForAfterPhase(timeline) { + return timeline.currentTime - ANIM_DELAY_MS - ANIM_DUR_MS - ANIM_DELAY_MS / 2; +} +function startTimeForStartOfActiveInterval(timeline) { + return timeline.currentTime - ANIM_DELAY_MS; +} +function startTimeForFiftyPercentThroughActiveInterval(timeline) { + return timeline.currentTime - ANIM_DELAY_MS - ANIM_DUR_MS * 0.5; +} +function startTimeForEndOfActiveInterval(timeline) { + return timeline.currentTime - ANIM_DELAY_MS - ANIM_DUR_MS; +} + + +// Expected computed 'margin-left' values at points during the active interval: +// When we assert_between_inclusive using these values we could in theory cause +// intermittent failure due to very long delays between paints, but since the +// active duration is 1000s long, a delay would need to be around 100s to cause +// that. If that's happening then there are likely other issues that should be +// fixed, so a failure to make us look into that seems like a good thing. +const INITIAL_POSITION = 100; +const TEN_PCT_POSITION = 110; +const FIFTY_PCT_POSITION = 150; +const END_POSITION = 200; + +// The terms used for the naming of the following helper functions refer to +// terms used in the Web Animations specification for specific phases of an +// animation. The terms can be found here: +// +// https://w3c.github.io/web-animations/#animation-effect-phases-and-states +// +// Note the distinction between the "animation start time" which occurs before +// the start delay and the start of the active interval which occurs after it. + +// Called when the ready Promise's callbacks should happen +function checkStateOnReadyPromiseResolved(animation) +{ + assert_less_than_equal(animation.startTime, animation.timeline.currentTime, + 'Animation.startTime should be less than the timeline\'s ' + + 'currentTime on the first paint tick after animation creation'); + + assert_equals(animation.playState, 'running', + 'Animation.playState should be "running" on the first paint ' + + 'tick after animation creation'); + + var div = animation.effect.target; + var marginLeft = parseFloat(getComputedStyle(div).marginLeft); + assert_equals(marginLeft, INITIAL_POSITION, + 'the computed value of margin-left should be unaffected ' + + 'by an animation with a delay on ready Promise resolve'); +} + +// Called when startTime is set to the time the active interval starts. +function checkStateAtActiveIntervalStartTime(animation) +{ + // We don't test animation.startTime since our caller just set it. + + assert_equals(animation.playState, 'running', + 'Animation.playState should be "running" at the start of ' + + 'the active interval'); + + var div = animation.effect.target; + var marginLeft = parseFloat(getComputedStyle(div).marginLeft); + assert_between_inclusive(marginLeft, INITIAL_POSITION, TEN_PCT_POSITION, + 'the computed value of margin-left should be close to the value at the ' + + 'beginning of the animation'); +} + +function checkStateAtFiftyPctOfActiveInterval(animation) +{ + // We don't test animation.startTime since our caller just set it. + + var div = animation.effect.target; + var marginLeft = parseFloat(getComputedStyle(div).marginLeft); + assert_equals(marginLeft, FIFTY_PCT_POSITION, + 'the computed value of margin-left should be half way through the ' + + 'animation at the midpoint of the active interval'); +} + +// Called when startTime is set to the time the active interval ends. +function checkStateAtActiveIntervalEndTime(animation) +{ + // We don't test animation.startTime since our caller just set it. + + assert_equals(animation.playState, 'finished', + 'Animation.playState should be "finished" at the end of ' + + 'the active interval'); + + var div = animation.effect.target; + var marginLeft = parseFloat(getComputedStyle(div).marginLeft); + assert_equals(marginLeft, END_POSITION, + 'the computed value of margin-left should be the final transitioned-to ' + + 'value at the end of the active duration'); +} + +test(function(t) +{ + var div = addDiv(t, {'class': 'animated-div'}); + flushComputedStyle(div); + div.style.marginLeft = '200px'; // initiate transition + + var animation = div.getAnimations()[0]; + assert_equals(animation.startTime, null, 'startTime is unresolved'); +}, 'startTime of a newly created transition is unresolved'); + + +test(function(t) +{ + var div = addDiv(t, {'class': 'animated-div'}); + flushComputedStyle(div); + div.style.marginLeft = '200px'; // initiate transition + + var animation = div.getAnimations()[0]; + var currentTime = animation.timeline.currentTime; + animation.startTime = currentTime; + assert_approx_equals(animation.startTime, currentTime, 0.0001, // rounding error + 'Check setting of startTime actually works'); +}, 'Sanity test to check round-tripping assigning to new animation\'s ' + + 'startTime'); + + +async_test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + var eventWatcher = new EventWatcher(t, div, 'transitionend'); + + flushComputedStyle(div); + div.style.marginLeft = '200px'; // initiate transition + + var animation = div.getAnimations()[0]; + + animation.ready.then(t.step_func(function() { + checkStateOnReadyPromiseResolved(animation); + + animation.startTime = startTimeForStartOfActiveInterval(animation.timeline); + checkStateAtActiveIntervalStartTime(animation); + + animation.startTime = + startTimeForFiftyPercentThroughActiveInterval(animation.timeline); + checkStateAtFiftyPctOfActiveInterval(animation); + + animation.startTime = startTimeForEndOfActiveInterval(animation.timeline); + return eventWatcher.wait_for('transitionend'); + })).then(t.step_func(function() { + checkStateAtActiveIntervalEndTime(animation); + })).catch(t.step_func(function(reason) { + assert_unreached(reason); + })).then(function() { + t.done(); + }); +}, 'Skipping forward through animation'); + + +test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + var eventWatcher = new EventWatcher(t, div, 'transitionend'); + + flushComputedStyle(div); + div.style.marginLeft = '200px'; // initiate transition + + var animation = div.getAnimations()[0]; + + // Unlike in the case of CSS animations, we cannot skip to the end and skip + // backwards since when we reach the end the transition effect is removed and + // changes to the Animation object no longer affect the element. For + // this reason we only skip forwards as far as the 90% through point. + + animation.startTime = + startTimeForFiftyPercentThroughActiveInterval(animation.timeline); + checkStateAtFiftyPctOfActiveInterval(animation); + + animation.startTime = startTimeForStartOfActiveInterval(animation.timeline); + + // Despite going backwards from being in the active interval to being before + // it, we now expect an 'animationend' event because the animation should go + // from being active to inactive. + // + // Calling checkStateAtActiveIntervalStartTime will check computed style, + // causing computed style to be updated and the 'transitionend' event to + // be dispatched synchronously. We need to call waitForEvent first + // otherwise eventWatcher will assert that the event was unexpected. + eventWatcher.wait_for('transitionend').then(function() { + t.done(); + }); + checkStateAtActiveIntervalStartTime(animation); +}, 'Skipping backwards through transition'); + + +async_test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + + flushComputedStyle(div); + div.style.marginLeft = '200px'; // initiate transition + + var animation = div.getAnimations()[0]; + + var storedCurrentTime; + + animation.ready.then(t.step_func(function() { + storedCurrentTime = animation.currentTime; + animation.startTime = null; + return animation.ready; + })).catch(t.step_func(function(reason) { + assert_unreached(reason); + })).then(t.step_func(function() { + assert_equals(animation.currentTime, storedCurrentTime, + 'Test that hold time is correct'); + t.done(); + })); +}, 'Setting startTime to null'); + + +async_test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + + flushComputedStyle(div); + div.style.marginLeft = '200px'; // initiate transition + + var animation = div.getAnimations()[0]; + + animation.ready.then(t.step_func(function() { + var savedStartTime = animation.startTime; + + assert_not_equals(animation.startTime, null, + 'Animation.startTime not null on ready Promise resolve'); + + animation.pause(); + return animation.ready; + })).then(t.step_func(function() { + assert_equals(animation.startTime, null, + 'Animation.startTime is null after paused'); + assert_equals(animation.playState, 'paused', + 'Animation.playState is "paused" after pause() call'); + })).catch(t.step_func(function(reason) { + assert_unreached(reason); + })).then(function() { + t.done(); + }); +}, 'Animation.startTime after paused'); + +done(); + </script> + </body> +</html> diff --git a/dom/animation/test/css-transitions/file_csstransition-events.html b/dom/animation/test/css-transitions/file_csstransition-events.html new file mode 100644 index 000000000..5011bc130 --- /dev/null +++ b/dom/animation/test/css-transitions/file_csstransition-events.html @@ -0,0 +1,223 @@ +<!doctype html> +<meta charset=utf-8> +<title>Tests for CSS-Transition events</title> +<link rel="help" href="https://drafts.csswg.org/css-transitions-2/#transition-events"> +<script src="../testcommon.js"></script> +<body> +<script> +'use strict'; + +/** + * Helper class to record the elapsedTime member of each event. + * The EventWatcher class in testharness.js allows us to wait on + * multiple events in a certain order but only records the event + * parameters of the most recent event. + */ +function TransitionEventHandler(target) { + this.target = target; + this.target.ontransitionrun = function(evt) { + this.transitionrun = evt.elapsedTime; + }.bind(this); + this.target.ontransitionstart = function(evt) { + this.transitionstart = evt.elapsedTime; + }.bind(this); + this.target.ontransitionend = function(evt) { + this.transitionend = evt.elapsedTime; + }.bind(this); +} + +TransitionEventHandler.prototype.clear = function() { + this.transitionrun = undefined; + this.transitionstart = undefined; + this.transitionend = undefined; +}; + +function setupTransition(t, transitionStyle) { + var div, watcher, handler, transition; + transitionStyle = transitionStyle || 'transition: margin-left 100s 100s'; + div = addDiv(t, { style: transitionStyle }); + watcher = new EventWatcher(t, div, [ 'transitionrun', + 'transitionstart', + 'transitionend' ]); + handler = new TransitionEventHandler(div); + flushComputedStyle(div); + + div.style.marginLeft = '100px'; + flushComputedStyle(div); + + transition = div.getAnimations()[0]; + + return [transition, watcher, handler]; +} + +// On the next frame (i.e. when events are queued), whether or not the +// transition is still pending depends on the implementation. +promise_test(function(t) { + var [transition, watcher, handler] = setupTransition(t); + return watcher.wait_for('transitionrun').then(function(evt) { + assert_equals(evt.elapsedTime, 0.0); + }); +}, 'Idle -> Pending or Before'); + +promise_test(function(t) { + var [transition, watcher, handler] = setupTransition(t); + // Force the transition to leave the idle phase + transition.startTime = document.timeline.currentTime; + return watcher.wait_for('transitionrun').then(function(evt) { + assert_equals(evt.elapsedTime, 0.0); + }); +}, 'Idle -> Before'); + +promise_test(function(t) { + var [transition, watcher, handler] = setupTransition(t); + // Seek to Active phase. + transition.currentTime = 100 * MS_PER_SEC; + transition.pause(); + return watcher.wait_for([ 'transitionrun', + 'transitionstart' ]).then(function(evt) { + assert_equals(handler.transitionrun, 0.0); + assert_equals(handler.transitionstart, 0.0); + }); +}, 'Idle or Pending -> Active'); + +promise_test(function(t) { + var [transition, watcher, handler] = setupTransition(t); + // Seek to After phase. + transition.finish(); + return watcher.wait_for([ 'transitionrun', + 'transitionstart', + 'transitionend' ]).then(function(evt) { + assert_equals(handler.transitionrun, 0.0); + assert_equals(handler.transitionstart, 0.0); + assert_equals(handler.transitionend, 100.0); + }); +}, 'Idle or Pending -> After'); + +promise_test(function(t) { + var [transition, watcher, handler] = setupTransition(t); + + return Promise.all([ watcher.wait_for('transitionrun'), + transition.ready ]).then(function() { + transition.currentTime = 100 * MS_PER_SEC; + return watcher.wait_for('transitionstart'); + }).then(function() { + assert_equals(handler.transitionstart, 0.0); + }); +}, 'Before -> Active'); + +promise_test(function(t) { + var [transition, watcher, handler] = setupTransition(t); + return Promise.all([ watcher.wait_for('transitionrun'), + transition.ready ]).then(function() { + // Seek to After phase. + transition.currentTime = 200 * MS_PER_SEC; + return watcher.wait_for([ 'transitionstart', 'transitionend' ]); + }).then(function(evt) { + assert_equals(handler.transitionstart, 0.0); + assert_equals(handler.transitionend, 100.0); + }); +}, 'Before -> After'); + +promise_test(function(t) { + var [transition, watcher, handler] = setupTransition(t); + // Seek to Active phase. + transition.currentTime = 100 * MS_PER_SEC; + return watcher.wait_for([ 'transitionrun', + 'transitionstart' ]).then(function(evt) { + // Seek to Before phase. + transition.currentTime = 0; + return watcher.wait_for('transitionend'); + }).then(function(evt) { + assert_equals(evt.elapsedTime, 0.0); + }); +}, 'Active -> Before'); + +promise_test(function(t) { + var [transition, watcher, handler] = setupTransition(t); + // Seek to Active phase. + transition.currentTime = 100 * MS_PER_SEC; + return watcher.wait_for([ 'transitionrun', + 'transitionstart' ]).then(function(evt) { + // Seek to After phase. + transition.currentTime = 200 * MS_PER_SEC; + return watcher.wait_for('transitionend'); + }).then(function(evt) { + assert_equals(evt.elapsedTime, 100.0); + }); +}, 'Active -> After'); + +promise_test(function(t) { + var [transition, watcher, handler] = setupTransition(t); + // Seek to After phase. + transition.finish(); + return watcher.wait_for([ 'transitionrun', + 'transitionstart', + 'transitionend' ]).then(function(evt) { + // Seek to Before phase. + transition.currentTime = 0; + return watcher.wait_for([ 'transitionstart', 'transitionend' ]); + }).then(function(evt) { + assert_equals(handler.transitionstart, 100.0); + assert_equals(handler.transitionend, 0.0); + }); +}, 'After -> Before'); + +promise_test(function(t) { + var [transition, watcher, handler] = setupTransition(t); + // Seek to After phase. + transition.finish(); + return watcher.wait_for([ 'transitionrun', + 'transitionstart', + 'transitionend' ]).then(function(evt) { + // Seek to Active phase. + transition.currentTime = 100 * MS_PER_SEC; + return watcher.wait_for('transitionstart'); + }).then(function(evt) { + assert_equals(evt.elapsedTime, 100.0); + }); +}, 'After -> Active'); + +promise_test(function(t) { + var [transition, watcher, handler] = + setupTransition(t, 'transition: margin-left 100s -50s'); + + return watcher.wait_for([ 'transitionrun', + 'transitionstart' ]).then(function() { + assert_equals(handler.transitionrun, 50.0); + assert_equals(handler.transitionstart, 50.0); + transition.finish(); + return watcher.wait_for('transitionend'); + }).then(function(evt) { + assert_equals(evt.elapsedTime, 100.0); + }); +}, 'Calculating the interval start and end time with negative start delay.'); + +promise_test(function(t) { + var [transition, watcher, handler] = setupTransition(t); + + return watcher.wait_for('transitionrun').then(function(evt) { + // We can't set the end delay via generated effect timing. + // Because CSS-Transition use the AnimationEffectTimingReadOnly. + transition.effect = new KeyframeEffect(handler.target, + { marginleft: [ '0px', '100px' ]}, + { duration: 100 * MS_PER_SEC, + endDelay: -50 * MS_PER_SEC }); + // Seek to Before and play. + transition.cancel(); + transition.play(); + return watcher.wait_for('transitionstart'); + }).then(function() { + assert_equals(handler.transitionstart, 0.0); + + // Seek to After phase. + transition.finish(); + return watcher.wait_for('transitionend'); + }).then(function(evt) { + assert_equals(evt.elapsedTime, 50.0); + }); +}, 'Calculating the interval start and end time with negative end delay.'); + +done(); +</script> +</body> +</html> diff --git a/dom/animation/test/css-transitions/file_csstransition-transitionproperty.html b/dom/animation/test/css-transitions/file_csstransition-transitionproperty.html new file mode 100644 index 000000000..176cc5a4d --- /dev/null +++ b/dom/animation/test/css-transitions/file_csstransition-transitionproperty.html @@ -0,0 +1,24 @@ +<!doctype html> +<meta charset=utf-8> +<script src="../testcommon.js"></script> +<body> +<script> +'use strict'; + +test(function(t) { + var div = addDiv(t); + + // Add a transition + div.style.left = '0px'; + window.getComputedStyle(div).transitionProperty; + div.style.transition = 'all 100s'; + div.style.left = '100px'; + + assert_equals(div.getAnimations()[0].transitionProperty, 'left', + 'The transitionProperty for the corresponds to the specific ' + + 'property being transitioned'); +}, 'CSSTransition.transitionProperty'); + +done(); +</script> +</body> diff --git a/dom/animation/test/css-transitions/file_document-get-animations.html b/dom/animation/test/css-transitions/file_document-get-animations.html new file mode 100644 index 000000000..a5d55b76c --- /dev/null +++ b/dom/animation/test/css-transitions/file_document-get-animations.html @@ -0,0 +1,93 @@ +<!doctype html> +<meta charset=utf-8> +<script src="../testcommon.js"></script> +<body> +<script> +'use strict'; + +test(function(t) { + assert_equals(document.getAnimations().length, 0, + 'getAnimations returns an empty sequence for a document' + + ' with no animations'); +}, 'getAnimations for non-animated content'); + +test(function(t) { + var div = addDiv(t); + + // Add a couple of transitions + div.style.left = '0px'; + div.style.top = '0px'; + getComputedStyle(div).transitionProperty; + + div.style.transition = 'all 100s'; + div.style.left = '100px'; + div.style.top = '100px'; + assert_equals(document.getAnimations().length, 2, + 'getAnimations returns two running CSS Transitions'); + + // Remove both + div.style.transitionProperty = 'none'; + assert_equals(document.getAnimations().length, 0, + 'getAnimations returns no running CSS Transitions'); +}, 'getAnimations for CSS Transitions'); + +test(function(t) { + addStyle(t, { '.init::after': 'content: ""; width: 0px; ' + + 'transition: all 100s;', + '.init::before': 'content: ""; width: 0px; ' + + 'transition: all 10s;', + '.change::after': 'width: 100px;', + '.change::before': 'width: 100px;' }); + // create two divs with these arrangement: + // parent + // ::before, + // ::after + // | + // child + var parent = addDiv(t); + var child = addDiv(t); + parent.appendChild(child); + + parent.style.left = '0px'; + parent.style.transition = 'left 10s'; + parent.classList.add('init'); + child.style.left = '0px'; + child.style.transition = 'left 10s'; + flushComputedStyle(parent); + + parent.style.left = '100px'; + parent.classList.add('change'); + child.style.left = '100px'; + + var anims = document.getAnimations(); + assert_equals(anims.length, 4, + 'CSS transition on both pseudo-elements and elements ' + + 'are returned'); + assert_equals(anims[0].effect.target, parent, + 'The animation targeting the parent element comes first'); + assert_equals(anims[1].effect.target.type, '::before', + 'The animation targeting the ::before element comes second'); + assert_equals(anims[2].effect.target.type, '::after', + 'The animation targeting the ::after element comes third'); + assert_equals(anims[3].effect.target, child, + 'The animation targeting the child element comes last'); +}, 'CSS Transitions targetting (pseudo-)elements should have correct order ' + + 'after sorting'); + +async_test(function(t) { + var div = addDiv(t, { style: 'left: 0px; transition: all 50ms' }); + flushComputedStyle(div); + + div.style.left = '100px'; + var animations = div.getAnimations(); + assert_equals(animations.length, 1, 'Got transition'); + animations[0].finished.then(t.step_func(function() { + assert_equals(document.getAnimations().length, 0, + 'No animations returned'); + t.done(); + })); +}, 'Transitions are not returned after they have finished'); + +done(); +</script> +</body> diff --git a/dom/animation/test/css-transitions/file_effect-target.html b/dom/animation/test/css-transitions/file_effect-target.html new file mode 100644 index 000000000..0f67b0b9a --- /dev/null +++ b/dom/animation/test/css-transitions/file_effect-target.html @@ -0,0 +1,66 @@ +<!doctype html> +<meta charset=utf-8> +<script src="../testcommon.js"></script> +<body> +<script> +'use strict'; + +test(function(t) { + var div = addDiv(t); + + div.style.left = '0px'; + window.getComputedStyle(div).transitionProperty; + div.style.transition = 'left 100s'; + div.style.left = '100px'; + + var animation = div.getAnimations()[0]; + assert_equals(animation.effect.target, div, + 'Animation.target is the animatable div'); +}, 'Returned CSS transitions have the correct Animation.target'); + +test(function(t) { + addStyle(t, { '.init::after': 'content: ""; width: 0px; height: 0px; ' + + 'transition: all 10s;', + '.change::after': 'width: 100px; height: 100px;' }); + var div = addDiv(t, { class: 'init' }); + flushComputedStyle(div); + div.classList.add('change'); + + var anims = document.getAnimations(); + assert_equals(anims.length, 2, + 'Got transitions running on ::after pseudo element'); + assert_equals(anims[0].effect.target, anims[1].effect.target, + 'Both transitions return the same target object'); +}, 'effect.target should return the same CSSPseudoElement object each time'); + +test(function(t) { + addStyle(t, { '.init::after': 'content: ""; width: 0px; transition: all 10s;', + '.change::after': 'width: 100px;' }); + var div = addDiv(t, { class: 'init' }); + flushComputedStyle(div); + div.classList.add('change'); + var pseudoTarget = document.getAnimations()[0].effect.target; + var effect = new KeyframeEffectReadOnly(pseudoTarget, + { background: ["blue", "red"] }, + 3000); + var newAnim = new Animation(effect, document.timeline); + newAnim.play(); + + var anims = document.getAnimations(); + assert_equals(anims.length, 2, + 'Got animations running on ::after pseudo element'); + assert_not_equals(anims[0], newAnim, + 'The scriped-generated animation appears last'); + assert_equals(newAnim.effect.target, pseudoTarget, + 'The effect.target of the scripted-generated animation is ' + + 'the same as the one from the argument of ' + + 'KeyframeEffectReadOnly constructor'); + assert_equals(anims[0].effect.target, newAnim.effect.target, + 'Both the transition and the scripted-generated animation ' + + 'return the same target object'); +}, 'effect.target from the script-generated animation should return the same ' + + 'CSSPseudoElement object as that from the CSS generated transition'); + +done(); +</script> +</body> diff --git a/dom/animation/test/css-transitions/file_element-get-animations.html b/dom/animation/test/css-transitions/file_element-get-animations.html new file mode 100644 index 000000000..0ce145da0 --- /dev/null +++ b/dom/animation/test/css-transitions/file_element-get-animations.html @@ -0,0 +1,147 @@ +<!doctype html> +<meta charset=utf-8> +<script src="../testcommon.js"></script> +<body> +<script> +'use strict'; + +async_test(function(t) { + var div = addDiv(t); + + // FIXME: This test does too many things. It should be split up. + + // Add a couple of transitions + div.style.left = '0px'; + div.style.top = '0px'; + window.getComputedStyle(div).transitionProperty; + + div.style.transition = 'all 100s'; + div.style.left = '100px'; + div.style.top = '100px'; + + var animations = div.getAnimations(); + assert_equals(animations.length, 2, + 'getAnimations() returns one Animation per transitioning property'); + waitForAllAnimations(animations).then(t.step_func(function() { + var startTime = animations[0].startTime; + assert_true(startTime > 0 && startTime <= document.timeline.currentTime, + 'CSS transitions have sensible start times'); + assert_equals(animations[0].startTime, animations[1].startTime, + 'CSS transitions started together have the same start time'); + // Wait a moment then add a third transition + return waitForFrame(); + })).then(t.step_func(function() { + div.style.backgroundColor = 'green'; + animations = div.getAnimations(); + assert_equals(animations.length, 3, + 'getAnimations returns Animations for all running CSS Transitions'); + return waitForAllAnimations(animations); + })).then(t.step_func(function() { + assert_less_than(animations[1].startTime, animations[2].startTime, + 'Animation for additional CSS transition starts after the original' + + ' transitions and appears later in the list'); + t.done(); + })); +}, 'getAnimations for CSS Transitions'); + +test(function(t) { + var div = addDiv(t, { style: 'left: 0px; transition: all 100s' }); + + flushComputedStyle(div); + div.style.left = '100px'; + + assert_class_string(div.getAnimations()[0], 'CSSTransition', + 'Interface of returned animation is CSSTransition'); +}, 'getAnimations returns CSSTransition objects for CSS Transitions'); + +async_test(function(t) { + var div = addDiv(t); + + // Set up event listener + div.addEventListener('transitionend', t.step_func(function() { + assert_equals(div.getAnimations().length, 0, + 'getAnimations does not return finished CSS Transitions'); + t.done(); + })); + + // Add a very short transition + div.style.left = '0px'; + window.getComputedStyle(div).left; + + div.style.transition = 'all 0.01s'; + div.style.left = '100px'; + window.getComputedStyle(div).left; +}, 'getAnimations for CSS Transitions that have finished'); + +test(function(t) { + var div = addDiv(t); + + // Try to transition non-animatable property animation-duration + div.style.animationDuration = '10s'; + window.getComputedStyle(div).animationDuration; + + div.style.transition = 'all 100s'; + div.style.animationDuration = '100s'; + + assert_equals(div.getAnimations().length, 0, + 'getAnimations returns an empty sequence for a transition' + + ' of a non-animatable property'); +}, 'getAnimations for transition on non-animatable property'); + +test(function(t) { + var div = addDiv(t); + + div.style.setProperty('-vendor-unsupported', '0px', ''); + window.getComputedStyle(div).transitionProperty; + div.style.transition = 'all 100s'; + div.style.setProperty('-vendor-unsupported', '100px', ''); + + assert_equals(div.getAnimations().length, 0, + 'getAnimations returns an empty sequence for a transition' + + ' of an unsupported property'); +}, 'getAnimations for transition on unsupported property'); + +test(function(t) { + var div = addDiv(t, { style: 'transform: translate(0px); ' + + 'opacity: 0; ' + + 'border-width: 0px; ' + // Shorthand + 'border-style: solid' }); + getComputedStyle(div).transform; + + div.style.transition = 'all 100s'; + div.style.transform = 'translate(100px)'; + div.style.opacity = '1'; + div.style.borderWidth = '1px'; + + var animations = div.getAnimations(); + assert_equals(animations.length, 6, + 'Generated expected number of transitions'); + assert_equals(animations[0].transitionProperty, 'border-bottom-width'); + assert_equals(animations[1].transitionProperty, 'border-left-width'); + assert_equals(animations[2].transitionProperty, 'border-right-width'); + assert_equals(animations[3].transitionProperty, 'border-top-width'); + assert_equals(animations[4].transitionProperty, 'opacity'); + assert_equals(animations[5].transitionProperty, 'transform'); +}, 'getAnimations sorts simultaneous transitions by name'); + +test(function(t) { + var div = addDiv(t, { style: 'transform: translate(0px); ' + + 'opacity: 0' }); + getComputedStyle(div).transform; + + div.style.transition = 'all 100s'; + div.style.transform = 'translate(100px)'; + assert_equals(div.getAnimations().length, 1, + 'Initially there is only one (transform) transition'); + div.style.opacity = '1'; + assert_equals(div.getAnimations().length, 2, + 'Then a second (opacity) transition is added'); + + var animations = div.getAnimations(); + assert_equals(animations[0].transitionProperty, 'transform'); + assert_equals(animations[1].transitionProperty, 'opacity'); +}, 'getAnimations sorts transitions by when they were generated'); + +done(); +</script> +</body> diff --git a/dom/animation/test/css-transitions/file_keyframeeffect-getkeyframes.html b/dom/animation/test/css-transitions/file_keyframeeffect-getkeyframes.html new file mode 100644 index 000000000..7bbf76fa7 --- /dev/null +++ b/dom/animation/test/css-transitions/file_keyframeeffect-getkeyframes.html @@ -0,0 +1,95 @@ +<!doctype html> +<meta charset=utf-8> +<script src="../testcommon.js"></script> +<style> +:root { + --var-100px: 100px; +} +</style> +<body> +<script> +'use strict'; + +function getKeyframes(e) { + return e.getAnimations()[0].effect.getKeyframes(); +} + +function assert_frames_equal(a, b, name) { + assert_equals(Object.keys(a).sort().toString(), + Object.keys(b).sort().toString(), + "properties on " + name); + for (var p in a) { + assert_equals(a[p], b[p], "value for '" + p + "' on " + name); + } +} + +test(function(t) { + var div = addDiv(t); + + div.style.left = '0px'; + window.getComputedStyle(div).transitionProperty; + div.style.transition = 'left 100s'; + div.style.left = '100px'; + + var frames = getKeyframes(div); + + assert_equals(frames.length, 2, "number of frames"); + + var expected = [ + { offset: 0, computedOffset: 0, easing: "ease", left: "0px" }, + { offset: 1, computedOffset: 1, easing: "linear", left: "100px" }, + ]; + + for (var i = 0; i < frames.length; i++) { + assert_frames_equal(frames[i], expected[i], "ComputedKeyframe #" + i); + } +}, 'KeyframeEffectReadOnly.getKeyframes() returns expected frames for a simple' + + ' transition'); + +test(function(t) { + var div = addDiv(t); + + div.style.left = '0px'; + window.getComputedStyle(div).transitionProperty; + div.style.transition = 'left 100s steps(2,end)'; + div.style.left = '100px'; + + var frames = getKeyframes(div); + + assert_equals(frames.length, 2, "number of frames"); + + var expected = [ + { offset: 0, computedOffset: 0, easing: "steps(2)", left: "0px" }, + { offset: 1, computedOffset: 1, easing: "linear", left: "100px" }, + ]; + + for (var i = 0; i < frames.length; i++) { + assert_frames_equal(frames[i], expected[i], "ComputedKeyframe #" + i); + } +}, 'KeyframeEffectReadOnly.getKeyframes() returns expected frames for a simple' + + ' transition with a non-default easing function'); + +test(function(t) { + var div = addDiv(t); + div.style.left = '0px'; + window.getComputedStyle(div).transitionProperty; + div.style.transition = 'left 100s'; + div.style.left = 'var(--var-100px)'; + + var frames = getKeyframes(div); + + // CSS transition endpoints are based on the computed value so we + // shouldn't see the variable reference + var expected = [ + { offset: 0, computedOffset: 0, easing: 'ease', left: '0px' }, + { offset: 1, computedOffset: 1, easing: 'linear', left: '100px' }, + ]; + for (var i = 0; i < frames.length; i++) { + assert_frames_equal(frames[i], expected[i], "ComputedKeyframe #" + i); + } +}, 'KeyframeEffectReadOnly.getKeyframes() returns expected frames for a' + + ' transition with a CSS variable endpoint'); + +done(); +</script> +</body> diff --git a/dom/animation/test/css-transitions/file_pseudoElement-get-animations.html b/dom/animation/test/css-transitions/file_pseudoElement-get-animations.html new file mode 100644 index 000000000..5683a14a1 --- /dev/null +++ b/dom/animation/test/css-transitions/file_pseudoElement-get-animations.html @@ -0,0 +1,45 @@ +<!doctype html> +<meta charset=utf-8> +<script src="../testcommon.js"></script> +<style> +.init::before { + content: ''; + height: 0px; + width: 0px; + opacity: 0; + transition: all 100s; +} +.change::before { + height: 100px; + width: 100px; + opacity: 1; +} +</style> +<body> +<script> +'use strict'; + +test(function(t) { + var div = addDiv(t, { class: 'init' }); + flushComputedStyle(div); + div.classList.add('change'); + + // Sanity checks + assert_equals(document.getAnimations().length, 3, + 'Got expected number of animations on document'); + var pseudoTarget = document.getAnimations()[0].effect.target; + assert_class_string(pseudoTarget, 'CSSPseudoElement', + 'Got pseudo-element target'); + + // Check animations returned from the pseudo element are in correct order + var anims = pseudoTarget.getAnimations(); + assert_equals(anims.length, 3, + 'Got expected number of animations on pseudo-element'); + assert_equals(anims[0].transitionProperty, 'height'); + assert_equals(anims[1].transitionProperty, 'opacity'); + assert_equals(anims[2].transitionProperty, 'width'); +}, 'getAnimations sorts simultaneous transitions by name'); + +done(); +</script> +</body> diff --git a/dom/animation/test/css-transitions/file_setting-effect.html b/dom/animation/test/css-transitions/file_setting-effect.html new file mode 100644 index 000000000..c61877194 --- /dev/null +++ b/dom/animation/test/css-transitions/file_setting-effect.html @@ -0,0 +1,91 @@ +<!doctype html> +<meta charset=utf-8> +<script src='../testcommon.js'></script> +<body> +<script> +'use strict'; + +promise_test(function(t) { + var div = addDiv(t); + div.style.left = '0px'; + + div.style.transition = 'left 100s'; + flushComputedStyle(div); + div.style.left = '100px'; + + var transition = div.getAnimations()[0]; + return transition.ready.then(function() { + transition.currentTime = 50 * MS_PER_SEC; + transition.effect = null; + assert_equals(transition.transitionProperty, 'left'); + assert_equals(transition.playState, 'finished'); + assert_equals(window.getComputedStyle(div).left, '100px'); + }); +}, 'Test for removing a transition effect'); + +promise_test(function(t) { + var div = addDiv(t); + div.style.left = '0px'; + + div.style.transition = 'left 100s'; + flushComputedStyle(div); + div.style.left = '100px'; + + var transition = div.getAnimations()[0]; + return transition.ready.then(function() { + transition.currentTime = 50 * MS_PER_SEC; + transition.effect = new KeyframeEffect(div, + { marginLeft: [ '0px' , '100px'] }, + 100 * MS_PER_SEC); + assert_equals(transition.transitionProperty, 'left'); + assert_equals(transition.playState, 'running'); + assert_equals(window.getComputedStyle(div).left, '100px'); + assert_equals(window.getComputedStyle(div).marginLeft, '50px'); + }); +}, 'Test for replacing the transition effect by a new keyframe effect'); + +promise_test(function(t) { + var div = addDiv(t); + div.style.left = '0px'; + div.style.width = '0px'; + + div.style.transition = 'left 100s'; + flushComputedStyle(div); + div.style.left = '100px'; + + var transition = div.getAnimations()[0]; + return transition.ready.then(function() { + transition.currentTime = 50 * MS_PER_SEC; + transition.effect = new KeyframeEffect(div, + { marginLeft: [ '0px' , '100px'] }, + 20 * MS_PER_SEC); + assert_equals(transition.playState, 'finished'); + }); +}, 'Test for setting a new keyframe effect with a shorter duration'); + +promise_test(function(t) { + var div = addDiv(t); + div.style.left = '0px'; + div.style.width = '0px'; + + div.style.transition = 'left 100s'; + flushComputedStyle(div); + div.style.left = '100px'; + + var transition = div.getAnimations()[0]; + assert_equals(transition.playState, 'pending'); + + transition.effect = new KeyframeEffect(div, + { marginLeft: [ '0px' , '100px'] }, + 100 * MS_PER_SEC); + assert_equals(transition.transitionProperty, 'left'); + assert_equals(transition.playState, 'pending'); + + return transition.ready.then(function() { + assert_equals(transition.playState, 'running'); + }); +}, 'Test for setting a new keyframe effect to a pending transition'); + +done(); +</script> +</body> diff --git a/dom/animation/test/css-transitions/test_animation-cancel.html b/dom/animation/test/css-transitions/test_animation-cancel.html new file mode 100644 index 000000000..949e0843e --- /dev/null +++ b/dom/animation/test/css-transitions/test_animation-cancel.html @@ -0,0 +1,14 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id="log"></div> +<script> +'use strict'; +setup({explicit_done: true}); +SpecialPowers.pushPrefEnv( + { "set": [["dom.animations-api.core.enabled", true]]}, + function() { + window.open("file_animation-cancel.html"); + }); +</script> diff --git a/dom/animation/test/css-transitions/test_animation-computed-timing.html b/dom/animation/test/css-transitions/test_animation-computed-timing.html new file mode 100644 index 000000000..c1b40aaf3 --- /dev/null +++ b/dom/animation/test/css-transitions/test_animation-computed-timing.html @@ -0,0 +1,16 @@ +<!doctype html> +<html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id="log"></div> +<script> +'use strict'; +setup({explicit_done: true}); +SpecialPowers.pushPrefEnv( + { "set": [["dom.animations-api.core.enabled", true]]}, + function() { + window.open("file_animation-computed-timing.html"); + }); +</script> +</html> diff --git a/dom/animation/test/css-transitions/test_animation-currenttime.html b/dom/animation/test/css-transitions/test_animation-currenttime.html new file mode 100644 index 000000000..30b0ed030 --- /dev/null +++ b/dom/animation/test/css-transitions/test_animation-currenttime.html @@ -0,0 +1,14 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id="log"></div> +<script> +'use strict'; +setup({explicit_done: true}); +SpecialPowers.pushPrefEnv( + { "set": [["dom.animations-api.core.enabled", true]]}, + function() { + window.open("file_animation-currenttime.html"); + }); +</script> diff --git a/dom/animation/test/css-transitions/test_animation-finished.html b/dom/animation/test/css-transitions/test_animation-finished.html new file mode 100644 index 000000000..f2ed7f80b --- /dev/null +++ b/dom/animation/test/css-transitions/test_animation-finished.html @@ -0,0 +1,14 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id="log"></div> +<script> +'use strict'; +setup({explicit_done: true}); +SpecialPowers.pushPrefEnv( + { "set": [["dom.animations-api.core.enabled", true]]}, + function() { + window.open("file_animation-finished.html"); + }); +</script> diff --git a/dom/animation/test/css-transitions/test_animation-pausing.html b/dom/animation/test/css-transitions/test_animation-pausing.html new file mode 100644 index 000000000..67484a2a5 --- /dev/null +++ b/dom/animation/test/css-transitions/test_animation-pausing.html @@ -0,0 +1,14 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id="log"></div> +<script> +'use strict'; +setup({explicit_done: true}); +SpecialPowers.pushPrefEnv( + { "set": [["dom.animations-api.core.enabled", true]]}, + function() { + window.open("file_animation-pausing.html"); + }); +</script> diff --git a/dom/animation/test/css-transitions/test_animation-ready.html b/dom/animation/test/css-transitions/test_animation-ready.html new file mode 100644 index 000000000..a928ded64 --- /dev/null +++ b/dom/animation/test/css-transitions/test_animation-ready.html @@ -0,0 +1,14 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id="log"></div> +<script> +'use strict'; +setup({explicit_done: true}); +SpecialPowers.pushPrefEnv( + { "set": [["dom.animations-api.core.enabled", true]]}, + function() { + window.open("file_animation-ready.html"); + }); +</script> diff --git a/dom/animation/test/css-transitions/test_animation-starttime.html b/dom/animation/test/css-transitions/test_animation-starttime.html new file mode 100644 index 000000000..8a8c85f2d --- /dev/null +++ b/dom/animation/test/css-transitions/test_animation-starttime.html @@ -0,0 +1,14 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id="log"></div> +<script> +'use strict'; +setup({explicit_done: true}); +SpecialPowers.pushPrefEnv( + { "set": [["dom.animations-api.core.enabled", true]]}, + function() { + window.open("file_animation-starttime.html"); + }); +</script> diff --git a/dom/animation/test/css-transitions/test_csstransition-events.html b/dom/animation/test/css-transitions/test_csstransition-events.html new file mode 100644 index 000000000..92559ad67 --- /dev/null +++ b/dom/animation/test/css-transitions/test_csstransition-events.html @@ -0,0 +1,14 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id="log"></div> +<script> +'use strict'; +setup({explicit_done: true}); +SpecialPowers.pushPrefEnv( + { "set": [["dom.animations-api.core.enabled", true]]}, + function() { + window.open("file_csstransition-events.html"); + }); +</script> diff --git a/dom/animation/test/css-transitions/test_csstransition-transitionproperty.html b/dom/animation/test/css-transitions/test_csstransition-transitionproperty.html new file mode 100644 index 000000000..0aa1912d9 --- /dev/null +++ b/dom/animation/test/css-transitions/test_csstransition-transitionproperty.html @@ -0,0 +1,14 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id="log"></div> +<script> +'use strict'; +setup({explicit_done: true}); +SpecialPowers.pushPrefEnv( + { "set": [["dom.animations-api.core.enabled", true]]}, + function() { + window.open("file_csstransition-transitionproperty.html"); + }); +</script> diff --git a/dom/animation/test/css-transitions/test_document-get-animations.html b/dom/animation/test/css-transitions/test_document-get-animations.html new file mode 100644 index 000000000..dc964e62c --- /dev/null +++ b/dom/animation/test/css-transitions/test_document-get-animations.html @@ -0,0 +1,15 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id="log"></div> +<script> +'use strict'; +setup({explicit_done: true}); +SpecialPowers.pushPrefEnv( + { "set": [["dom.animations-api.core.enabled", true]]}, + function() { + window.open("file_document-get-animations.html"); + }); +</script> +</html> diff --git a/dom/animation/test/css-transitions/test_effect-target.html b/dom/animation/test/css-transitions/test_effect-target.html new file mode 100644 index 000000000..f3ae72229 --- /dev/null +++ b/dom/animation/test/css-transitions/test_effect-target.html @@ -0,0 +1,14 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id="log"></div> +<script> +'use strict'; +setup({explicit_done: true}); +SpecialPowers.pushPrefEnv( + { "set": [["dom.animations-api.core.enabled", true]]}, + function() { + window.open("file_effect-target.html"); + }); +</script> diff --git a/dom/animation/test/css-transitions/test_element-get-animations.html b/dom/animation/test/css-transitions/test_element-get-animations.html new file mode 100644 index 000000000..87abdfa73 --- /dev/null +++ b/dom/animation/test/css-transitions/test_element-get-animations.html @@ -0,0 +1,14 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id="log"></div> +<script> +'use strict'; +setup({explicit_done: true}); +SpecialPowers.pushPrefEnv( + { "set": [["dom.animations-api.core.enabled", true]]}, + function() { + window.open("file_element-get-animations.html"); + }); +</script> diff --git a/dom/animation/test/css-transitions/test_keyframeeffect-getkeyframes.html b/dom/animation/test/css-transitions/test_keyframeeffect-getkeyframes.html new file mode 100644 index 000000000..dcc54255d --- /dev/null +++ b/dom/animation/test/css-transitions/test_keyframeeffect-getkeyframes.html @@ -0,0 +1,14 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id="log"></div> +<script> +'use strict'; +setup({explicit_done: true}); +SpecialPowers.pushPrefEnv( + { "set": [["dom.animations-api.core.enabled", true]]}, + function() { + window.open("file_keyframeeffect-getkeyframes.html"); + }); +</script> diff --git a/dom/animation/test/css-transitions/test_pseudoElement-get-animations.html b/dom/animation/test/css-transitions/test_pseudoElement-get-animations.html new file mode 100644 index 000000000..1e0dc5c82 --- /dev/null +++ b/dom/animation/test/css-transitions/test_pseudoElement-get-animations.html @@ -0,0 +1,14 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id="log"></div> +<script> +'use strict'; +setup({explicit_done: true}); +SpecialPowers.pushPrefEnv( + { "set": [["dom.animations-api.core.enabled", true]]}, + function() { + window.open("file_pseudoElement-get-animations.html"); + }); +</script> diff --git a/dom/animation/test/css-transitions/test_setting-effect.html b/dom/animation/test/css-transitions/test_setting-effect.html new file mode 100644 index 000000000..a9654ec55 --- /dev/null +++ b/dom/animation/test/css-transitions/test_setting-effect.html @@ -0,0 +1,14 @@ +<!doctype html> +<meta charset=utf-8> +<script src='/resources/testharness.js'></script> +<script src='/resources/testharnessreport.js'></script> +<div id='log'></div> +<script> +'use strict'; +setup({explicit_done: true}); +SpecialPowers.pushPrefEnv( + { 'set': [['dom.animations-api.core.enabled', true]]}, + function() { + window.open('file_setting-effect.html'); + }); +</script> diff --git a/dom/animation/test/document-timeline/file_document-timeline.html b/dom/animation/test/document-timeline/file_document-timeline.html new file mode 100644 index 000000000..7ec48e291 --- /dev/null +++ b/dom/animation/test/document-timeline/file_document-timeline.html @@ -0,0 +1,135 @@ +<!doctype html> +<meta charset=utf-8> +<title>Web Animations API: DocumentTimeline tests</title> +<script src="../testcommon.js"></script> +<iframe src="data:text/html;charset=utf-8," width="10" height="10" id="iframe"></iframe> +<iframe src="data:text/html;charset=utf-8,%3Chtml%20style%3D%22display%3Anone%22%3E%3C%2Fhtml%3E" width="10" height="10" id="hidden-iframe"></iframe> +<script> +'use strict'; + +test(function() { + assert_equals(document.timeline, document.timeline, + 'document.timeline returns the same object every time'); + var iframe = document.getElementById('iframe'); + assert_not_equals(document.timeline, iframe.contentDocument.timeline, + 'document.timeline returns a different object for each document'); + assert_not_equals(iframe.contentDocument.timeline, null, + 'document.timeline on an iframe is not null'); +}, +'document.timeline identity tests', +{ + help: 'http://dev.w3.org/fxtf/web-animations/#the-document-timeline', + assert: [ 'Each document has a timeline called the document timeline' ], + author: 'Brian Birtles' +}); + +async_test(function(t) { + assert_true(document.timeline.currentTime > 0, + 'document.timeline.currentTime is positive'); + // document.timeline.currentTime should be set even before document + // load fires. We expect this code to be run before document load and hence + // the above assertion is sufficient. + // If the following assertion fails, this test needs to be redesigned. + assert_true(document.readyState !== 'complete', + 'Test is running prior to document load'); + + // Test that the document timeline's current time is measured from + // navigationStart. + // + // We can't just compare document.timeline.currentTime to + // window.performance.now() because currentTime is only updated on a sample + // so we use requestAnimationFrame instead. + window.requestAnimationFrame(t.step_func(function(rafTime) { + assert_equals(document.timeline.currentTime, rafTime, + 'document.timeline.currentTime matches' + + ' requestAnimationFrame time'); + t.done(); + })); +}, +'document.timeline.currentTime value tests', +{ + help: [ + 'http://dev.w3.org/fxtf/web-animations/#the-global-clock', + 'http://dev.w3.org/fxtf/web-animations/#the-document-timeline' + ], + assert: [ + 'The global clock is a source of monotonically increasing time values', + 'The time values of the document timeline are calculated as a fixed' + + ' offset from the global clock', + 'the zero time corresponds to the navigationStart moment', + 'the time value of each document timeline must be equal to the time ' + + 'passed to animation frame request callbacks for that browsing context' + ], + author: 'Brian Birtles' +}); + +async_test(function(t) { + var valueAtStart = document.timeline.currentTime; + var timeAtStart = window.performance.now(); + while (window.performance.now() - timeAtStart < 100) { + // Wait 100ms + } + assert_equals(document.timeline.currentTime, valueAtStart, + 'document.timeline.currentTime does not change within a script block'); + window.requestAnimationFrame(t.step_func(function() { + assert_true(document.timeline.currentTime > valueAtStart, + 'document.timeline.currentTime increases between script blocks'); + t.done(); + })); +}, +'document.timeline.currentTime liveness tests', +{ + help: 'http://dev.w3.org/fxtf/web-animations/#script-execution-and-live-updates-to-the-model', + assert: [ 'The value returned by the currentTime attribute of a' + + ' document timeline will not change within a script block' ], + author: 'Brian Birtles' +}); + +test(function() { + var hiddenIFrame = document.getElementById('hidden-iframe'); + assert_equals(typeof hiddenIFrame.contentDocument.timeline.currentTime, + 'number', + 'currentTime of an initially hidden subframe\'s timeline is a number'); + assert_true(hiddenIFrame.contentDocument.timeline.currentTime >= 0, + 'currentTime of an initially hidden subframe\'s timeline is >= 0'); +}, 'document.timeline.currentTime hidden subframe test'); + +async_test(function(t) { + var hiddenIFrame = document.getElementById('hidden-iframe'); + + // Don't run the test until after the iframe has completed loading or else the + // contentDocument may change. + var testToRunOnLoad = t.step_func(function() { + // Remove display:none + hiddenIFrame.style.display = 'block'; + window.getComputedStyle(hiddenIFrame).display; + + window.requestAnimationFrame(t.step_func(function() { + assert_true(hiddenIFrame.contentDocument.timeline.currentTime > 0, + 'document.timeline.currentTime is positive after removing' + + ' display:none'); + var previousValue = hiddenIFrame.contentDocument.timeline.currentTime; + + // Re-introduce display:none + hiddenIFrame.style.display = 'none'; + window.getComputedStyle(hiddenIFrame).display; + + window.requestAnimationFrame(t.step_func(function() { + assert_true( + hiddenIFrame.contentDocument.timeline.currentTime >= previousValue, + 'document.timeline.currentTime does not go backwards after' + + ' re-setting display:none'); + t.done(); + })); + })); + }); + + if (hiddenIFrame.contentDocument.readyState === 'complete') { + testToRunOnLoad(); + } else { + hiddenIFrame.addEventListener("load", testToRunOnLoad); + } +}, 'document.timeline.currentTime hidden subframe dynamic test'); + +done(); +</script> diff --git a/dom/animation/test/document-timeline/test_document-timeline.html b/dom/animation/test/document-timeline/test_document-timeline.html new file mode 100644 index 000000000..812d88ef2 --- /dev/null +++ b/dom/animation/test/document-timeline/test_document-timeline.html @@ -0,0 +1,14 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id="log"></div> +<script> +'use strict'; +setup({explicit_done: true}); +SpecialPowers.pushPrefEnv( + { "set": [["dom.animations-api.core.enabled", true]]}, + function() { + window.open("file_document-timeline.html"); + }); +</script> diff --git a/dom/animation/test/document-timeline/test_request_animation_frame.html b/dom/animation/test/document-timeline/test_request_animation_frame.html new file mode 100644 index 000000000..302a385b7 --- /dev/null +++ b/dom/animation/test/document-timeline/test_request_animation_frame.html @@ -0,0 +1,27 @@ +<!DOCTYPE html> +<meta charset=utf-8> +<title>Test RequestAnimationFrame Timestamps are monotonically increasing</title> +<script type="application/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> +<link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +<script> + var lastRequestAnimationFrameTimestamp = 0; + var requestAnimationFrameCount = 20; + var currentCount = 0; + + // Test that all timestamps are always increasing + // and do not ever go backwards + function rafCallback(aTimestamp) { + SimpleTest.ok(aTimestamp > lastRequestAnimationFrameTimestamp, + "New RequestAnimationFrame timestamp should be later than the previous RequestAnimationFrame timestamp"); + lastRequestAnimationFrameTimestamp = aTimestamp; + if (currentCount == requestAnimationFrameCount) { + SimpleTest.finish(); + } else { + currentCount++; + window.requestAnimationFrame(rafCallback); + } + } + + window.requestAnimationFrame(rafCallback); + SimpleTest.waitForExplicitFinish(); +</script> diff --git a/dom/animation/test/mochitest.ini b/dom/animation/test/mochitest.ini new file mode 100644 index 000000000..feb424518 --- /dev/null +++ b/dom/animation/test/mochitest.ini @@ -0,0 +1,111 @@ +[DEFAULT] +# Support files for chrome tests that we want to load over HTTP need +# to go in here, not chrome.ini. +support-files = + chrome/file_animate_xrays.html + css-animations/file_animation-cancel.html + css-animations/file_animation-computed-timing.html + css-animations/file_animation-currenttime.html + css-animations/file_animation-finish.html + css-animations/file_animation-finished.html + css-animations/file_animation-id.html + css-animations/file_animation-pausing.html + css-animations/file_animation-playstate.html + css-animations/file_animation-ready.html + css-animations/file_animation-reverse.html + css-animations/file_animation-starttime.html + css-animations/file_animations-dynamic-changes.html + css-animations/file_cssanimation-animationname.html + css-animations/file_document-get-animations.html + css-animations/file_effect-target.html + css-animations/file_element-get-animations.html + css-animations/file_keyframeeffect-getkeyframes.html + css-animations/file_pseudoElement-get-animations.html + css-transitions/file_animation-cancel.html + css-transitions/file_animation-computed-timing.html + css-transitions/file_animation-currenttime.html + css-transitions/file_animation-finished.html + css-transitions/file_animation-pausing.html + css-transitions/file_animation-ready.html + css-transitions/file_animation-starttime.html + css-transitions/file_csstransition-transitionproperty.html + css-transitions/file_document-get-animations.html + css-transitions/file_effect-target.html + css-transitions/file_element-get-animations.html + css-transitions/file_keyframeeffect-getkeyframes.html + css-transitions/file_pseudoElement-get-animations.html + css-transitions/file_setting-effect.html + document-timeline/file_document-timeline.html + mozilla/file_cubic_bezier_limits.html + mozilla/file_deferred_start.html + mozilla/file_disabled_properties.html + mozilla/file_disable_animations_api_core.html + mozilla/file_discrete-animations.html + mozilla/file_document-timeline-origin-time-range.html + mozilla/file_hide_and_show.html + mozilla/file_partial_keyframes.html + mozilla/file_spacing_property_order.html + mozilla/file_spacing_transform.html + mozilla/file_transform_limits.html + mozilla/file_transition_finish_on_compositor.html + mozilla/file_underlying-discrete-value.html + mozilla/file_set-easing.html + style/file_animation-seeking-with-current-time.html + style/file_animation-seeking-with-start-time.html + style/file_animation-setting-effect.html + style/file_animation-setting-spacing.html + testcommon.js + +[css-animations/test_animations-dynamic-changes.html] +[css-animations/test_animation-cancel.html] +[css-animations/test_animation-computed-timing.html] +[css-animations/test_animation-currenttime.html] +[css-animations/test_animation-finish.html] +[css-animations/test_animation-finished.html] +[css-animations/test_animation-id.html] +[css-animations/test_animation-pausing.html] +[css-animations/test_animation-playstate.html] +[css-animations/test_animation-ready.html] +[css-animations/test_animation-reverse.html] +[css-animations/test_animation-starttime.html] +[css-animations/test_cssanimation-animationname.html] +[css-animations/test_document-get-animations.html] +[css-animations/test_effect-target.html] +[css-animations/test_element-get-animations.html] +[css-animations/test_keyframeeffect-getkeyframes.html] +[css-animations/test_pseudoElement-get-animations.html] +[css-transitions/test_animation-cancel.html] +[css-transitions/test_animation-computed-timing.html] +[css-transitions/test_animation-currenttime.html] +[css-transitions/test_animation-finished.html] +[css-transitions/test_animation-pausing.html] +[css-transitions/test_animation-ready.html] +[css-transitions/test_animation-starttime.html] +[css-transitions/test_csstransition-transitionproperty.html] +[css-transitions/test_document-get-animations.html] +[css-transitions/test_effect-target.html] +[css-transitions/test_element-get-animations.html] +[css-transitions/test_keyframeeffect-getkeyframes.html] +[css-transitions/test_pseudoElement-get-animations.html] +[css-transitions/test_setting-effect.html] +[document-timeline/test_document-timeline.html] +[document-timeline/test_request_animation_frame.html] +[mozilla/test_cubic_bezier_limits.html] +[mozilla/test_deferred_start.html] +[mozilla/test_disable_animations_api_core.html] +[mozilla/test_disabled_properties.html] +[mozilla/test_discrete-animations.html] +[mozilla/test_document-timeline-origin-time-range.html] +[mozilla/test_hide_and_show.html] +[mozilla/test_partial_keyframes.html] +[mozilla/test_set-easing.html] +[mozilla/test_spacing_property_order.html] +[mozilla/test_spacing_transform.html] +[mozilla/test_transform_limits.html] +[mozilla/test_transition_finish_on_compositor.html] +skip-if = toolkit == 'android' +[mozilla/test_underlying-discrete-value.html] +[style/test_animation-seeking-with-current-time.html] +[style/test_animation-seeking-with-start-time.html] +[style/test_animation-setting-effect.html] +[style/test_animation-setting-spacing.html] diff --git a/dom/animation/test/mozilla/file_cubic_bezier_limits.html b/dom/animation/test/mozilla/file_cubic_bezier_limits.html new file mode 100644 index 000000000..a0378f395 --- /dev/null +++ b/dom/animation/test/mozilla/file_cubic_bezier_limits.html @@ -0,0 +1,167 @@ +<!doctype html> +<meta charset=utf-8> +<script src="../testcommon.js"></script> +<body> +<style> +@keyframes anim { + to { margin-left: 100px; } +} + +.transition-div { + margin-left: 100px; +} +</style> +<script> +'use strict'; + +// We clamp +infinity or -inifinity value in floating point to +// maximum floating point value or -maxinum floating point value. +const max_float = 3.40282e+38; + +test(function(t) { + var div = addDiv(t); + var anim = div.animate({ }, 100 * MS_PER_SEC); + + anim.effect.timing.easing = 'cubic-bezier(0, 1e+39, 0, 0)'; + assert_equals(anim.effect.timing.easing, + 'cubic-bezier(0, ' + max_float + ', 0, 0)', + 'y1 control point for effect easing is out of upper boundary'); + + anim.effect.timing.easing = 'cubic-bezier(0, 0, 0, 1e+39)'; + assert_equals(anim.effect.timing.easing, + 'cubic-bezier(0, 0, 0, ' + max_float + ')', + 'y2 control point for effect easing is out of upper boundary'); + + anim.effect.timing.easing = 'cubic-bezier(0, -1e+39, 0, 0)'; + assert_equals(anim.effect.timing.easing, + 'cubic-bezier(0, ' + -max_float + ', 0, 0)', + 'y1 control point for effect easing is out of lower boundary'); + + anim.effect.timing.easing = 'cubic-bezier(0, 0, 0, -1e+39)'; + assert_equals(anim.effect.timing.easing, + 'cubic-bezier(0, 0, 0, ' + -max_float + ')', + 'y2 control point for effect easing is out of lower boundary'); + +}, 'Clamp y1 and y2 control point out of boundaries for effect easing' ); + +test(function(t) { + var div = addDiv(t); + var anim = div.animate({ }, 100 * MS_PER_SEC); + + anim.effect.setKeyframes([ { easing: 'cubic-bezier(0, 1e+39, 0, 0)' }]); + assert_equals(anim.effect.getKeyframes()[0].easing, + 'cubic-bezier(0, ' + max_float + ', 0, 0)', + 'y1 control point for keyframe easing is out of upper boundary'); + + anim.effect.setKeyframes([ { easing: 'cubic-bezier(0, 0, 0, 1e+39)' }]); + assert_equals(anim.effect.getKeyframes()[0].easing, + 'cubic-bezier(0, 0, 0, ' + max_float + ')', + 'y2 control point for keyframe easing is out of upper boundary'); + + anim.effect.setKeyframes([ { easing: 'cubic-bezier(0, -1e+39, 0, 0)' }]); + assert_equals(anim.effect.getKeyframes()[0].easing, + 'cubic-bezier(0, ' + -max_float + ', 0, 0)', + 'y1 control point for keyframe easing is out of lower boundary'); + + anim.effect.setKeyframes([ { easing: 'cubic-bezier(0, 0, 0, -1e+39)' }]); + assert_equals(anim.effect.getKeyframes()[0].easing, + 'cubic-bezier(0, 0, 0, ' + -max_float + ')', + 'y2 control point for keyframe easing is out of lower boundary'); + +}, 'Clamp y1 and y2 control point out of boundaries for keyframe easing' ); + +test(function(t) { + var div = addDiv(t); + + div.style.animation = 'anim 100s cubic-bezier(0, 1e+39, 0, 0)'; + + assert_equals(div.getAnimations()[0].effect.getKeyframes()[0].easing, + 'cubic-bezier(0, ' + max_float + ', 0, 0)', + 'y1 control point for CSS animation is out of upper boundary'); + + div.style.animation = 'anim 100s cubic-bezier(0, 0, 0, 1e+39)'; + assert_equals(div.getAnimations()[0].effect.getKeyframes()[0].easing, + 'cubic-bezier(0, 0, 0, ' + max_float + ')', + 'y2 control point for CSS animation is out of upper boundary'); + + div.style.animation = 'anim 100s cubic-bezier(0, -1e+39, 0, 0)'; + assert_equals(div.getAnimations()[0].effect.getKeyframes()[0].easing, + 'cubic-bezier(0, ' + -max_float + ', 0, 0)', + 'y1 control point for CSS animation is out of lower boundary'); + + div.style.animation = 'anim 100s cubic-bezier(0, 0, 0, -1e+39)'; + assert_equals(div.getAnimations()[0].effect.getKeyframes()[0].easing, + 'cubic-bezier(0, 0, 0, ' + -max_float + ')', + 'y2 control point for CSS animation is out of lower boundary'); + +}, 'Clamp y1 and y2 control point out of boundaries for CSS animation' ); + +test(function(t) { + var div = addDiv(t, {'class': 'transition-div'}); + + div.style.transition = 'margin-left 100s cubic-bezier(0, 1e+39, 0, 0)'; + flushComputedStyle(div); + div.style.marginLeft = '0px'; + assert_equals(div.getAnimations()[0].effect.getKeyframes()[0].easing, + 'cubic-bezier(0, ' + max_float + ', 0, 0)', + 'y1 control point for CSS transition on upper boundary'); + div.style.transition = ''; + div.style.marginLeft = ''; + + div.style.transition = 'margin-left 100s cubic-bezier(0, 0, 0, 1e+39)'; + flushComputedStyle(div); + div.style.marginLeft = '0px'; + assert_equals(div.getAnimations()[0].effect.getKeyframes()[0].easing, + 'cubic-bezier(0, 0, 0, ' + max_float + ')', + 'y2 control point for CSS transition on upper boundary'); + div.style.transition = ''; + div.style.marginLeft = ''; + + div.style.transition = 'margin-left 100s cubic-bezier(0, -1e+39, 0, 0)'; + flushComputedStyle(div); + div.style.marginLeft = '0px'; + assert_equals(div.getAnimations()[0].effect.getKeyframes()[0].easing, + 'cubic-bezier(0, ' + -max_float + ', 0, 0)', + 'y1 control point for CSS transition on lower boundary'); + div.style.transition = ''; + div.style.marginLeft = ''; + + div.style.transition = 'margin-left 100s cubic-bezier(0, 0, 0, -1e+39)'; + flushComputedStyle(div); + div.style.marginLeft = '0px'; + assert_equals(div.getAnimations()[0].effect.getKeyframes()[0].easing, + 'cubic-bezier(0, 0, 0, ' + -max_float + ')', + 'y2 control point for CSS transition on lower boundary'); + +}, 'Clamp y1 and y2 control point out of boundaries for CSS transition' ); + +test(function(t) { + var div = addDiv(t); + var anim = div.animate({ }, { duration: 100 * MS_PER_SEC, fill: 'forwards' }); + + anim.pause(); + // The positive steepest function on both edges. + anim.effect.timing.easing = 'cubic-bezier(0, 1e+39, 0, 1e+39)'; + assert_equals(anim.effect.getComputedTiming().progress, 0.0, + 'progress on lower edge for the highest value of y1 and y2 control points'); + + anim.finish(); + assert_equals(anim.effect.getComputedTiming().progress, 1.0, + 'progress on upper edge for the highest value of y1 and y2 control points'); + + // The negative steepest function on both edges. + anim.effect.timing.easing = 'cubic-bezier(0, -1e+39, 0, -1e+39)'; + anim.currentTime = 0; + assert_equals(anim.effect.getComputedTiming().progress, 0.0, + 'progress on lower edge for the lowest value of y1 and y2 control points'); + + anim.finish(); + assert_equals(anim.effect.getComputedTiming().progress, 1.0, + 'progress on lower edge for the lowest value of y1 and y2 control points'); + +}, 'Calculated values on both edges'); + +done(); + +</script> +</body> diff --git a/dom/animation/test/mozilla/file_deferred_start.html b/dom/animation/test/mozilla/file_deferred_start.html new file mode 100644 index 000000000..3be3f56aa --- /dev/null +++ b/dom/animation/test/mozilla/file_deferred_start.html @@ -0,0 +1,121 @@ +<!doctype html> +<meta charset=utf-8> +<script src="../testcommon.js"></script> +<script src="/tests/SimpleTest/paint_listener.js"></script> +<style> +@keyframes empty { } +@keyframes animTransform { + from { transform: translate(0px); } + to { transform: translate(100px); } +} +.target { + /* Element needs geometry to be eligible for layerization */ + width: 100px; + height: 100px; + background-color: white; +} +</style> +<body> +<script> +'use strict'; + +function waitForDocLoad() { + return new Promise(function(resolve, reject) { + if (document.readyState === 'complete') { + resolve(); + } else { + window.addEventListener('load', resolve); + } + }); +} + +function waitForPaints() { + return new Promise(function(resolve, reject) { + waitForAllPaintsFlushed(resolve); + }); +} + +promise_test(function(t) { + var div = addDiv(t); + var cs = window.getComputedStyle(div); + + // Test that empty animations actually start. + // + // Normally we tie the start of animations to when their first frame of + // the animation is rendered. However, for animations that don't actually + // trigger a paint (e.g. because they are empty, or are animating something + // that doesn't render or is offscreen) we want to make sure they still + // start. + // + // Before we start, wait for the document to finish loading. This is because + // during loading we will have other paint events taking place which might, + // by luck, happen to trigger animations that otherwise would not have been + // triggered, leading to false positives. + // + // As a result, it's better to wait until we have a more stable state before + // continuing. + var promiseCallbackDone = false; + return waitForDocLoad().then(function() { + div.style.animation = 'empty 1000s'; + var animation = div.getAnimations()[0]; + + return animation.ready.then(function() { + promiseCallbackDone = true; + }).catch(function() { + assert_unreached('ready promise was rejected'); + }); + }).then(function() { + // We need to wait for up to three frames. This is because in some + // cases it can take up to two frames for the initial layout + // to take place. Even after that happens we don't actually resolve the + // ready promise until the following tick. + return waitForAnimationFrames(3); + }).then(function() { + assert_true(promiseCallbackDone, + 'ready promise for an empty animation was resolved' + + ' within three animation frames'); + }); +}, 'Animation.ready is resolved for an empty animation'); + +// Test that compositor animations with delays get synced correctly +// +// NOTE: It is important that we DON'T use +// SpecialPowers.DOMWindowUtils.advanceTimeAndRefresh here since that takes +// us through a different code path. +promise_test(function(t) { + // This test only applies to compositor animations + if (!isOMTAEnabled()) { + return; + } + + // Setup animation + var div = addDiv(t); + div.classList.add('target'); + div.style.animation = 'animTransform 100s -50s forwards'; + var animation = div.getAnimations()[0]; + + return waitForPaints(function() { + var transformStr = + SpecialPowers.DOMWindowUtils.getOMTAStyle(div, 'transform'); + + var matrixComponents = + transformStr.startsWith('matrix(') + ? transformStr.substring('matrix('.length, transformStr.length-1) + .split(',') + .map(component => Number(component)) + : []; + assert_equals(matrixComponents.length, 6, + 'Got a valid transform matrix on the compositor' + + ' (got: "' + transformStr + '")'); + + // If the delay has been applied correctly we should be at least + // half-way through the animation + assert_true(matrixComponents[4] >= 50, + 'Animation is at least half-way through on the compositor' + + ' (got translation of ' + matrixComponents[4] + ')'); + }); +}, 'Starting an animation with a delay starts from the correct point'); + +done(); +</script> +</body> diff --git a/dom/animation/test/mozilla/file_disable_animations_api_core.html b/dom/animation/test/mozilla/file_disable_animations_api_core.html new file mode 100644 index 000000000..ef77988d9 --- /dev/null +++ b/dom/animation/test/mozilla/file_disable_animations_api_core.html @@ -0,0 +1,30 @@ +<!doctype html> +<meta charset=utf-8> +<script src="../testcommon.js"></script> +<body> +<script> +'use strict'; + +test(function(t) { + var div = addDiv(t); + var anim = + div.animate({ marginLeft: ['0px', '10px'] }, + { duration: 100 * MS_PER_SEC, + easing: 'linear', + iterations: 10, + iterationComposite: 'accumulate' }); + anim.pause(); + + // NOTE: We can't check iterationComposite value itself though API since + // Animation.effect is also behind the the Web Animations API. So we just + // check that style value is not affected by iterationComposite. + anim.currentTime = 200 * MS_PER_SEC; + assert_equals(getComputedStyle(div).marginLeft, '0px', + 'Animated style should not be accumulated when the Web Animations API is ' + + 'not enabled even if accumulate is specified in the constructor'); +}, 'iterationComposite should not affect at all if the Web Animations API ' + + 'is not enabled'); + +done(); +</script> +</body> diff --git a/dom/animation/test/mozilla/file_disabled_properties.html b/dom/animation/test/mozilla/file_disabled_properties.html new file mode 100644 index 000000000..f1b72973f --- /dev/null +++ b/dom/animation/test/mozilla/file_disabled_properties.html @@ -0,0 +1,73 @@ +<!doctype html> +<meta charset=utf-8> +<script src="../testcommon.js"></script> +<body> +<script> +'use strict'; + +function waitForSetPref(pref, value) { + return new Promise(function(resolve, reject) { + SpecialPowers.pushPrefEnv({ 'set': [[pref, value]] }, resolve); + }); +} + +/* + * These tests rely on the fact that the -webkit-text-fill-color property + * is disabled by the layout.css.prefixes.webkit pref. If we ever remove that + * pref we will need to substitute some other pref:property combination. + */ + +promise_test(function(t) { + return waitForSetPref('layout.css.prefixes.webkit', true).then(() => { + var anim = addDiv(t).animate({ webkitTextFillColor: [ 'green', 'blue' ]}); + assert_equals(anim.effect.getKeyframes().length, 2, + 'A property-indexed keyframe specifying only enabled' + + ' properties produces keyframes'); + return waitForSetPref('layout.css.prefixes.webkit', false); + }).then(() => { + var anim = addDiv(t).animate({ webkitTextFillColor: [ 'green', 'blue' ]}); + assert_equals(anim.effect.getKeyframes().length, 0, + 'A property-indexed keyframe specifying only disabled' + + ' properties produces no keyframes'); + }); +}, 'Specifying a disabled property using a property-indexed keyframe'); + +promise_test(function(t) { + var createAnim = () => { + var anim = addDiv(t).animate([ { webkitTextFillColor: 'green' }, + { webkitTextFillColor: 'blue' } ]); + assert_equals(anim.effect.getKeyframes().length, 2, + 'Animation specified using a keyframe sequence should' + + ' return the same number of keyframes regardless of' + + ' whether or not the specified properties are disabled'); + return anim; + }; + + var assert_has_property = (anim, index, descr, property) => { + assert_true( + anim.effect.getKeyframes()[index].hasOwnProperty(property), + `${descr} should have the '${property}' property`); + }; + var assert_does_not_have_property = (anim, index, descr, property) => { + assert_false( + anim.effect.getKeyframes()[index].hasOwnProperty(property), + `${descr} should NOT have the '${property}' property`); + }; + + return waitForSetPref('layout.css.prefixes.webkit', true).then(() => { + var anim = createAnim(); + assert_has_property(anim, 0, 'Initial keyframe', 'webkitTextFillColor'); + assert_has_property(anim, 1, 'Final keyframe', 'webkitTextFillColor'); + return waitForSetPref('layout.css.prefixes.webkit', false); + }).then(() => { + var anim = createAnim(); + assert_does_not_have_property(anim, 0, 'Initial keyframe', + 'webkitTextFillColor'); + assert_does_not_have_property(anim, 1, 'Final keyframe', + 'webkitTextFillColor'); + }); +}, 'Specifying a disabled property using a keyframe sequence'); + +done(); +</script> +</body> diff --git a/dom/animation/test/mozilla/file_discrete-animations.html b/dom/animation/test/mozilla/file_discrete-animations.html new file mode 100644 index 000000000..35e818a90 --- /dev/null +++ b/dom/animation/test/mozilla/file_discrete-animations.html @@ -0,0 +1,170 @@ +<!doctype html> +<head> +<meta charset=utf-8> +<title>Test Mozilla-specific discrete animatable properties</title> +<script type="application/javascript" src="../testcommon.js"></script> +</head> +<body> +<script> +"use strict"; + +const gMozillaSpecificProperties = { + "-moz-appearance": { + // https://drafts.csswg.org/css-align/#propdef-align-content + from: "button", + to: "none" + }, + "-moz-border-bottom-colors": { + from: "rgb(255, 0, 0) rgb(255, 0, 0) rgb(255, 0, 0) rgb(255, 0, 0)", + to: "rgb(0, 255, 0) rgb(0, 255, 0) rgb(0, 255, 0) rgb(0, 255, 0)" + }, + "-moz-border-left-colors": { + from: "rgb(255, 0, 0) rgb(255, 0, 0) rgb(255, 0, 0) rgb(255, 0, 0)", + to: "rgb(0, 255, 0) rgb(0, 255, 0) rgb(0, 255, 0) rgb(0, 255, 0)" + }, + "-moz-border-right-colors": { + from: "rgb(255, 0, 0) rgb(255, 0, 0) rgb(255, 0, 0) rgb(255, 0, 0)", + to: "rgb(0, 255, 0) rgb(0, 255, 0) rgb(0, 255, 0) rgb(0, 255, 0)" + }, + "-moz-border-top-colors": { + from: "rgb(255, 0, 0) rgb(255, 0, 0) rgb(255, 0, 0) rgb(255, 0, 0)", + to: "rgb(0, 255, 0) rgb(0, 255, 0) rgb(0, 255, 0) rgb(0, 255, 0)" + }, + "-moz-box-align": { + // https://developer.mozilla.org/en/docs/Web/CSS/box-align + from: "center", + to: "stretch" + }, + "-moz-box-direction": { + // https://developer.mozilla.org/en/docs/Web/CSS/box-direction + from: "reverse", + to: "normal" + }, + "-moz-box-ordinal-group": { + // https://developer.mozilla.org/en/docs/Web/CSS/box-ordinal-group + from: "1", + to: "5" + }, + "-moz-box-orient": { + // https://www.w3.org/TR/css-flexbox-1/ + from: "horizontal", + to: "vertical" + }, + "-moz-box-pack": { + // https://www.w3.org/TR/2009/WD-css3-flexbox-20090723/#propdef-box-pack + from: "center", + to: "end" + }, + "-moz-float-edge": { + // https://developer.mozilla.org/en/docs/Web/CSS/-moz-float-edge + from: "margin-box", + to: "content-box" + }, + "-moz-force-broken-image-icon": { + // https://developer.mozilla.org/en/docs/Web/CSS/-moz-force-broken-image-icon + from: "1", + to: "5" + }, + "image-rendering": { + // https://drafts.csswg.org/css-images-3/#propdef-image-rendering + from: "-moz-crisp-edges", + to: "auto" + }, + "-moz-stack-sizing": { + // https://developer.mozilla.org/en/docs/Web/CSS/-moz-stack-sizing + from: "ignore", + to: "stretch-to-fit" + }, + "-moz-tab-size": { + // https://drafts.csswg.org/css-text-3/#propdef-tab-size + from: "1", + to: "5" + }, + "-moz-text-size-adjust": { + // https://drafts.csswg.org/css-size-adjust/#propdef-text-size-adjust + from: "none", + to: "auto" + }, + "-webkit-text-stroke-width": { + // https://compat.spec.whatwg.org/#propdef--webkit-text-stroke-width + from: "10px", + to: "50px" + } +} + +for (let property in gMozillaSpecificProperties) { + const testData = gMozillaSpecificProperties[property]; + const from = testData.from; + const to = testData.to; + const idlName = propertyToIDL(property); + const keyframes = {}; + keyframes[idlName] = [from, to]; + + test(t => { + const div = addDiv(t); + const animation = div.animate(keyframes, + { duration: 1000, fill: "both" }); + testAnimationSamples(animation, idlName, + [{ time: 0, expected: from.toLowerCase() }, + { time: 499, expected: from.toLowerCase() }, + { time: 500, expected: to.toLowerCase() }, + { time: 1000, expected: to.toLowerCase() }]); + }, property + " should animate between '" + + from + "' and '" + to + "' with linear easing"); + + test(function(t) { + // Easing: http://cubic-bezier.com/#.68,0,1,.01 + // With this curve, we don't reach the 50% point until about 95% of + // the time has expired. + const div = addDiv(t); + const animation = div.animate(keyframes, + { duration: 1000, fill: "both", + easing: "cubic-bezier(0.68,0,1,0.01)" }); + testAnimationSamples(animation, idlName, + [{ time: 0, expected: from.toLowerCase() }, + { time: 940, expected: from.toLowerCase() }, + { time: 960, expected: to.toLowerCase() }]); + }, property + " should animate between '" + + from + "' and '" + to + "' with effect easing"); + + test(function(t) { + // Easing: http://cubic-bezier.com/#.68,0,1,.01 + // With this curve, we don't reach the 50% point until about 95% of + // the time has expired. + keyframes.easing = "cubic-bezier(0.68,0,1,0.01)"; + const div = addDiv(t); + const animation = div.animate(keyframes, + { duration: 1000, fill: "both" }); + testAnimationSamples(animation, idlName, + [{ time: 0, expected: from.toLowerCase() }, + { time: 940, expected: from.toLowerCase() }, + { time: 960, expected: to.toLowerCase() }]); + }, property + " should animate between '" + + from + "' and '" + to + "' with keyframe easing"); +} + +function propertyToIDL(property) { + var prefixMatch = property.match(/^-(\w+)-/); + if (prefixMatch) { + var prefix = prefixMatch[1] === "moz" ? "Moz" : prefixMatch[1]; + property = prefix + property.substring(prefixMatch[0].length - 1); + } + // https://drafts.csswg.org/cssom/#css-property-to-idl-attribute + return property.replace(/-([a-z])/gi, function(str, group) { + return group.toUpperCase(); + }); +} + +function testAnimationSamples(animation, idlName, testSamples) { + const target = animation.effect.target; + testSamples.forEach(testSample => { + animation.currentTime = testSample.time; + assert_equals(getComputedStyle(target)[idlName], testSample.expected, + "The value should be " + testSample.expected + + " at " + testSample.time + "ms"); + }); +} + +done(); +</script> +</body> diff --git a/dom/animation/test/mozilla/file_document-timeline-origin-time-range.html b/dom/animation/test/mozilla/file_document-timeline-origin-time-range.html new file mode 100644 index 000000000..083bf0903 --- /dev/null +++ b/dom/animation/test/mozilla/file_document-timeline-origin-time-range.html @@ -0,0 +1,30 @@ +<!doctype html> +<meta charset=utf-8> +<script src="../testcommon.js"></script> +<body> +<script> +'use strict'; + +// If the originTime parameter passed to the DocumentTimeline exceeds +// the range of the internal storage type (a signed 64-bit integer number +// of ticks--a platform-dependent unit) then we should throw. +// Infinity isn't allowed as an origin time value and clamping to just +// inside the allowed range will just mean we overflow elsewhere. + +test(function(t) { + assert_throws({ name: 'TypeError'}, + function() { + new DocumentTimeline({ originTime: Number.MAX_SAFE_INTEGER }); + }); +}, 'Calculated current time is positive infinity'); + +test(function(t) { + assert_throws({ name: 'TypeError'}, + function() { + new DocumentTimeline({ originTime: -1 * Number.MAX_SAFE_INTEGER }); + }); +}, 'Calculated current time is negative infinity'); + +done(); +</script> +</body> diff --git a/dom/animation/test/mozilla/file_hide_and_show.html b/dom/animation/test/mozilla/file_hide_and_show.html new file mode 100644 index 000000000..0771fcce1 --- /dev/null +++ b/dom/animation/test/mozilla/file_hide_and_show.html @@ -0,0 +1,162 @@ +<!doctype html> +<meta charset=utf-8> +<script src="../testcommon.js"></script> +<style> +@keyframes move { + 100% { + transform: translateX(100px); + } +} + +</style> +<body> +<script> +'use strict'; + +test(function(t) { + var div = addDiv(t, { style: 'animation: move 100s infinite' }); + assert_equals(div.getAnimations().length, 1, + 'display:initial element has animations'); + + div.style.display = 'none'; + assert_equals(div.getAnimations().length, 0, + 'display:none element has no animations'); +}, 'Animation stops playing when the element style display is set to "none"'); + +test(function(t) { + var parentElement = addDiv(t); + var div = addDiv(t, { style: 'animation: move 100s infinite' }); + parentElement.appendChild(div); + assert_equals(div.getAnimations().length, 1, + 'display:initial element has animations'); + + parentElement.style.display = 'none'; + assert_equals(div.getAnimations().length, 0, + 'Element in display:none subtree has no animations'); +}, 'Animation stops playing when its parent element style display is set ' + + 'to "none"'); + +test(function(t) { + var div = addDiv(t, { style: 'animation: move 100s infinite' }); + assert_equals(div.getAnimations().length, 1, + 'display:initial element has animations'); + + div.style.display = 'none'; + assert_equals(div.getAnimations().length, 0, + 'display:none element has no animations'); + + div.style.display = ''; + assert_equals(div.getAnimations().length, 1, + 'Element which is no longer display:none has animations ' + + 'again'); +}, 'Animation starts playing when the element gets shown from ' + + '"display:none" state'); + +test(function(t) { + var parentElement = addDiv(t); + var div = addDiv(t, { style: 'animation: move 100s infinite' }); + parentElement.appendChild(div); + assert_equals(div.getAnimations().length, 1, + 'display:initial element has animations'); + + parentElement.style.display = 'none'; + assert_equals(div.getAnimations().length, 0, + 'Element in display:none subtree has no animations'); + + parentElement.style.display = ''; + assert_equals(div.getAnimations().length, 1, + 'Element which is no longer in display:none subtree has ' + + 'animations again'); +}, 'Animation starts playing when its parent element is shown from ' + + '"display:none" state'); + +test(function(t) { + var div = addDiv(t, { style: 'animation: move 100s forwards' }); + assert_equals(div.getAnimations().length, 1, + 'display:initial element has animations'); + + var animation = div.getAnimations()[0]; + animation.finish(); + assert_equals(div.getAnimations().length, 1, + 'Element has finished animation if the animation ' + + 'fill-mode is forwards'); + + div.style.display = 'none'; + assert_equals(animation.playState, 'idle', + 'The animation.playState should be idle'); + + assert_equals(div.getAnimations().length, 0, + 'display:none element has no animations'); + + div.style.display = ''; + assert_equals(div.getAnimations().length, 1, + 'Element which is no longer display:none has animations ' + + 'again'); + assert_not_equals(div.getAnimations()[0], animation, + 'Restarted animation is a newly-generated animation'); + +}, 'Animation which has already finished starts playing when the element ' + + 'gets shown from "display:none" state'); + +test(function(t) { + var parentElement = addDiv(t); + var div = addDiv(t, { style: 'animation: move 100s forwards' }); + parentElement.appendChild(div); + assert_equals(div.getAnimations().length, 1, + 'display:initial element has animations'); + + var animation = div.getAnimations()[0]; + animation.finish(); + assert_equals(div.getAnimations().length, 1, + 'Element has finished animation if the animation ' + + 'fill-mode is forwards'); + + parentElement.style.display = 'none'; + assert_equals(animation.playState, 'idle', + 'The animation.playState should be idle'); + assert_equals(div.getAnimations().length, 0, + 'Element in display:none subtree has no animations'); + + parentElement.style.display = ''; + assert_equals(div.getAnimations().length, 1, + 'Element which is no longer in display:none subtree has ' + + 'animations again'); + + assert_not_equals(div.getAnimations()[0], animation, + 'Restarted animation is a newly-generated animation'); + +}, 'Animation with fill:forwards which has already finished starts playing ' + + 'when its parent element is shown from "display:none" state'); + +test(function(t) { + var parentElement = addDiv(t); + var div = addDiv(t, { style: 'animation: move 100s' }); + parentElement.appendChild(div); + assert_equals(div.getAnimations().length, 1, + 'display:initial element has animations'); + + var animation = div.getAnimations()[0]; + animation.finish(); + assert_equals(div.getAnimations().length, 0, + 'Element does not have finished animations'); + + parentElement.style.display = 'none'; + assert_equals(animation.playState, 'idle', + 'The animation.playState should be idle'); + assert_equals(div.getAnimations().length, 0, + 'Element in display:none subtree has no animations'); + + parentElement.style.display = ''; + assert_equals(div.getAnimations().length, 1, + 'Element which is no longer in display:none subtree has ' + + 'animations again'); + + assert_not_equals(div.getAnimations()[0], animation, + 'Restarted animation is a newly-generated animation'); + +}, 'CSS Animation which has already finished starts playing when its parent ' + + 'element is shown from "display:none" state'); + +done(); +</script> +</body> diff --git a/dom/animation/test/mozilla/file_partial_keyframes.html b/dom/animation/test/mozilla/file_partial_keyframes.html new file mode 100644 index 000000000..68832be7a --- /dev/null +++ b/dom/animation/test/mozilla/file_partial_keyframes.html @@ -0,0 +1,41 @@ +<!doctype html> +<meta charset=utf-8> +<script src="../testcommon.js"></script> +<body> +<script> +'use strict'; + +// Tests for cases we currently don't handle and should throw an exception for. +var gTests = [ + { desc: "single Keyframe with no offset", + keyframes: [{ left: "100px" }] }, + { desc: "multiple Keyframes with missing 0% Keyframe", + keyframes: [{ left: "100px", offset: 0.25 }, + { left: "200px", offset: 0.50 }, + { left: "300px", offset: 1.00 }] }, + { desc: "multiple Keyframes with missing 100% Keyframe", + keyframes: [{ left: "100px", offset: 0.00 }, + { left: "200px", offset: 0.50 }, + { left: "300px", offset: 0.75 }] }, + { desc: "multiple Keyframes with missing properties on first Keyframe", + keyframes: [{ left: "100px", offset: 0.0 }, + { left: "200px", top: "200px", offset: 0.5 }, + { left: "300px", top: "300px", offset: 1.0 }] }, + { desc: "multiple Keyframes with missing properties on last Keyframe", + keyframes: [{ left: "100px", top: "200px", offset: 0.0 }, + { left: "200px", top: "200px", offset: 0.5 }, + { left: "300px", offset: 1.0 }] }, +]; + +gTests.forEach(function(subtest) { + test(function(t) { + var div = addDiv(t); + assert_throws("NotSupportedError", function() { + new KeyframeEffectReadOnly(div, subtest.keyframes); + }); + }, "KeyframeEffectReadOnly constructor throws with " + subtest.desc); +}); + +done(); +</script> +</body> diff --git a/dom/animation/test/mozilla/file_set-easing.html b/dom/animation/test/mozilla/file_set-easing.html new file mode 100644 index 000000000..072b125cb --- /dev/null +++ b/dom/animation/test/mozilla/file_set-easing.html @@ -0,0 +1,34 @@ +<!doctype html> +<head> +<meta charset=utf-8> +<title>Test setting easing in sandbox</title> +<script src="../testcommon.js"></script> +</head> +<body> +<script> +"use strict"; + +test(function(t) { + const div = document.createElement("div"); + document.body.appendChild(div); + div.animate({ opacity: [0, 1] }, 100000 ); + + const contentScript = function() { + try { + document.getAnimations()[0].effect.timing.easing = "linear"; + assert_true(true, 'Setting easing should not throw in sandbox'); + } catch (e) { + assert_unreached('Setting easing threw ' + e); + } + }; + + const sandbox = new SpecialPowers.Cu.Sandbox(window); + sandbox.importFunction(document, "document"); + sandbox.importFunction(assert_true, "assert_true"); + sandbox.importFunction(assert_unreached, "assert_unreached"); + SpecialPowers.Cu.evalInSandbox(`(${contentScript.toSource()})()`, sandbox); +}, 'Setting easing should not throw any exceptions in sandbox'); + +done(); +</script> +</body> diff --git a/dom/animation/test/mozilla/file_spacing_property_order.html b/dom/animation/test/mozilla/file_spacing_property_order.html new file mode 100644 index 000000000..1338d6081 --- /dev/null +++ b/dom/animation/test/mozilla/file_spacing_property_order.html @@ -0,0 +1,33 @@ +<!doctype html> +<meta charset=utf-8> +<script src="../testcommon.js"></script> +<body> +<script> +'use strict'; + +test(function(t) { + var div = document.createElement('div'); + document.documentElement.appendChild(div); + var anim = div.animate([ { borderRadius: "0", borderTopRightRadius: "10%" }, + { borderTopLeftRadius: "20%", + borderTopRightRadius: "30%", + borderBottomRightRadius: "40%", + borderBottomLeftRadius: "50%" }, + { borderRadius: "50%" } ], + { spacing:"paced(border-radius)" }); + + var frames = anim.effect.getKeyframes(); + var dist = [ 0, + Math.sqrt(20 * 20 + (30 - 10) * (30 - 10) + 40 * 40 + 50 * 50), + Math.sqrt((50 - 20) * (50 - 20) + (50 - 30) * (50 - 30) + + (50 - 40) * (50 - 40) + (50 - 50) * (50 - 50)) ]; + var cumDist = []; + dist.reduce(function(prev, curr, i) { return cumDist[i] = prev + curr; }, 0); + assert_approx_equals(frames[1].computedOffset, cumDist[1] / cumDist[2], + 0.0001, 'frame offset'); +}, 'Test for the longhand components of the shorthand property surely sorted' ); + +done(); + +</script> +</body> diff --git a/dom/animation/test/mozilla/file_spacing_transform.html b/dom/animation/test/mozilla/file_spacing_transform.html new file mode 100644 index 000000000..0de773786 --- /dev/null +++ b/dom/animation/test/mozilla/file_spacing_transform.html @@ -0,0 +1,240 @@ +<!doctype html> +<meta charset=utf-8> +<script src="../testcommon.js"></script> +<body> +<script> +'use strict'; + +const pi = Math.PI; +const cos = Math.cos; +const sin = Math.sin; +const tan = Math.tan; +const sqrt = Math.sqrt; + +// Help function for testing the computed offsets by the distance array. +function assert_animation_offsets(anim, dist) { + const epsilon = 0.00000001; + const frames = anim.effect.getKeyframes(); + const cumDist = dist.reduce( (prev, curr) => { + prev.push(prev.length == 0 ? curr : curr + prev[prev.length - 1]); + return prev; + }, []); + + const total = cumDist[cumDist.length - 1]; + for (var i = 0; i < frames.length; ++i) { + assert_approx_equals(frames[i].computedOffset, cumDist[i] / total, + epsilon, 'computedOffset of frame ' + i); + } +} + +function getAngleDist(rotate1, rotate2) { + function quaternion(axis, angle) { + var x = axis[0] * sin(angle/2.0); + var y = axis[1] * sin(angle/2.0); + var z = axis[2] * sin(angle/2.0); + var w = cos(angle/2.0); + return { 'x': x, 'y': y, 'z': z, 'w': w }; + } + var q1 = quaternion(rotate1.axis, rotate1.angle); + var q2 = quaternion(rotate2.axis, rotate2.angle); + var dotProduct = q1.x * q2.x + q1.y * q2.y + q1.z * q2.z + q1.w * q2.w; + return 2.0 * Math.acos(dotProduct); +} + +function createMatrix(elements, Is3D) { + return (Is3D ? "matrix3d" : "matrix") + "(" + elements.join() + ")"; +} + +test(function(t) { + var anim = addDiv(t).animate([ { transform: "none" }, + { transform: "translate(-20px)" }, + { transform: "translate(100px)" }, + { transform: "translate(50px)"} ], + { spacing: "paced(transform)" }); + assert_animation_offsets(anim, [ 0, 20, 120, 50 ]); +}, 'Test spacing on translate' ); + +test(function(t) { + var anim = + addDiv(t).animate([ { transform: "none" }, + { transform: "translate3d(-20px, 10px, 100px)" }, + { transform: "translate3d(100px, 200px, 50px)" }, + { transform: "translate(50px, -10px)"} ], + { spacing: "paced(transform)" }); + var dist = [ 0, + Math.sqrt(20 * 20 + 10 * 10 + 100 * 100), + Math.sqrt(120 * 120 + 190 * 190 + 50 * 50), + Math.sqrt(50 * 50 + 210 * 210 + 50 * 50) ]; + assert_animation_offsets(anim, dist); +}, 'Test spacing on translate3d' ); + +test(function(t) { + var anim = addDiv(t).animate([ { transform: "scale(0.5)" }, + { transform: "scale(4.5)" }, + { transform: "scale(2.5)" }, + { transform: "none"} ], + { spacing: "paced(transform)" }); + assert_animation_offsets(anim, [ 0, 4.0, 2.0, 1.5 ]); +}, 'Test spacing on scale' ); + +test(function(t) { + var anim = addDiv(t).animate([ { transform: "scale(0.5, 0.5)" }, + { transform: "scale3d(4.5, 5.0, 2.5)" }, + { transform: "scale3d(2.5, 1.0, 2.0)" }, + { transform: "scale3d(1, 0.5, 1.0)"} ], + { spacing:"paced(transform)" }); + var dist = [ 0, + Math.sqrt(4.0 * 4.0 + 4.5 * 4.5 + 1.5 * 1.5), + Math.sqrt(2.0 * 2.0 + 4.0 * 4.0 + 0.5 * 0.5), + Math.sqrt(1.5 * 1.5 + 0.5 * 0.5 + 1.0 * 1.0) ]; + assert_animation_offsets(anim, dist); +}, 'Test spacing on scale3d' ); + +test(function(t) { + var anim = addDiv(t).animate([ { transform: "rotate(60deg)" }, + { transform: "none" }, + { transform: "rotate(720deg)" }, + { transform: "rotate(-360deg)"} ], + { spacing: "paced(transform)" }); + assert_animation_offsets(anim, [ 0, 60, 720, 1080 ]); +}, 'Test spacing on rotate' ); + +test(function(t) { + var anim = addDiv(t).animate([ { transform: "rotate3d(1,0,0,60deg)" }, + { transform: "rotate3d(1,0,0,70deg)" }, + { transform: "rotate3d(0,0,1,-110deg)" }, + { transform: "rotate3d(1,0,0,219deg)"} ], + { spacing: "paced(transform)" }); + var dist = [ 0, + getAngleDist({ axis: [1,0,0], angle: 60 * pi / 180 }, + { axis: [1,0,0], angle: 70 * pi / 180 }), + getAngleDist({ axis: [0,1,0], angle: 70 * pi / 180 }, + { axis: [0,0,1], angle: -110 * pi / 180 }), + getAngleDist({ axis: [0,0,1], angle: -110 * pi / 180 }, + { axis: [1,0,0], angle: 219 * pi / 180 }) ]; + assert_animation_offsets(anim, dist); +}, 'Test spacing on rotate3d' ); + +test(function(t) { + var anim = addDiv(t).animate([ { transform: "skew(60deg)" }, + { transform: "none" }, + { transform: "skew(-90deg)" }, + { transform: "skew(90deg)"} ], + { spacing: "paced(transform)" }); + assert_animation_offsets(anim, [ 0, 60, 90, 180 ]); +}, 'Test spacing on skew' ); + +test(function(t) { + var anim = addDiv(t).animate([ { transform: "skew(60deg, 30deg)" }, + { transform: "none" }, + { transform: "skew(-90deg, 60deg)" }, + { transform: "skew(90deg, 60deg)"} ], + { spacing: "paced(transform)" }); + var dist = [ 0, + sqrt(60 * 60 + 30 * 30), + sqrt(90 * 90 + 60 * 60), + sqrt(180 * 180 + 0) ]; + assert_animation_offsets(anim, dist); +}, 'Test spacing on skew along both X and Y' ); + +test(function(t) { + // We calculate the distance of two perspective functions by converting them + // into two matrix3ds, and then do matrix decomposition to get two + // perspective vectors, so the equivalent perspective vectors are: + // perspective 1: (0, 0, -1/128, 1); + // perspective 2: (0, 0, -1/infinity = 0, 1); + // perspective 3: (0, 0, -1/1024, 1); + // perspective 4: (0, 0, -1/32, 1); + var anim = addDiv(t).animate([ { transform: "perspective(128px)" }, + { transform: "none" }, + { transform: "perspective(1024px)" }, + { transform: "perspective(32px)"} ], + { spacing: "paced(transform)" }); + assert_animation_offsets(anim, + [ 0, 1/128, 1/1024, 1/32 - 1/1024 ]); +}, 'Test spacing on perspective' ); + +test(function(t) { + var anim = + addDiv(t).animate([ { transform: "none" }, + { transform: "rotate(180deg) translate(0px)" }, + { transform: "rotate(180deg) translate(1000px)" }, + { transform: "rotate(360deg) translate(1000px)"} ], + { spacing: "paced(transform)" }); + var dist = [ 0, + sqrt(pi * pi + 0), + sqrt(1000 * 1000), + sqrt(pi * pi + 0) ]; + assert_animation_offsets(anim, dist); +}, 'Test spacing on matched transform lists' ); + +test(function(t) { + // matrix1 => translate(100px, 50px), skewX(60deg). + // matrix2 => translate(1000px), rotate(180deg). + // matrix3 => translate(1000px), scale(1.5, 0.7). + const matrix1 = createMatrix([ 1, 0, tan(pi/4.0), 1, 100, 50 ]); + const matrix2 = createMatrix([ cos(pi), sin(pi), + -sin(pi), cos(pi), + 1000, 0 ]); + const matrix3 = createMatrix([ 1.5, 0, 0, 0.7, 1000, 0 ]); + var anim = addDiv(t).animate([ { transform: "none" }, + { transform: matrix1 }, + { transform: matrix2 }, + { transform: matrix3 } ], + { spacing: "paced(transform)" }); + var dist = [ 0, + sqrt(100 * 100 + 50 * 50 + pi/4 * pi/4), + sqrt(900 * 900 + 50 * 50 + pi * pi + pi/4 * pi/4), + sqrt(pi * pi + 0.5 * 0.5 + 0.3 * 0.3) ]; + assert_animation_offsets(anim, dist); +}, 'Test spacing on matrix' ); + +test(function(t) { + // matrix1 => translate3d(100px, 50px, -10px), skew(60deg). + // matrix2 => translate3d(1000px, 0, 0), rotate3d(1, 0, 0, 180deg). + // matrix3 => translate3d(1000px, 0, 0), scale3d(1.5, 0.7, 2.2). + const matrix1 = createMatrix([ 1, 0, 0, 0, + tan(pi/4.0), 1, 0, 0, + 0, 0, 1, 0, + 100, 50, -10, 1 ], true); + const matrix2 = createMatrix([ 1, 0, 0, 0, + 0, cos(pi), sin(pi), 0, + 0, -sin(pi), cos(pi), 0, + 1000, 0, 0, 1 ], true); + const matrix3 = createMatrix([ 1.5, 0, 0, 0, + 0, 0.7, 0, 0, + 0, 0, 2.2, 0, + 1000, 0, 0, 1 ], true); + var anim = addDiv(t).animate([ { transform: "none" }, + { transform: matrix1 }, + { transform: matrix2 }, + { transform: matrix3 } ], + { spacing: "paced(transform)" }); + var dist = [ 0, + sqrt(100 * 100 + 50 * 50 + 10 * 10 + pi/4 * pi/4), + sqrt(900 * 900 + 50 * 50 + 10 * 10 + pi/4 * pi/4 + pi * pi), + sqrt(0.5 * 0.5 + 0.3 * 0.3 + 1.2 * 1.2 + pi * pi) ]; + assert_animation_offsets(anim, dist); +}, 'Test spacing on matrix3d' ); + +test(function(t) { + var anim = + addDiv(t).animate([ { transform: "none" }, + { transform: "translate(100px, 50px) skew(45deg)" }, + { transform: "translate(1000px) " + + "rotate3d(1, 0, 0, 180deg)" }, + { transform: "translate(1000px) " + + "scale3d(2.5, 0.5, 0.7)" } ], + { spacing: "paced(transform)" }); + + var dist = [ 0, + sqrt(100 * 100 + 50 * 50 + pi/4 * pi/4), + sqrt(900 * 900 + 50 * 50 + pi/4 * pi/4 + pi * pi), + sqrt(1.5 * 1.5 + 0.5 * 0.5 + 0.3 * 0.3 + pi * pi) ]; + assert_animation_offsets(anim, dist); +}, 'Test spacing on mismatched transform list' ); + +done(); + +</script> +</body> diff --git a/dom/animation/test/mozilla/file_transform_limits.html b/dom/animation/test/mozilla/file_transform_limits.html new file mode 100644 index 000000000..d4c813c67 --- /dev/null +++ b/dom/animation/test/mozilla/file_transform_limits.html @@ -0,0 +1,55 @@ +<!doctype html> +<meta charset=utf-8> +<script src="../testcommon.js"></script> +<body> +<script> +'use strict'; + +// We clamp +infinity or -inifinity value in floating point to +// maximum floating point value or -maximum floating point value. +const max_float = 3.40282e+38; + +test(function(t) { + var div = addDiv(t); + div.style = "width: 1px; height: 1px;"; + var anim = div.animate([ { transform: 'scale(1)' }, + { transform: 'scale(3.5e+38)'}, + { transform: 'scale(3)' } ], 100 * MS_PER_SEC); + + anim.pause(); + anim.currentTime = 50 * MS_PER_SEC; + assert_equals(getComputedStyle(div).transform, + 'matrix(' + max_float + ', 0, 0, ' + max_float + ', 0, 0)'); +}, 'Test that the parameter of transform scale is clamped' ); + +test(function(t) { + var div = addDiv(t); + div.style = "width: 1px; height: 1px;"; + var anim = div.animate([ { transform: 'translate(1px)' }, + { transform: 'translate(3.5e+38px)'}, + { transform: 'translate(3px)' } ], 100 * MS_PER_SEC); + + anim.pause(); + anim.currentTime = 50 * MS_PER_SEC; + assert_equals(getComputedStyle(div).transform, + 'matrix(1, 0, 0, 1, ' + max_float + ', 0)'); +}, 'Test that the parameter of transform translate is clamped' ); + +test(function(t) { + var div = addDiv(t); + div.style = "width: 1px; height: 1px;"; + var anim = div.animate([ { transform: 'matrix(0.5, 0, 0, 0.5, 0, 0)' }, + { transform: 'matrix(2, 0, 0, 2, 3.5e+38, 0)'}, + { transform: 'matrix(0, 2, 0, -2, 0, 0)' } ], + 100 * MS_PER_SEC); + + anim.pause(); + anim.currentTime = 50 * MS_PER_SEC; + assert_equals(getComputedStyle(div).transform, + 'matrix(2, 0, 0, 2, ' + max_float + ', 0)'); +}, 'Test that the parameter of transform matrix is clamped' ); + +done(); + +</script> +</body> diff --git a/dom/animation/test/mozilla/file_transition_finish_on_compositor.html b/dom/animation/test/mozilla/file_transition_finish_on_compositor.html new file mode 100644 index 000000000..4912d05dd --- /dev/null +++ b/dom/animation/test/mozilla/file_transition_finish_on_compositor.html @@ -0,0 +1,67 @@ +<!doctype html> +<meta charset=utf-8> +<script src="../testcommon.js"></script> +<script src="/tests/SimpleTest/paint_listener.js"></script> +<style> +div { + /* Element needs geometry to be eligible for layerization */ + width: 100px; + height: 100px; + background-color: white; +} +</style> +<body> +<script> +'use strict'; + +function waitForPaints() { + return new Promise(function(resolve, reject) { + waitForAllPaintsFlushed(resolve); + }); +} + +promise_test(t => { + // This test only applies to compositor animations + if (!isOMTAEnabled()) { + return; + } + + var div = addDiv(t, { style: 'transition: transform 50ms; ' + + 'transform: translateX(0px)' }); + getComputedStyle(div).transform; + + div.style.transform = 'translateX(100px)'; + + var timeBeforeStart = window.performance.now(); + return waitForPaints().then(() => { + // If it took over 50ms to paint the transition, we have no luck + // to test it. This situation will happen if GC runs while waiting for the + // paint. + if (window.performance.now() - timeBeforeStart >= 50) { + return; + } + + var transform = + SpecialPowers.DOMWindowUtils.getOMTAStyle(div, 'transform'); + assert_not_equals(transform, '', + 'The transition style is applied on the compositor'); + + // Generate artificial busyness on the main thread for 100ms. + var timeAtStart = window.performance.now(); + while (window.performance.now() - timeAtStart < 100) {} + + // Now the transition on the compositor should finish but stay at the final + // position because there was no chance to pull the transition back from + // the compositor. + transform = + SpecialPowers.DOMWindowUtils.getOMTAStyle(div, 'transform'); + assert_equals(transform, 'matrix(1, 0, 0, 1, 100, 0)', + 'The final transition style is still applied on the ' + + 'compositor'); + }); +}, 'Transition on the compositor keeps the final style while the main thread ' + + 'is busy even if the transition finished on the compositor'); + +done(); +</script> +</body> diff --git a/dom/animation/test/mozilla/file_underlying-discrete-value.html b/dom/animation/test/mozilla/file_underlying-discrete-value.html new file mode 100644 index 000000000..3be01b904 --- /dev/null +++ b/dom/animation/test/mozilla/file_underlying-discrete-value.html @@ -0,0 +1,192 @@ +<!doctype html> +<meta charset=utf-8> +<script src="../testcommon.js"></script> +<body> +<script> +"use strict"; + +// Tests that we correctly extract the underlying value when the animation +// type is 'discrete'. +const discreteTests = [ + { + stylesheet: { + "@keyframes keyframes": + "from { align-content: flex-start; } to { align-content: flex-end; } " + }, + expectedKeyframes: [ + { computedOffset: 0, alignContent: "flex-start" }, + { computedOffset: 1, alignContent: "flex-end" } + ], + explanation: "Test for fully-specified keyframes" + }, + { + stylesheet: { + "@keyframes keyframes": "from { align-content: flex-start; }" + }, + // The value of 100% should be 'stretch', + // but we are not supporting underlying value. + // https://bugzilla.mozilla.org/show_bug.cgi?id=1295401 + expectedKeyframes: [ + { computedOffset: 0, alignContent: "flex-start" }, + { computedOffset: 1, alignContent: "unset" } + ], + explanation: "Test for 0% keyframe only" + }, + { + stylesheet: { + "@keyframes keyframes": "to { align-content: flex-end; }" + }, + // The value of 0% should be 'stretch', + // but we are not supporting underlying value. + // https://bugzilla.mozilla.org/show_bug.cgi?id=1295401 + expectedKeyframes: [ + { computedOffset: 0, alignContent: "unset" }, + { computedOffset: 1, alignContent: "flex-end" } + ], + explanation: "Test for 100% keyframe only" + }, + { + stylesheet: { + "@keyframes keyframes": "50% { align-content: center; }", + "#target": "align-content: space-between;" + }, + expectedKeyframes: [ + { computedOffset: 0, alignContent: "space-between" }, + { computedOffset: 0.5, alignContent: "center" }, + { computedOffset: 1, alignContent: "space-between" } + ], + explanation: "Test for no 0%/100% keyframes " + + "and specified style on target element" + }, + { + stylesheet: { + "@keyframes keyframes": "50% { align-content: center; }" + }, + attributes: { + style: "align-content: space-between" + }, + expectedKeyframes: [ + { computedOffset: 0, alignContent: "space-between" }, + { computedOffset: 0.5, alignContent: "center" }, + { computedOffset: 1, alignContent: "space-between" } + ], + explanation: "Test for no 0%/100% keyframes " + + "and specified style on target element using style attribute" + }, + { + stylesheet: { + "@keyframes keyframes": "50% { align-content: center; }", + "#target": "align-content: inherit;" + }, + // The value of 0%/100% should be 'stretch', + // but we are not supporting underlying value. + // https://bugzilla.mozilla.org/show_bug.cgi?id=1295401 + expectedKeyframes: [ + { computedOffset: 0, alignContent: "inherit" }, + { computedOffset: 0.5, alignContent: "center" }, + { computedOffset: 1, alignContent: "inherit" } + ], + explanation: "Test for no 0%/100% keyframes " + + "and 'inherit' specified on target element" + }, + { + stylesheet: { + "@keyframes keyframes": "50% { align-content: center; }", + ".target": "align-content: space-between;" + }, + attributes: { + class: "target" + }, + expectedKeyframes: [ + { computedOffset: 0, alignContent: "space-between" }, + { computedOffset: 0.5, alignContent: "center" }, + { computedOffset: 1, alignContent: "space-between" } + ], + explanation: "Test for no 0%/100% keyframes " + + "and specified style on target element using class selector" + }, + { + stylesheet: { + "@keyframes keyframes": "50% { align-content: center; }", + "div": "align-content: space-between;" + }, + expectedKeyframes: [ + { computedOffset: 0, alignContent: "space-between" }, + { computedOffset: 0.5, alignContent: "center" }, + { computedOffset: 1, alignContent: "space-between" } + ], + explanation: "Test for no 0%/100% keyframes " + + "and specified style on target element using type selector" + }, + { + stylesheet: { + "@keyframes keyframes": "50% { align-content: center; }", + "div": "align-content: space-between;", + ".target": "align-content: flex-start;", + "#target": "align-content: flex-end;" + }, + attributes: { + class: "target" + }, + expectedKeyframes: [ + { computedOffset: 0, alignContent: "flex-end" }, + { computedOffset: 0.5, alignContent: "center" }, + { computedOffset: 1, alignContent: "flex-end" } + ], + explanation: "Test for no 0%/100% keyframes " + + "and specified style on target element " + + "using ID selector that overrides class selector" + }, + { + stylesheet: { + "@keyframes keyframes": "50% { align-content: center; }", + "div": "align-content: space-between !important;", + ".target": "align-content: flex-start;", + "#target": "align-content: flex-end;" + }, + attributes: { + class: "target" + }, + expectedKeyframes: [ + { computedOffset: 0, alignContent: "space-between" }, + { computedOffset: 0.5, alignContent: "center" }, + { computedOffset: 1, alignContent: "space-between" } + ], + explanation: "Test for no 0%/100% keyframes " + + "and specified style on target element " + + "using important type selector that overrides other rules" + }, +]; + +discreteTests.forEach(testcase => { + test(t => { + addStyle(t, testcase.stylesheet); + + const div = addDiv(t, { "id": "target" }); + if (testcase.attributes) { + for (let attributeName in testcase.attributes) { + div.setAttribute(attributeName, testcase.attributes[attributeName]); + } + } + div.style.animation = "keyframes 100s"; + + const keyframes = div.getAnimations()[0].effect.getKeyframes(); + const expectedKeyframes = testcase.expectedKeyframes; + assert_equals(keyframes.length, expectedKeyframes.length, + `keyframes.length should be ${ expectedKeyframes.length }`); + + keyframes.forEach((keyframe, index) => { + const expectedKeyframe = expectedKeyframes[index]; + assert_equals(keyframe.computedOffset, expectedKeyframe.computedOffset, + `computedOffset of keyframes[${ index }] should be ` + + `${ expectedKeyframe.computedOffset }`); + assert_equals(keyframe.alignContent, expectedKeyframe.alignContent, + `alignContent of keyframes[${ index }] should be ` + + `${ expectedKeyframe.alignContent }`); + }); + }, testcase.explanation); +}); + +done(); +</script> +</body> diff --git a/dom/animation/test/mozilla/test_cubic_bezier_limits.html b/dom/animation/test/mozilla/test_cubic_bezier_limits.html new file mode 100644 index 000000000..e67e5dbbb --- /dev/null +++ b/dom/animation/test/mozilla/test_cubic_bezier_limits.html @@ -0,0 +1,14 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id="log"></div> +<script> +'use strict'; +setup({explicit_done: true}); +SpecialPowers.pushPrefEnv( + { "set": [["dom.animations-api.core.enabled", true]]}, + function() { + window.open("file_cubic_bezier_limits.html"); + }); +</script> diff --git a/dom/animation/test/mozilla/test_deferred_start.html b/dom/animation/test/mozilla/test_deferred_start.html new file mode 100644 index 000000000..4db4bf676 --- /dev/null +++ b/dom/animation/test/mozilla/test_deferred_start.html @@ -0,0 +1,14 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id="log"></div> +<script> +'use strict'; +setup({explicit_done: true}); +SpecialPowers.pushPrefEnv( + { "set": [["dom.animations-api.core.enabled", true]]}, + function() { + window.open("file_deferred_start.html"); + }); +</script> diff --git a/dom/animation/test/mozilla/test_disable_animations_api_core.html b/dom/animation/test/mozilla/test_disable_animations_api_core.html new file mode 100644 index 000000000..cfb64e537 --- /dev/null +++ b/dom/animation/test/mozilla/test_disable_animations_api_core.html @@ -0,0 +1,14 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id="log"></div> +<script> +'use strict'; +setup({explicit_done: true}); +SpecialPowers.pushPrefEnv( + { "set": [["dom.animations-api.core.enabled", false]]}, + function() { + window.open("file_disable_animations_api_core.html"); + }); +</script> diff --git a/dom/animation/test/mozilla/test_disabled_properties.html b/dom/animation/test/mozilla/test_disabled_properties.html new file mode 100644 index 000000000..86d02e6b6 --- /dev/null +++ b/dom/animation/test/mozilla/test_disabled_properties.html @@ -0,0 +1,14 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id="log"></div> +<script> +'use strict'; +setup({explicit_done: true}); +SpecialPowers.pushPrefEnv( + { "set": [["dom.animations-api.core.enabled", true]]}, + function() { + window.open("file_disabled_properties.html"); + }); +</script> diff --git a/dom/animation/test/mozilla/test_discrete-animations.html b/dom/animation/test/mozilla/test_discrete-animations.html new file mode 100644 index 000000000..2a36bd50e --- /dev/null +++ b/dom/animation/test/mozilla/test_discrete-animations.html @@ -0,0 +1,18 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id="log"></div> +<script> +'use strict'; +setup({explicit_done: true}); +SpecialPowers.pushPrefEnv( + { "set": [ + ["dom.animations-api.core.enabled", true], + ["layout.css.osx-font-smoothing.enabled", true], + ["layout.css.prefixes.webkit", true] + ] }, + function() { + window.open("file_discrete-animations.html"); + }); +</script> diff --git a/dom/animation/test/mozilla/test_document-timeline-origin-time-range.html b/dom/animation/test/mozilla/test_document-timeline-origin-time-range.html new file mode 100644 index 000000000..f73c233d3 --- /dev/null +++ b/dom/animation/test/mozilla/test_document-timeline-origin-time-range.html @@ -0,0 +1,14 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id="log"></div> +<script> +'use strict'; +setup({explicit_done: true}); +SpecialPowers.pushPrefEnv( + { "set": [["dom.animations-api.core.enabled", true]]}, + function() { + window.open("file_document-timeline-origin-time-range.html"); + }); +</script> diff --git a/dom/animation/test/mozilla/test_hide_and_show.html b/dom/animation/test/mozilla/test_hide_and_show.html new file mode 100644 index 000000000..929a31bd4 --- /dev/null +++ b/dom/animation/test/mozilla/test_hide_and_show.html @@ -0,0 +1,14 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id="log"></div> +<script> +'use strict'; +setup({explicit_done: true}); +SpecialPowers.pushPrefEnv( + { "set": [["dom.animations-api.core.enabled", true]]}, + function() { + window.open("file_hide_and_show.html"); + }); +</script> diff --git a/dom/animation/test/mozilla/test_partial_keyframes.html b/dom/animation/test/mozilla/test_partial_keyframes.html new file mode 100644 index 000000000..28eb4c588 --- /dev/null +++ b/dom/animation/test/mozilla/test_partial_keyframes.html @@ -0,0 +1,14 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id="log"></div> +<script> +'use strict'; +setup({explicit_done: true}); +SpecialPowers.pushPrefEnv( + { "set": [["dom.animations-api.core.enabled", true]]}, + function() { + window.open("file_partial_keyframes.html"); + }); +</script> diff --git a/dom/animation/test/mozilla/test_set-easing.html b/dom/animation/test/mozilla/test_set-easing.html new file mode 100644 index 000000000..e0069ff1c --- /dev/null +++ b/dom/animation/test/mozilla/test_set-easing.html @@ -0,0 +1,14 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id="log"></div> +<script> +'use strict'; +setup({explicit_done: true}); +SpecialPowers.pushPrefEnv( + { "set": [["dom.animations-api.core.enabled", true]]}, + function() { + window.open("file_set-easing.html"); + }); +</script> diff --git a/dom/animation/test/mozilla/test_spacing_property_order.html b/dom/animation/test/mozilla/test_spacing_property_order.html new file mode 100644 index 000000000..afcc12bed --- /dev/null +++ b/dom/animation/test/mozilla/test_spacing_property_order.html @@ -0,0 +1,14 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id="log"></div> +<script> +'use strict'; +setup({explicit_done: true}); +SpecialPowers.pushPrefEnv( + { "set": [["dom.animations-api.core.enabled", true]]}, + function() { + window.open("file_spacing_property_order.html"); + }); +</script> diff --git a/dom/animation/test/mozilla/test_spacing_transform.html b/dom/animation/test/mozilla/test_spacing_transform.html new file mode 100644 index 000000000..38dce7e99 --- /dev/null +++ b/dom/animation/test/mozilla/test_spacing_transform.html @@ -0,0 +1,14 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id="log"></div> +<script> +'use strict'; +setup({explicit_done: true}); +SpecialPowers.pushPrefEnv( + { "set": [["dom.animations-api.core.enabled", true]]}, + function() { + window.open("file_spacing_transform.html"); + }); +</script> diff --git a/dom/animation/test/mozilla/test_transform_limits.html b/dom/animation/test/mozilla/test_transform_limits.html new file mode 100644 index 000000000..6c9b5e4fa --- /dev/null +++ b/dom/animation/test/mozilla/test_transform_limits.html @@ -0,0 +1,14 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id="log"></div> +<script> +'use strict'; +setup({explicit_done: true}); +SpecialPowers.pushPrefEnv( + { "set": [["dom.animations-api.core.enabled", true]]}, + function() { + window.open("file_transform_limits.html"); + }); +</script> diff --git a/dom/animation/test/mozilla/test_transition_finish_on_compositor.html b/dom/animation/test/mozilla/test_transition_finish_on_compositor.html new file mode 100644 index 000000000..357e5297e --- /dev/null +++ b/dom/animation/test/mozilla/test_transition_finish_on_compositor.html @@ -0,0 +1,14 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id="log"></div> +<script> +'use strict'; +setup({explicit_done: true}); +SpecialPowers.pushPrefEnv( + { "set": [["dom.animations-api.core.enabled", true]]}, + function() { + window.open("file_transition_finish_on_compositor.html"); + }); +</script> diff --git a/dom/animation/test/mozilla/test_underlying-discrete-value.html b/dom/animation/test/mozilla/test_underlying-discrete-value.html new file mode 100644 index 000000000..7feee53a1 --- /dev/null +++ b/dom/animation/test/mozilla/test_underlying-discrete-value.html @@ -0,0 +1,15 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id="log"></div> +<script> +'use strict'; +setup({explicit_done: true}); +SpecialPowers.pushPrefEnv( + { "set": [["dom.animations-api.core.enabled", true]]}, + function() { + window.open("file_underlying-discrete-value.html"); + }); +</script> +</html> diff --git a/dom/animation/test/style/file_animation-seeking-with-current-time.html b/dom/animation/test/style/file_animation-seeking-with-current-time.html new file mode 100644 index 000000000..c3a590394 --- /dev/null +++ b/dom/animation/test/style/file_animation-seeking-with-current-time.html @@ -0,0 +1,121 @@ +<!doctype html> +<html> + <head> + <meta charset=utf-8> + <title>Tests for seeking using Animation.currentTime</title> + <style> +.animated-div { + margin-left: -10px; + animation-timing-function: linear ! important; +} + +@keyframes anim { + from { margin-left: 0px; } + to { margin-left: 100px; } +} + </style> + <script src="../testcommon.js"></script> + </head> + <body> + <script type="text/javascript"> +'use strict'; + +function assert_marginLeft_equals(target, expect, description) { + var marginLeft = parseFloat(getComputedStyle(target).marginLeft); + assert_equals(marginLeft, expect, description); +} + +promise_test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + div.style.animation = "anim 100s"; + var animation = div.getAnimations()[0]; + + return animation.ready.then(function() { + animation.currentTime = 90 * MS_PER_SEC; + assert_marginLeft_equals(div, 90, + 'Computed style is updated when seeking forwards in active interval'); + + animation.currentTime = 10 * MS_PER_SEC; + assert_marginLeft_equals(div, 10, + 'Computed style is updated when seeking backwards in active interval'); + }); +}, 'Seeking forwards and backward in active interval'); + +promise_test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + div.style.animation = "anim 100s 100s"; + var animation = div.getAnimations()[0]; + + return animation.ready.then(function(t) { + assert_marginLeft_equals(div, -10, + 'Computed style is unaffected in before phase with no backwards fill'); + + // before -> active (non-active -> active) + animation.currentTime = 150 * MS_PER_SEC; + assert_marginLeft_equals(div, 50, + 'Computed style is updated when seeking forwards from ' + + 'not \'in effect\' to \'in effect\' state'); + }); +}, 'Seeking to non-\'in effect\' from \'in effect\' (before -> active)'); + +promise_test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + div.style.animation = "anim 100s 100s"; + var animation = div.getAnimations()[0]; + + return animation.ready.then(function(t) { + // move to after phase + animation.currentTime = 250 * MS_PER_SEC; + assert_marginLeft_equals(div, -10, + 'Computed style is unaffected in after phase with no forwards fill'); + + // after -> active (non-active -> active) + animation.currentTime = 150 * MS_PER_SEC; + assert_marginLeft_equals(div, 50, + 'Computed style is updated when seeking backwards from ' + + 'not \'in effect\' to \'in effect\' state'); + }); +}, 'Seeking to non-\'in effect\' from \'in effect\' (after -> active)'); + +promise_test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + div.style.animation = "anim 100s 100s"; + var animation = div.getAnimations()[0]; + + return animation.ready.then(function(t) { + // move to active phase + animation.currentTime = 150 * MS_PER_SEC; + assert_marginLeft_equals(div, 50, + 'Computed value is set during active phase'); + + // active -> before + animation.currentTime = 50 * MS_PER_SEC; + assert_marginLeft_equals(div, -10, + 'Computed value is not effected after seeking backwards from ' + + '\'in effect\' to not \'in effect\' state'); + }); +}, 'Seeking to \'in effect\' from non-\'in effect\' (active -> before)'); + +promise_test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + div.style.animation = "anim 100s 100s"; + var animation = div.getAnimations()[0]; + + return animation.ready.then(function(t) { + // move to active phase + animation.currentTime = 150 * MS_PER_SEC; + assert_marginLeft_equals(div, 50, + 'Computed value is set during active phase'); + + // active -> after + animation.currentTime = 250 * MS_PER_SEC; + assert_marginLeft_equals(div, -10, + 'Computed value is not affected after seeking forwards from ' + + '\'in effect\' to not \'in effect\' state'); + }); +}, 'Seeking to \'in effect\' from non-\'in effect\' (active -> after)'); + +done(); + </script> + </body> +</html> diff --git a/dom/animation/test/style/file_animation-seeking-with-start-time.html b/dom/animation/test/style/file_animation-seeking-with-start-time.html new file mode 100644 index 000000000..ba09827c6 --- /dev/null +++ b/dom/animation/test/style/file_animation-seeking-with-start-time.html @@ -0,0 +1,121 @@ +<!doctype html> +<html> + <head> + <meta charset=utf-8> + <title>Tests for seeking using Animation.startTime</title> + <style> +.animated-div { + margin-left: -10px; + animation-timing-function: linear ! important; +} + +@keyframes anim { + from { margin-left: 0px; } + to { margin-left: 100px; } +} + </style> + <script src="../testcommon.js"></script> + </head> + <body> + <script type="text/javascript"> +'use strict'; + +function assert_marginLeft_equals(target, expect, description) { + var marginLeft = parseFloat(getComputedStyle(target).marginLeft); + assert_equals(marginLeft, expect, description); +} + +promise_test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + div.style.animation = "anim 100s"; + var animation = div.getAnimations()[0]; + + return animation.ready.then(function() { + animation.startTime = animation.timeline.currentTime - 90 * MS_PER_SEC + assert_marginLeft_equals(div, 90, + 'Computed style is updated when seeking forwards in active interval'); + + animation.startTime = animation.timeline.currentTime - 10 * MS_PER_SEC; + assert_marginLeft_equals(div, 10, + 'Computed style is updated when seeking backwards in active interval'); + }); +}, 'Seeking forwards and backward in active interval'); + +promise_test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + div.style.animation = "anim 100s 100s"; + var animation = div.getAnimations()[0]; + + return animation.ready.then(function(t) { + assert_marginLeft_equals(div, -10, + 'Computed style is unaffected in before phase with no backwards fill'); + + // before -> active (non-active -> active) + animation.startTime = animation.timeline.currentTime - 150 * MS_PER_SEC; + assert_marginLeft_equals(div, 50, + 'Computed style is updated when seeking forwards from ' + + 'not \'in effect\' to \'in effect\' state'); + }); +}, 'Seeking to non-\'in effect\' from \'in effect\' (before -> active)'); + +promise_test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + div.style.animation = "anim 100s 100s"; + var animation = div.getAnimations()[0]; + + return animation.ready.then(function(t) { + // move to after phase + animation.startTime = animation.timeline.currentTime - 250 * MS_PER_SEC; + assert_marginLeft_equals(div, -10, + 'Computed style is unaffected in after phase with no forwards fill'); + + // after -> active (non-active -> active) + animation.startTime = animation.timeline.currentTime - 150 * MS_PER_SEC; + assert_marginLeft_equals(div, 50, + 'Computed style is updated when seeking backwards from ' + + 'not \'in effect\' to \'in effect\' state'); + }); +}, 'Seeking to non-\'in effect\' from \'in effect\' (after -> active)'); + +promise_test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + div.style.animation = "anim 100s 100s"; + var animation = div.getAnimations()[0]; + + return animation.ready.then(function(t) { + // move to active phase + animation.startTime = animation.timeline.currentTime - 150 * MS_PER_SEC; + assert_marginLeft_equals(div, 50, + 'Computed value is set during active phase'); + + // active -> before + animation.startTime = animation.timeline.currentTime - 50 * MS_PER_SEC; + assert_marginLeft_equals(div, -10, + 'Computed value is not affected after seeking backwards from ' + + '\'in effect\' to not \'in effect\' state'); + }); +}, 'Seeking to \'in effect\' from non-\'in effect\' (active -> before)'); + +promise_test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + div.style.animation = "anim 100s 100s"; + var animation = div.getAnimations()[0]; + + return animation.ready.then(function(t) { + // move to active phase + animation.startTime = animation.timeline.currentTime - 150 * MS_PER_SEC; + assert_marginLeft_equals(div, 50, + 'Computed value is set during active phase'); + + // active -> after + animation.startTime = animation.timeline.currentTime - 250 * MS_PER_SEC; + assert_marginLeft_equals(div, -10, + 'Computed value is not affected after seeking forwards from ' + + '\'in effect\' to not \'in effect\' state'); + }); +}, 'Seeking to \'in effect\' from non-\'in effect\' (active -> after)'); + +done(); + </script> + </body> +</html> diff --git a/dom/animation/test/style/file_animation-setting-effect.html b/dom/animation/test/style/file_animation-setting-effect.html new file mode 100644 index 000000000..cf50cf2ce --- /dev/null +++ b/dom/animation/test/style/file_animation-setting-effect.html @@ -0,0 +1,125 @@ +<!doctype html> +<html> + <head> + <meta charset=utf-8> + <title>Tests for setting effects by using Animation.effect</title> + <script src='../testcommon.js'></script> + </head> + <body> + <script type='text/javascript'> + +'use strict'; + +test(function(t) { + var target = addDiv(t); + var anim = new Animation(); + anim.effect = new KeyframeEffectReadOnly(target, + { marginLeft: [ '0px', '100px' ] }, + 100 * MS_PER_SEC); + anim.currentTime = 50 * MS_PER_SEC; + assert_equals(getComputedStyle(target).marginLeft, '50px'); +}, 'After setting target effect on an animation with null effect, the ' + + 'animation still works'); + +test(function(t) { + var target = addDiv(t); + target.style.marginLeft = '10px'; + var anim = target.animate({ marginLeft: [ '0px', '100px' ] }, + 100 * MS_PER_SEC); + anim.currentTime = 50 * MS_PER_SEC; + assert_equals(getComputedStyle(target).marginLeft, '50px'); + + anim.effect = null; + assert_equals(getComputedStyle(target).marginLeft, '10px'); +}, 'After setting null target effect, the computed style of the target ' + + 'element becomes the initial value'); + +test(function(t) { + var target = addDiv(t); + var animA = target.animate({ marginLeft: [ '0px', '100px' ] }, + 100 * MS_PER_SEC); + var animB = new Animation(); + animA.currentTime = 50 * MS_PER_SEC; + animB.currentTime = 20 * MS_PER_SEC; + assert_equals(getComputedStyle(target).marginLeft, '50px', + 'original computed style of the target element'); + + animB.effect = animA.effect; + assert_equals(getComputedStyle(target).marginLeft, '20px', + 'new computed style of the target element'); +}, 'After setting the target effect from an existing animation, the computed ' + + 'style of the target effect should reflect the time of the updated ' + + 'animation.'); + +test(function(t) { + var target = addDiv(t); + target.style.marginTop = '-10px'; + var animA = target.animate({ marginLeft: [ '0px', '100px' ] }, + 100 * MS_PER_SEC); + var animB = target.animate({ marginTop: [ '0px', '100px' ] }, + 50 * MS_PER_SEC); + animA.currentTime = 50 * MS_PER_SEC; + animB.currentTime = 10 * MS_PER_SEC; + assert_equals(getComputedStyle(target).marginLeft, '50px', + 'original margin-left of the target element'); + assert_equals(getComputedStyle(target).marginTop, '20px', + 'original margin-top of the target element'); + + animB.effect = animA.effect; + assert_equals(getComputedStyle(target).marginLeft, '10px', + 'new margin-left of the target element'); + assert_equals(getComputedStyle(target).marginTop, '-10px', + 'new margin-top of the target element'); +}, 'After setting target effect with an animation to another animation which ' + + 'also has an target effect and both animation effects target to the same ' + + 'element, the computed style of this element should reflect the time and ' + + 'effect of the animation that was set'); + +test(function(t) { + var targetA = addDiv(t); + var targetB = addDiv(t); + targetB.style.marginLeft = '-10px'; + var animA = targetA.animate({ marginLeft: [ '0px', '100px' ] }, + 100 * MS_PER_SEC); + var animB = targetB.animate({ marginLeft: [ '0px', '100px' ] }, + 50 * MS_PER_SEC); + animA.currentTime = 50 * MS_PER_SEC; + animB.currentTime = 10 * MS_PER_SEC; + assert_equals(getComputedStyle(targetA).marginLeft, '50px', + 'original margin-left of the first element'); + assert_equals(getComputedStyle(targetB).marginLeft, '20px', + 'original margin-left of the second element'); + + animB.effect = animA.effect; + assert_equals(getComputedStyle(targetA).marginLeft, '10px', + 'new margin-left of the first element'); + assert_equals(getComputedStyle(targetB).marginLeft, '-10px', + 'new margin-left of the second element'); +}, 'After setting target effect with an animation to another animation which ' + + 'also has an target effect and these animation effects target to ' + + 'different elements, the computed styles of the two elements should ' + + 'reflect the time and effect of the animation that was set'); + +test(function(t) { + var target = addDiv(t); + var animA = target.animate({ marginLeft: [ '0px', '100px' ] }, + 50 * MS_PER_SEC); + var animB = target.animate({ marginTop: [ '0px', '50px' ] }, + 100 * MS_PER_SEC); + animA.currentTime = 20 * MS_PER_SEC; + animB.currentTime = 30 * MS_PER_SEC; + assert_equals(getComputedStyle(target).marginLeft, '40px'); + assert_equals(getComputedStyle(target).marginTop, '15px'); + + var effectA = animA.effect; + animA.effect = animB.effect; + animB.effect = effectA; + assert_equals(getComputedStyle(target).marginLeft, '60px'); + assert_equals(getComputedStyle(target).marginTop, '10px'); +}, 'After swapping effects of two playing animations, both animations are ' + + 'still running with the same current time'); + +done(); + </script> + </body> +</html> diff --git a/dom/animation/test/style/file_animation-setting-spacing.html b/dom/animation/test/style/file_animation-setting-spacing.html new file mode 100644 index 000000000..6098b7433 --- /dev/null +++ b/dom/animation/test/style/file_animation-setting-spacing.html @@ -0,0 +1,111 @@ +<!doctype html> +<head> +<meta charset=utf-8> +<title>Tests for setting spacing by using KeyframeEffect.spacing</title> +<script src='../testcommon.js'></script> +</head> +<body> +<script> +'use strict'; + +function calculateInterpolation(pacedDistances, values, progress) { + if (progress == 0.0) { + return values[0]; + } else if (progress == 1.0) { + return values[valus.length - 1]; + } + + const cumDist = pacedDistances.reduce( (prev, curr) => { + prev.push(prev.length == 0 ? curr : curr + prev[prev.length - 1]); + return prev; + }, []); + + const last = cumDist[cumDist.length - 1]; + const offsets = cumDist.map( (curr) => { return curr / last; } ); + + let idx = 0; + for (let i = 0; i < offsets.length - 1; ++i) { + if (progress >= offsets[i] && progress < offsets[i + 1]) { + idx = i; + break; + } + } + + const ratio = (progress - offsets[idx]) / (offsets[idx + 1] - offsets[idx]); + return values[idx] + ratio * (values[idx + 1] - values[idx]) + 'px'; +} + +promise_test(function(t) { + var target = addDiv(t); + var anim = target.animate([ { marginLeft: '0px' }, + { marginLeft: '-20px' }, + { marginLeft: '100px' }, + { marginLeft: '50px' } ], + 100 * MS_PER_SEC); + + return anim.ready.then(function() { + anim.currentTime = 50 * MS_PER_SEC; + assert_equals(getComputedStyle(target).marginLeft, '40px', + 'computed value before setting a new spacing'); + + var dist = [0, 20, 120, 50]; + var marginLeftValues = [0, -20, 100, 50]; + anim.effect.spacing = 'paced(margin-left)'; + assert_equals(getComputedStyle(target).marginLeft, + calculateInterpolation(dist, marginLeftValues, 0.5), + 'computed value after setting a new spacing'); + }); +}, 'Test for setting spacing from distribute to paced'); + +promise_test(function(t) { + var target = addDiv(t); + var anim = target.animate([ { marginLeft: '0px' }, + { marginLeft: '-20px' }, + { marginLeft: '100px' }, + { marginLeft: '50px' } ], + { duration: 100 * MS_PER_SEC, + spacing: 'paced(margin-left)' }); + + return anim.ready.then(function() { + var dist = [0, 20, 120, 50]; + var marginLeftValues = [0, -20, 100, 50]; + anim.currentTime = 50 * MS_PER_SEC; + assert_equals(getComputedStyle(target).marginLeft, + calculateInterpolation(dist, marginLeftValues, 0.5), + 'computed value before setting a new spacing'); + + anim.effect.spacing = 'distribute'; + assert_equals(getComputedStyle(target).marginLeft, '40px', + 'computed value after setting a new spacing'); + }); +}, 'Test for setting spacing from paced to distribute'); + +promise_test(function(t) { + var target = addDiv(t); + var anim = + target.animate([ { marginLeft: '0px', borderRadius: '0%' }, + { marginLeft: '-20px', borderRadius: '50%' }, + { marginLeft: '100px', borderRadius: '25%' }, + { marginLeft: '50px', borderRadius: '100%' } ], + { duration: 100 * MS_PER_SEC, + spacing: 'paced(margin-left)' }); + + return anim.ready.then(function() { + var dist = [0, 20, 120, 50]; + var marginLeftValues = [0, -20, 100, 50]; + anim.currentTime = 50 * MS_PER_SEC; + assert_equals(getComputedStyle(target).marginLeft, + calculateInterpolation(dist, marginLeftValues, 0.5), + 'computed value before setting a new spacing'); + + dist = [0, 50, 25, 75]; + anim.effect.spacing = 'paced(border-radius)'; + assert_equals(getComputedStyle(target).marginLeft, + calculateInterpolation(dist, marginLeftValues, 0.5), + 'computed value after setting a new spacing'); + }); +}, 'Test for setting spacing from paced to a different paced'); + +done(); +</script> +</body> diff --git a/dom/animation/test/style/test_animation-seeking-with-current-time.html b/dom/animation/test/style/test_animation-seeking-with-current-time.html new file mode 100644 index 000000000..386e57788 --- /dev/null +++ b/dom/animation/test/style/test_animation-seeking-with-current-time.html @@ -0,0 +1,15 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id="log"></div> +<script> +'use strict'; +setup({explicit_done: true}); +SpecialPowers.pushPrefEnv( + { "set": [["dom.animations-api.core.enabled", true]]}, + function() { + window.open("file_animation-seeking-with-current-time.html"); + }); +</script> +</html> diff --git a/dom/animation/test/style/test_animation-seeking-with-start-time.html b/dom/animation/test/style/test_animation-seeking-with-start-time.html new file mode 100644 index 000000000..0a1691a08 --- /dev/null +++ b/dom/animation/test/style/test_animation-seeking-with-start-time.html @@ -0,0 +1,15 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id="log"></div> +<script> +'use strict'; +setup({explicit_done: true}); +SpecialPowers.pushPrefEnv( + { "set": [["dom.animations-api.core.enabled", true]]}, + function() { + window.open("file_animation-seeking-with-start-time.html"); + }); +</script> +</html> diff --git a/dom/animation/test/style/test_animation-setting-effect.html b/dom/animation/test/style/test_animation-setting-effect.html new file mode 100644 index 000000000..1199b3e75 --- /dev/null +++ b/dom/animation/test/style/test_animation-setting-effect.html @@ -0,0 +1,15 @@ +<!doctype html> +<meta charset=utf-8> +<script src='/resources/testharness.js'></script> +<script src='/resources/testharnessreport.js'></script> +<div id='log'></div> +<script> +'use strict'; +setup({explicit_done: true}); +SpecialPowers.pushPrefEnv( + { 'set': [['dom.animations-api.core.enabled', true]]}, + function() { + window.open('file_animation-setting-effect.html'); + }); +</script> +</html> diff --git a/dom/animation/test/style/test_animation-setting-spacing.html b/dom/animation/test/style/test_animation-setting-spacing.html new file mode 100644 index 000000000..1c703e2a3 --- /dev/null +++ b/dom/animation/test/style/test_animation-setting-spacing.html @@ -0,0 +1,14 @@ +<!doctype html> +<meta charset=utf-8> +<script src='/resources/testharness.js'></script> +<script src='/resources/testharnessreport.js'></script> +<div id='log'></div> +<script> +'use strict'; +setup({explicit_done: true}); +SpecialPowers.pushPrefEnv( + { 'set': [['dom.animations-api.core.enabled', true]]}, + function() { + window.open('file_animation-setting-spacing.html'); + }); +</script> diff --git a/dom/animation/test/testcommon.js b/dom/animation/test/testcommon.js new file mode 100644 index 000000000..b9001e4f4 --- /dev/null +++ b/dom/animation/test/testcommon.js @@ -0,0 +1,216 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Use this variable if you specify duration or some other properties + * for script animation. + * E.g., div.animate({ opacity: [0, 1] }, 100 * MS_PER_SEC); + * + * NOTE: Creating animations with short duration may cause intermittent + * failures in asynchronous test. For example, the short duration animation + * might be finished when animation.ready has been fulfilled because of slow + * platforms or busyness of the main thread. + * Setting short duration to cancel its animation does not matter but + * if you don't want to cancel the animation, consider using longer duration. + */ +const MS_PER_SEC = 1000; + +/* The recommended minimum precision to use for time values[1]. + * + * [1] https://w3c.github.io/web-animations/#precision-of-time-values + */ +var TIME_PRECISION = 0.0005; // ms + +/* + * Allow implementations to substitute an alternative method for comparing + * times based on their precision requirements. + */ +function assert_times_equal(actual, expected, description) { + assert_approx_equals(actual, expected, TIME_PRECISION, description); +} + +/** + * Appends a div to the document body and creates an animation on the div. + * NOTE: This function asserts when trying to create animations with durations + * shorter than 100s because the shorter duration may cause intermittent + * failures. If you are not sure how long it is suitable, use 100s; it's + * long enough but shorter than our test framework timeout (330s). + * If you really need to use shorter durations, use animate() function directly. + * + * @param t The testharness.js Test object. If provided, this will be used + * to register a cleanup callback to remove the div when the test + * finishes. + * @param attrs A dictionary object with attribute names and values to set on + * the div. + * @param frames The keyframes passed to Element.animate(). + * @param options The options passed to Element.animate(). + */ +function addDivAndAnimate(t, attrs, frames, options) { + let animDur = (typeof options === 'object') ? + options.duration : options; + assert_greater_than_equal(animDur, 100 * MS_PER_SEC, + 'Clients of this addDivAndAnimate API must request a duration ' + + 'of at least 100s, to avoid intermittent failures from e.g.' + + 'the main thread being busy for an extended period'); + + return addDiv(t, attrs).animate(frames, options); +} + +/** + * Appends a div to the document body. + * + * @param t The testharness.js Test object. If provided, this will be used + * to register a cleanup callback to remove the div when the test + * finishes. + * + * @param attrs A dictionary object with attribute names and values to set on + * the div. + */ +function addDiv(t, attrs) { + var div = document.createElement('div'); + if (attrs) { + for (var attrName in attrs) { + div.setAttribute(attrName, attrs[attrName]); + } + } + document.body.appendChild(div); + if (t && typeof t.add_cleanup === 'function') { + t.add_cleanup(function() { + if (div.parentNode) { + div.parentNode.removeChild(div); + } + }); + } + return div; +} + +/** + * Appends a style div to the document head. + * + * @param t The testharness.js Test object. If provided, this will be used + * to register a cleanup callback to remove the style element + * when the test finishes. + * + * @param rules A dictionary object with selector names and rules to set on + * the style sheet. + */ +function addStyle(t, rules) { + var extraStyle = document.createElement('style'); + document.head.appendChild(extraStyle); + if (rules) { + var sheet = extraStyle.sheet; + for (var selector in rules) { + sheet.insertRule(selector + '{' + rules[selector] + '}', + sheet.cssRules.length); + } + } + + if (t && typeof t.add_cleanup === 'function') { + t.add_cleanup(function() { + extraStyle.remove(); + }); + } +} + +/** + * Promise wrapper for requestAnimationFrame. + */ +function waitForFrame() { + return new Promise(function(resolve, reject) { + window.requestAnimationFrame(resolve); + }); +} + +/** + * Returns a Promise that is resolved after the given number of consecutive + * animation frames have occured (using requestAnimationFrame callbacks). + * + * @param frameCount The number of animation frames. + * @param onFrame An optional function to be processed in each animation frame. + */ +function waitForAnimationFrames(frameCount, onFrame) { + return new Promise(function(resolve, reject) { + function handleFrame() { + if (onFrame && typeof onFrame === 'function') { + onFrame(); + } + if (--frameCount <= 0) { + resolve(); + } else { + window.requestAnimationFrame(handleFrame); // wait another frame + } + } + window.requestAnimationFrame(handleFrame); + }); +} + +/** + * Wrapper that takes a sequence of N animations and returns: + * + * Promise.all([animations[0].ready, animations[1].ready, ... animations[N-1].ready]); + */ +function waitForAllAnimations(animations) { + return Promise.all(animations.map(function(animation) { + return animation.ready; + })); +} + +/** + * Flush the computed style for the given element. This is useful, for example, + * when we are testing a transition and need the initial value of a property + * to be computed so that when we synchronouslyet set it to a different value + * we actually get a transition instead of that being the initial value. + */ +function flushComputedStyle(elem) { + var cs = window.getComputedStyle(elem); + cs.marginLeft; +} + +if (opener) { + for (var funcName of ["async_test", "assert_not_equals", "assert_equals", + "assert_approx_equals", "assert_less_than", + "assert_less_than_equal", "assert_greater_than", + "assert_between_inclusive", + "assert_true", "assert_false", + "assert_class_string", "assert_throws", + "assert_unreached", "promise_test", "test"]) { + window[funcName] = opener[funcName].bind(opener); + } + + window.EventWatcher = opener.EventWatcher; + + function done() { + opener.add_completion_callback(function() { + self.close(); + }); + opener.done(); + } +} + +/** + * Return a new MutaionObserver which started observing |target| element + * with { animations: true, subtree: |subtree| } option. + * NOTE: This observer should be used only with takeRecords(). If any of + * MutationRecords are observed in the callback of the MutationObserver, + * it will raise an assertion. + */ +function setupSynchronousObserver(t, target, subtree) { + var observer = new MutationObserver(records => { + assert_unreached("Any MutationRecords should not be observed in this " + + "callback"); + }); + t.add_cleanup(() => { + observer.disconnect(); + }); + observer.observe(target, { animations: true, subtree: subtree }); + return observer; +} + +/** + * Returns true if off-main-thread animations. + */ +function isOMTAEnabled() { + const OMTAPrefKey = 'layers.offmainthreadcomposition.async-animations'; + return SpecialPowers.DOMWindowUtils.layerManagerRemote && + SpecialPowers.getBoolPref(OMTAPrefKey); +} |